diff --git a/.claude/skills/screenshot/SKILL.MD b/.claude/skills/screenshot/SKILL.MD deleted file mode 100644 index b542d1df8..000000000 --- a/.claude/skills/screenshot/SKILL.MD +++ /dev/null @@ -1,62 +0,0 @@ ---- -name: screenshot -description: Regenerate real product screenshots — either CLI screenshots (actual `basilisk check` output rendered in a Terminal window) or VS Code editor screenshots (the extension showing diagnostics, hover, quick-fix, activity panel). Use when the user asks to regenerate, update, or add marketing/docs screenshots. Requires a target argument, `cli` or `vsix`. -argument-hint: "cli|vsix [cli-shot-names...]" -arguments: target -allowed-tools: Bash(npm run *), Bash(node screenshots/*), Bash(cargo build *) ---- - -# Regenerate product screenshots - -Screenshots must be **real output of the actual binary/extension**, never hand-typed code fences or synthetic renders (those drift and mislead). Both suites write to the canonical location `website/src/assets/images/` and are committed; CI only verifies they render (`website/tests/e2e/screenshots.spec.ts`), never captures, per `[GITHUB-NO-ARTIFACTS]`. - -## Step 1 — Resolve the target - -The target is `$target` (`$ARGUMENTS`). - -- `cli` → CLI screenshots. Go to **Step 2a**. -- `vsix` (or `vscode`) → VS Code editor screenshots. Go to **Step 2b**. -- **Empty or anything else** → STOP and ask the user which target they want: `cli` or `vsix`. Do not guess — the two paths capture different images with different tooling. - -## Step 2a — CLI screenshots - -Real `basilisk check --color always` output rendered in a faithful macOS Terminal window via Playwright. See `[WEBSITE-SCREENSHOTS]` (`docs/specs/WEBSITE-SCREENSHOTS-SPEC.md`). - -Images: rule shots `e0001.png`…`e0025.png` (named after the code), plus the homepage pair `cli-demo.png` (errors) + `cli-clean.png` (pass). Snippet→expected-code pairings live in `website/screenshots/shots.mjs`; `generate.mjs` **asserts the documented diagnostic actually fires**, so a checker change can't silently ship a misleading image. - -From `website/`: - -```bash -npm run screenshots # regenerate every image -node screenshots/generate.mjs e0001 e0012 # only the named shots -BASILISK_BIN=../target/release/basilisk npm run screenshots # pin the binary -``` - -If the user passed extra args after `cli` (e.g. `e0001 e0012`), pass them through to `node screenshots/generate.mjs ` to regenerate only those. Otherwise regenerate all. - -To **add or change** a shot, edit `website/screenshots/shots.mjs` (snippet + expected code) and rerun — never craft images by hand. After regenerating, run `npm run build` and confirm the images copied to `website/_site/assets/images/`. - -## Step 2b — VS Code editor screenshots - -Headed VS Code capture of the running extension (diagnostics, hover, quick-fix, activity panel) via a dependency-free CDP sidecar (`vscode-extension/screenshot-watcher.mjs`). See `[VSIX-EDITOR-SCREENSHOTS]` (`docs/specs/VSIX-EDITOR-SCREENSHOTS-SPEC.md`). - -Images: `vscode-*.png` in `website/src/assets/images/`. - -The suite is a no-op without the `BASILISK_SCREENSHOTS=1` flag (which `npm run screenshots:editor` sets), so normal `npm test` never opens these windows. - -First build the binaries the extension stages, then run the suite. From `vscode-extension/`: - -```bash -cargo build -p basilisk-cli -p basilisk-profiler-helper -npm run screenshots:editor -``` - -`screenshots:editor` stages the freshly built binary into the dev extension, copies `shipwright.json`, launches the **headed** "Editor screenshots" suite, and the sidecar captures each window to `website/src/assets/images/vscode-*.png`. - -To **add** an editor screenshot, add a `test(...)` that makes the feature visible and calls `takeWindowScreenshot(...)`, then rerun. - -> ⚠️ Never kill a VS Code process (per CLAUDE.md) — it disrupts active debugging/test sessions. Let the headed suite open and close its own windows. - -## Step 3 — Report - -Report exactly which images were regenerated and their paths. If a run asserted a diagnostic and it failed to fire, surface that failure — do not commit a misleading image. diff --git a/.github/release-templates/basilisk.json.tmpl b/.github/release-templates/basilisk.json.tmpl index 76aff65e0..e3adcead3 100644 --- a/.github/release-templates/basilisk.json.tmpl +++ b/.github/release-templates/basilisk.json.tmpl @@ -1,6 +1,6 @@ { "version": "${BASILISK_VERSION}", - "description": "Strict-by-default Python type checker and LSP, built in Rust", + "description": "Basilisk's type checker produced incorrect results. Basilisk is unlisted and is being rebuilt from the ground up as a new product.", "homepage": "https://www.basilisk-python.dev", "license": "MIT", "architecture": { diff --git a/.github/release-templates/basilisk.rb.tmpl b/.github/release-templates/basilisk.rb.tmpl index e88948e1c..2b7ff498d 100644 --- a/.github/release-templates/basilisk.rb.tmpl +++ b/.github/release-templates/basilisk.rb.tmpl @@ -2,7 +2,7 @@ # frozen_string_literal: true class Basilisk < Formula - desc "Strict-by-default Python type checker and LSP, built in Rust" + desc "Basilisk's type checker produced incorrect results. Basilisk is unlisted and is being rebuilt from the ground up as a new product." homepage "https://www.basilisk-python.dev" version "${BASILISK_VERSION}" license "MIT" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index da233c9c0..161549f80 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,10 +26,9 @@ env: # anyway. Verified empirically: with RUSTFLAGS unset the config flag reaches # rustc; with RUSTFLAGS set it reaches it zero times. # - # Three jobs override this per-job because they link something that is not - # x86_64 linux and would reject `-fuse-ld=lld`: `website` (wasm32-unknown- - # unknown, and it re-adds the playground's 16 MiB stack for the same reason), - # `zed` (wasm32-wasip2), and `test-vscode-windows` (MSVC link.exe). Job-level + # Two jobs override this per-job because they link something that is not + # x86_64 linux and would reject `-fuse-ld=lld`: `zed` (wasm32-wasip2) and + # `test-vscode-windows` (MSVC link.exe). Job-level # `env` merges per-key, so every other job inherits both flags automatically. RUSTFLAGS: "-D warnings -C link-arg=-fuse-ld=lld" @@ -132,30 +131,31 @@ jobs: # `website` gates the site build: only files the published site is # actually built from (templates/assets + the benchmark CSVs it reads). case "$f" in - # The site build, plus the checker rule sources the diagnostic - # data (website/src/_data/rules.json) and /errors/ pages are - # generated from — so a new/renamed rule re-runs the drift guard. - website/*|benchmarks/status/*|crates/basilisk-checker/src/rules/*) website=true ;; - # The playground ships the checker itself, compiled to wasm at - # site-build time ([WASM-BUILD]). A change to ANY crate the - # playground links changes what the published site answers, so the - # site build and its playground e2e must re-run to catch a - # browser-only break. This list is `basilisk-wasm`'s dependency - # closure; extend it when that closure grows. The release deploy - # publishes whatever is on `main`, unfiltered — so a crate that - # skips this check reaches the live playground unverified. - crates/basilisk-wasm/*|crates/basilisk-checker/*|crates/basilisk-common/*) website=true ;; - crates/basilisk-config/*|crates/basilisk-db/*|crates/basilisk-parser/*) website=true ;; - crates/basilisk-resolver/*|crates/basilisk-stubs/*|crates/basilisk-uv/*) website=true ;; - Cargo.lock) 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. 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 ;; + # The site build, plus the messaging spec its every published word + # is extracted from ([WITHDRAWAL-COPY]) — so editing the spec + # re-runs the drift guard and the site build. + website/*|docs/specs/DOCS-WITHDRAWAL-MESSAGING-SPEC.md) website=true ;; + # The generators themselves: a change to either one can move the + # published words without touching the spec or a template. + scripts/gen_withdrawal_copy.py|scripts/gen_readmes.py) website=true ;; + scripts/test_published_readmes.py) website=true ;; + # The public-copy scan and its own tests ([WITHDRAWAL-SURFACES]). + scripts/check_public_copy.py) website=true ;; + scripts/test_check_public_copy.py) website=true ;; + # Every other surface the scan reads: a marketing sentence in any + # of them contradicts the statement just as loudly as one in a + # README, so a change to one must re-run the scan. + CONTRIBUTING.md|SECURITY.md|pyproject.toml) website=true ;; + crates/*/README.md|book/README.md|delist/README.md) website=true ;; + vscode-extension/package.json|basilisk-zed/extension.toml) website=true ;; + basilisk.nvim/doc/*) website=true ;; + .github/release-templates/*) website=true ;; + # The published READMEs are generated from docs/readme/ ([README]), + # so touching a source OR a generated copy re-runs that guard too. + docs/readme/*) website=true ;; + README.md|README-pypi.md) website=true ;; + vscode-extension/README.md) website=true ;; + basilisk-zed/README.md|basilisk.nvim/README.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 @@ -201,26 +201,11 @@ jobs: needs: changes if: needs.changes.outputs.website == 'true' runs-on: ubuntu-24.04 - # Overrides the workflow-level RUSTFLAGS: this job links wasm32-unknown- - # unknown, which rejects `-fuse-ld=lld`. It also RESTORES the playground's - # 16 MiB stack ([WASM-BUILD]) — that flag lives in .cargo/config.toml, which - # the workflow-level RUSTFLAGS discards entirely (see the env block above), - # so this pre-merge check was compiling the playground with the 1 MiB wasm - # default while deploy-pages.yml (which sets no RUSTFLAGS) shipped it with - # 16 MiB. The check and the deploy now build the same thing. - env: - RUSTFLAGS: "-D warnings -C link-arg=-zstack-size=16777216" - # 20, not 10: this job compiles the checker to WebAssembly for the - # playground ([WASM-BUILD]) before Eleventy runs, and a cold wasm build of - # the parser + typeshed does not fit the old budget. - timeout-minutes: 20 + # The site is templates and one generated data file — no Rust, no wasm, no + # git history ([WITHDRAWAL-SURFACES]). + timeout-minutes: 10 steps: - # fetch-depth: 0 — mirrors deploy-pages.yml so the conformance over-time - # chart (built from the git history of conformance_status.csv) is exercised - # by the pre-merge build check, not just the post-merge deploy. - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - fetch-depth: 0 - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: @@ -228,70 +213,45 @@ jobs: cache: npm cache-dependency-path: website/package-lock.json - # The playground engine is the real checker compiled to wasm32, built by - # `npm run build:wasm` ([WASM-BUILD]). Declare the target explicitly - # rather than letting wasm-pack add it implicitly, so a missing target is - # a setup failure here instead of a confusing mid-build one. - - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable - with: - targets: wasm32-unknown-unknown - - - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 - with: - cache-on-failure: true - prefix-key: v1-rust-glibc - - name: Install dependencies working-directory: website run: npm ci - # The /errors/ pages and the rules reference are generated from the checker - # source ([WEBSITE-ERROR-PAGES]); fail if the committed data is stale so the - # pages the CLI deep-links to can never drift from the diagnostics it emits. - - name: Check generated diagnostic data is in sync with the checker + # Every word the site publishes is extracted from the messaging spec + # ([WITHDRAWAL-COPY]); fail if the committed data drifted from it, so the + # site can never say something the spec does not. + - name: Check site copy is in sync with the messaging spec + run: python3 scripts/gen_withdrawal_copy.py --check + + # Every published README renders from docs/readme/ ([README-DRIFT]) — + # GitHub, the VSIX (Marketplace AND Open VSX), and PyPI cannot drift apart + # or be edited in place. + - name: Check generated READMEs run: | - python3 scripts/gen_rules_reference.py --data /tmp/rules.json - 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 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 - - # Separate from `npm run build` ON PURPOSE. The Eleventy build has no Rust - # dependency — every page but the playground renders from committed data — - # so a checker that does not compile can no longer take the whole site - # down, locally or here. The playground e2e below drives the real engine, - # so this job still builds it explicitly. - - name: Build playground engine (wasm) - working-directory: website - run: npm run build:wasm + python3 scripts/gen_readmes.py --check + python3 scripts/test_published_readmes.py + + # The generated READMEs are five of ~34 public surfaces. This scans the + # rest — crate READMEs, the security policy, the package manifests, the + # store descriptions, the site templates — for anything + # [WITHDRAWAL-PROHIBITED] bars. + - name: Scan every public surface for prohibited copy + run: | + python3 scripts/check_public_copy.py + python3 scripts/test_check_public_copy.py - name: Build site working-directory: website - # GITHUB_TOKEN raises the GitHub API rate limit for _data/releases.js - # (the releases page is generated from the live Releases API at build - # time); the build still degrades gracefully if the call fails. - env: - GITHUB_TOKEN: ${{ github.token }} run: npm run build - # Navigation smoke tests ([WEBSITE-E2E-SMOKE]) and CLI-screenshot render - # checks ([WEBSITE-SCREENSHOTS-VERIFY]). Both presets (Desktop Chrome + - # Pixel 5) run on Chromium, so only chromium is installed. The screenshots - # are committed, regenerated locally with `npm run screenshots` against the - # real binary — CI only verifies they render, it never captures them. + # Withdrawal-contract tests ([WEBSITE-E2E-WITHDRAWAL]): the statement is + # the approved copy, every retired URL still resolves, and no page says + # anything [WITHDRAWAL-PROHIBITED] forbids. Both presets run on Chromium. - name: Install Playwright browser working-directory: website run: npx playwright install --with-deps chromium - - name: Run navigation + screenshot smoke tests (desktop + mobile) + - name: Run withdrawal-contract tests (desktop + mobile) working-directory: website # CI uses the stdout `list` reporter only — no HTML report, trace, # video or screenshot is produced or uploaded ([GITHUB-NO-ARTIFACTS]). @@ -490,41 +450,28 @@ jobs: test-vscode: name: VS Code Extension needs: changes - # Its own tree, or any core change — the e2e suite builds + runs the real - # release binary, so a checker/LSP change alters what this suite exercises. - if: needs.changes.outputs.core == 'true' || needs.changes.outputs.vscode == 'true' + # The extension is a notice ([WITHDRAWAL-SURFACES]): it bundles no binary + # and starts no language server, so a core Rust change can no longer alter + # what this suite exercises. Its own tree is the only trigger left. + if: needs.changes.outputs.vscode == 'true' runs-on: ubuntu-24.04 - # TIMEOUT EXCEPTION: VS Code extension e2e tests launch a full VS Code instance - # via xvfb (~25 min), plus one VS Code session per real-world corpus repo - # ([VSIX-REALWORLD-WIRING]: flask/rich/fastapi whole-workspace analysis) - timeout-minutes: 50 + # No Rust toolchain, no debugpy, no real-world corpus, no per-platform + # binary: packaging the VSIX and driving one small suite in a VS Code + # instance under xvfb is all that remains. + timeout-minutes: 20 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable - - - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 - with: - cache-on-failure: true - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 22.x cache: npm cache-dependency-path: vscode-extension/package-lock.json - - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 - with: - python-version: "3.12" - - - name: Install Python test dependencies - run: pip install debugpy==1.8.21 ruff==0.16.1 - - - name: Install lld, xvfb, and VS Code test dependencies + - name: Install xvfb and VS Code test dependencies run: | sudo apt-get update sudo apt-get install -y \ - lld \ xvfb \ libnspr4 \ libnss3 \ @@ -549,138 +496,55 @@ jobs: uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: vscode-extension/.vscode-test - key: vscode-test-${{ runner.os }}-${{ hashFiles('vscode-extension/.vscode-test.mjs', 'vscode-extension/.vscode-test.js', 'vscode-extension/package.json') }} + key: vscode-test-${{ runner.os }}-${{ hashFiles('vscode-extension/.vscode-test.mjs', 'vscode-extension/package.json') }} restore-keys: vscode-test-${{ runner.os }}- - # The real-world corpus ([VSIX-REALWORLD-CORPUS]) is pinned to exact - # commit SHAs in the manifest, so a cache hit is byte-identical to a - # fresh fetch; the fetch script re-validates the pin marker either way. - - name: Cache real-world corpus repos - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: vscode-extension/.real-world - key: real-world-corpus-${{ hashFiles('vscode-extension/test-fixtures/real-world-corpus.json') }} - - name: Run VS Code extension tests run: make _test_vsix # ── VS Code extension on Windows (runs in parallel) ──────────────────────── # Implements [VSIX-CI-PLATFORM-COVERAGE] (docs/specs/VSIX-SPEC.md). - # Windows is a first-class target — the VSIX ships win32-x64/win32-arm64 - # binaries (shipwright.json) — but until this job existed EVERY test ran on - # Linux, so win32-only defects (`.exe` suffixes, `\` separators, per-platform - # bundle paths) shipped uncaught. This runs the SAME `workspace-suite` the - # Linux job runs — all of src/test/suite, including the DAP debugger - # ([VSIX-PYTHON-DEBUGGER-DAP]) and CPU/memory profiler - # ([VSIX-PROFILER]) suites — natively on Windows. - # - # It deliberately does NOT call `make _test_vsix`: that recipe packages a - # release VSIX and re-enforces the coverage ratchet, both of which the Linux - # job already owns. Here the steps are spelled out so the Windows run is the - # thing under test — build the win32 binary, stage it through the ONE - # canonical staging path ([VSIX-PACKAGING-PARITY], scripts/stage-runtime.mjs), - # then drive the suite against that staged bundle. + # The VSIX is platform-neutral now — it bundles no binary — so this job no + # longer guards `.exe` suffixes or per-platform bundle paths. It still guards + # the one thing that is genuinely OS-dependent and shipped: the extension + # host loading and running the notice on Windows, where path handling and the + # `vscode-test` launcher differ. test-vscode-windows: name: VS Code Extension (Windows) needs: changes - # Same gate as the Linux e2e job: the suite runs the real basilisk binary, - # so a core change alters what it exercises. - if: needs.changes.outputs.core == 'true' || needs.changes.outputs.vscode == 'true' + if: needs.changes.outputs.vscode == 'true' runs-on: windows-latest - # Overrides the workflow-level RUSTFLAGS: MSVC's link.exe rejects the - # gcc/clang driver flag `-fuse-ld=lld`. - env: - RUSTFLAGS: "-D warnings" - # TIMEOUT EXCEPTION: a cold Windows cargo build plus a full VS Code e2e - # suite (real debugpy sessions, real profiler runs) is slower than the Linux - # equivalent; Windows I/O makes both phases materially longer. No real-world - # corpus here, so the total still lands under Linux's. 40 rather than 75: - # with the `e2e` profile replacing fat-LTO `release` the measured 8m48s - # build collapses, so this is a hang backstop with ~3x headroom, not a - # perf budget. The first run after a profile change is the cold one. - timeout-minutes: 40 - # Git Bash — the repo's scripts and these steps are POSIX shell, and it is - # the shell `_release_vsix` already targets on Windows (MINGW*/MSYS*). + timeout-minutes: 20 + # Git Bash — the repo's scripts and these steps are POSIX shell. defaults: run: shell: bash steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable - - - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 - with: - cache-on-failure: true - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 22.x cache: npm cache-dependency-path: vscode-extension/package-lock.json - - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 - with: - python-version: "3.12" - - # Same pins as the Linux e2e job — the debug suites drive real debugpy - # sessions and the LSP shells out to ruff. - - name: Install Python test dependencies - run: pip install debugpy==1.8.21 ruff==0.16.1 - # Keyed on runner.os, so this is a Windows-only cache bucket and never # collides with the Linux job's VS Code download. - name: Cache VS Code test build uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: vscode-extension/.vscode-test - key: vscode-test-${{ runner.os }}-${{ hashFiles('vscode-extension/.vscode-test.mjs', 'vscode-extension/.vscode-test.js', 'vscode-extension/package.json') }} + key: vscode-test-${{ runner.os }}-${{ hashFiles('vscode-extension/.vscode-test.mjs', 'vscode-extension/package.json') }} restore-keys: vscode-test-${{ runner.os }}- - # basilisk-profiler-helper is a darwin-arm64-only component in - # shipwright.json, so win32 bundles the LSP binary alone. - # - # `--profile e2e`, not `--release`: this job exists to catch win32-only - # defects (`.exe` suffixes, `\` separators, per-platform bundle paths, - # process spawn/teardown), none of which depend on how LLVM optimizes. - # `release` pins fat LTO + codegen-units=1, which is a SERIAL link — it - # used one core for 8m48s of this job's 18m45s while the rest of the - # runner idled. The e2e profile keeps opt-level 3 (the suite drives a real - # LSP under per-test timeouts, so runtime speed still matters) and gives up - # only cross-crate optimization, which this job never measures. Shipped - # artifacts and benchmarks stay on `release` — see [profile.e2e] in - # Cargo.toml for the measurements and the reasoning. - - name: Build the win32 basilisk binary - run: cargo build --profile e2e --bin basilisk - - # The ONE canonical staging path ([VSIX-PACKAGING-PARITY]) — the same - # script the release packager and release.yml `vsix` job use, so the - # bundle these tests see is laid out exactly like the shipped VSIX - # (`bin/win32-x64/basilisk.exe`). - - name: Stage the bundled runtime - run: node vscode-extension/scripts/stage-runtime.mjs target/e2e win32-x64 - - name: Build the extension working-directory: vscode-extension run: | npm ci - npm run sync:shipwright npm run compile - # Bundled debugpy is what the DAP suites launch against, matching a real - # install (the user never has to pip-install it themselves). - - name: Vendor debugpy into the bundle - working-directory: vscode-extension - run: node scripts/vendor-debugpy.mjs - - # `npx vscode-test` rather than `npm test` so npm's `pretest` hook does - # not fetch the real-world corpus; `--label workspace-suite` selects the - # full src/test/suite run and leaves the corpus configs out. # BSK_TEST_BAIL=0 (see .vscode-test.mjs) makes this job report EVERY - # win32 failure in one run. Everywhere else the suite still fails fast; - # here the ~30s of test time sits behind a ~20min cold cargo build, so - # bailing hides the next defect behind the first and buys a full rebuild - # to find it — which is exactly how the first two runs went. + # win32 failure in one run rather than hiding the next behind the first. - name: Run VS Code extension tests working-directory: vscode-extension env: @@ -720,8 +584,8 @@ jobs: - name: Install lld run: sudo apt-get update && sudo apt-get install -y lld - - name: Build Shipwright binaries - run: cargo build --release --bin basilisk --bin basilisk-profiler-helper + - name: Build the Shipwright binary + run: cargo build --release --bin basilisk - name: Install VSIX verifier dependencies working-directory: vscode-extension @@ -731,9 +595,7 @@ jobs: working-directory: vscode-extension run: | npm run test:shipwright - node scripts/verify-shipwright.mjs versions \ - ../target/release/basilisk \ - ../target/release/basilisk-profiler-helper + node scripts/verify-shipwright.mjs versions ../target/release/basilisk - name: Verify Typeshed release attribution policy run: python3 scripts/verify_release_attribution.py --policy-only @@ -742,21 +604,19 @@ jobs: test-nvim: name: Neovim Extension (${{ matrix.neovim.label }}) needs: changes - # Its own tree, or any core change — the e2e harness drives the real binary - # over LSP/DAP, so a checker/LSP change alters what this suite exercises. - if: needs.changes.outputs.core == 'true' || needs.changes.outputs.nvim == 'true' + # The plugin is a notice ([WITHDRAWAL-SURFACES]): it starts no language + # server and no debug adapter, so a core Rust change can no longer alter + # what this suite exercises. Its own tree is the only trigger left. + if: needs.changes.outputs.nvim == 'true' runs-on: ubuntu-24.04 - # The 0.11 leg finishes in ~7min, but nightly runs the LSP suite ~3× slower - # (~12min per pass) and test-nvim.sh's bounded footer-flake retry can - # legitimately run the suite twice — build + two passes + screenshots needs - # ~30min worst case. This is a hang backstop, not a perf budget. - timeout-minutes: 35 + # One spec directory against a plugin with three modules. This is a hang + # backstop, not a perf budget. + timeout-minutes: 10 strategy: fail-fast: false matrix: - # The plugin requires Neovim 0.11+ (health.lua hard-errors below it and - # lua/basilisk/lsp.lua uses the 0.11-only vim.lsp.config/vim.lsp.enable), - # so the matrix covers the supported floor and the forward-compat tip. + # The plugin requires Neovim 0.11+, so the matrix covers the supported + # floor and the forward-compat tip. neovim: - label: "0.11" version: v0.11.6 @@ -765,26 +625,9 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable - - - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 - with: - cache-on-failure: true - - - name: Install lld - run: sudo apt-get update && sudo apt-get install -y lld - - - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 - with: - python-version: "3.12" - - - name: Install pytest, debugpy and luarocks + - name: Install luarocks run: | - # 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.21 + sudo apt-get update sudo apt-get install -y luarocks luarocks install --local luacov @@ -794,17 +637,13 @@ jobs: neovim: true version: ${{ matrix.neovim.version }} - # The nvim e2e harness git-clones plenary/dap/mini into /tmp on every run. - # Cache them so the clones become no-ops — test-nvim.sh skips any dir that - # already exists. Bump the key suffix to force a refresh of the pins. + # The harness git-clones plenary into /tmp on every run. Cache it so the + # clone becomes a no-op. Bump the key suffix to refresh the pin. - name: Cache Neovim test plugins uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: - path: | - /tmp/plenary.nvim - /tmp/nvim-dap - /tmp/mini.nvim - key: nvim-test-plugins-${{ runner.os }}-${{ matrix.neovim.label }}-v1 + path: /tmp/plenary.nvim + key: nvim-test-plugins-${{ runner.os }}-${{ matrix.neovim.label }}-v2 - name: Run Neovim extension tests run: | diff --git a/.github/workflows/deploy-pages.yml b/.github/workflows/deploy-pages.yml index d12173f1a..915a4a692 100644 --- a/.github/workflows/deploy-pages.yml +++ b/.github/workflows/deploy-pages.yml @@ -1,27 +1,25 @@ name: Deploy GitHub Pages -# A RELEASE is the only thing that publishes this site. There is DELIBERATELY no -# `push:` trigger — a merge to `main` must NOT deploy, and adding one back is a -# regression, not a convenience. +# The site is one statement plus a notice at every retired URL +# ([WITHDRAWAL-SURFACES]). It publishes on merge to `main`. # -# Why: the site is not static data. The playground embeds the checker itself, -# compiled to WebAssembly at build time ([WASM-BUILD]), and the docs quote the -# conformance score and the released binary's behaviour. Publishing on merge -# would put an UNRELEASED checker in front of users — the playground would -# answer differently from the `basilisk` anyone can install, and the docs would -# describe a version that does not exist yet. Deploying only on release keeps -# the site in lockstep with the binary it documents. -# -# A change merged to `main` is verified pre-merge by ci.yml's `Website Build` -# job (same build, same playground e2e) and ships with the next release. +# This used to deploy on release ONLY, because the site was coupled to the +# binary: the playground embedded the checker compiled to wasm, and the docs +# quoted the conformance score, so publishing on merge would have put an +# unreleased checker in front of users. None of that is left — there is no +# playground, no score, no documentation of a shipped version, and there will be +# no further release to ride along with. Holding the withdrawal notice back +# until a release that is not coming would leave the old marketing live. on: - # Called by release.yml after a stable GitHub Release is created. A release - # created with the default GITHUB_TOKEN does not emit a `release: published` - # event that can start another workflow, so the release pipeline invokes this - # one directly instead of relying on that event. + push: + branches: [main] + paths: + - "website/**" + - "docs/specs/DOCS-WITHDRAWAL-MESSAGING-SPEC.md" + - ".github/workflows/deploy-pages.yml" + # Still callable by release.yml, which invokes this workflow directly. workflow_call: - # Manual escape hatch for an out-of-band site fix between releases (a typo, a - # dead link). Deliberately a human decision — never automatic. + # Manual escape hatch for an out-of-band fix. Deliberately a human decision. workflow_dispatch: permissions: @@ -37,20 +35,14 @@ jobs: deploy: name: Build and deploy runs-on: ubuntu-latest - # 20, not 10: this job compiles the checker to WebAssembly for the - # playground ([WASM-BUILD]) before Eleventy runs. Kept in step with the - # same budget in ci.yml's website job. - timeout-minutes: 20 + # The site is templates and one generated data file — no Rust, no wasm, no + # git history. Kept in step with ci.yml's website job. + timeout-minutes: 10 environment: name: github-pages url: ${{ steps.deploy.outputs.page_url }} steps: - # fetch-depth: 0 — the conformance over-time chart (_data/conformance.js) - # reads the full git history of conformance/conformance_status.csv. A - # shallow clone would collapse the chart to a single point. - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - fetch-depth: 0 - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: @@ -58,37 +50,18 @@ jobs: cache: npm cache-dependency-path: website/package-lock.json - # The playground engine is the real checker compiled to wasm32, built by - # `npm run build:wasm` ([WASM-BUILD]). Without this the deployed site - # would serve a playground page whose engine never loads. - - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable - with: - targets: wasm32-unknown-unknown - - - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 - with: - cache-on-failure: true - prefix-key: v1-rust-glibc - - name: Install dependencies working-directory: website run: npm ci - # Separate from `npm run build` ON PURPOSE. The Eleventy build has no Rust - # dependency, so a checker that does not compile can no longer take every - # page down with it. The deployed site DOES ship a working playground, so - # this job runs the engine build explicitly and fails here if it breaks. - - name: Build playground engine (wasm) - working-directory: website - run: npm run build:wasm + # Every word the site publishes is extracted from the messaging spec + # ([WITHDRAWAL-COPY]). Deploy must not be able to ship copy the spec does + # not contain, so the drift gate runs here too — not only pre-merge. + - name: Check site copy is in sync with the messaging spec + run: python3 scripts/gen_withdrawal_copy.py --check - name: Build site working-directory: website - # GITHUB_TOKEN raises the GitHub API rate limit for _data/releases.js - # (the releases page is generated from the live Releases API at build - # time); the build still degrades gracefully if the call fails. - env: - GITHUB_TOKEN: ${{ github.token }} run: npm run build - uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index af65f84b2..92f10fe77 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,5 +1,14 @@ name: Release +# THE FINAL RELEASE ([WITHDRAWAL-UNLIST]). +# Basilisk is unlisted. This workflow exists to ship ONE last version to every +# channel — the inert CLI and the notice-only extension — so installations that +# already exist learn what happened; unlisting a listing does nothing for a copy +# already on a developer's machine. Immediately after that release is verified +# live, every channel is unlisted (see delist/) and this workflow is disabled. +# Do not tag another release. Existing releases stay published: deleting them +# would destroy the record. + # ⚠️ NEVER STORE CI ARTIFACTS — they cost money even on this PUBLIC repo (compute on standard runners is free; **storage is billed**). # The ONLY artifacts we keep are the **GitHub Releases** (Release assets are free + unlimited). NO `actions/upload-artifact` for coverage HTML, mutation reports, logs, screenshots, or any diagnostic. # The sole permitted upload is a transient in-run cross-job hand-off, and it MUST set `retention-days: 1` (the floor) so it is consumed and deleted within the same run. @@ -74,29 +83,25 @@ jobs: key: release-${{ matrix.target }} cache-on-failure: true - - name: Build release binaries + # Only `basilisk`. The profiler helper existed for the VS Code + # profiler, which no longer ships ([WITHDRAWAL-SURFACES]), so nothing + # consumes it and a released binary nothing consumes is a claim we would + # have to stand behind. + - name: Build the inert basilisk binary shell: bash - run: | - set -euo pipefail - cargo build --release --target ${{ matrix.target }} --bin basilisk - if [[ "${{ matrix.platform }}" == darwin-* ]]; then - cargo build --release --target ${{ matrix.target }} --bin basilisk-profiler-helper - fi + run: cargo build --release --target ${{ matrix.target }} --bin basilisk - name: Package macOS archive if: startsWith(matrix.platform, 'darwin-') run: | set -euo pipefail reldir="target/${{ matrix.target }}/release" - # The release archive is the single source of truth for the VSIX job, - # so the darwin archive must also carry the profiler helper that ships - # inside the macOS VSIX. ditto's zip preserves the Unix executable bit; - # Homebrew's `bin.install "basilisk"` ignores the extra helper. + # ditto's zip preserves the Unix executable bit, which Homebrew's + # `bin.install "basilisk"` needs. staging="basilisk-darwin" rm -rf "$staging" mkdir -p "$staging" cp "$reldir/${{ matrix.binary }}" "$staging/" - cp "$reldir/basilisk-profiler-helper" "$staging/" # [STUBRES-TYPESHED-LICENSE] The embedded Typeshed snapshot makes # attribution part of every binary-bearing release artifact. cp LICENSE NOTICES THIRD-PARTY-LICENSES RUST-DEPENDENCY-LICENSES "$staging/" @@ -203,21 +208,13 @@ jobs: pattern: basilisk-* merge-multiple: true - # Release notes cannot drift from what actually shipped: the component - # block is generated from shipwright.json and the release binary itself - # ([LSPFMT-RELEASE-NOTES]), then appended to the auto-generated notes. - # Drift-tested against the built binary in - # crates/basilisk-cli/tests/e2e_release_notes_block.rs. - - name: Generate release-notes component block + # The release body IS the statement ([WITHDRAWAL-SURFACES]), generated + # from the messaging spec. Auto-generated "what's changed" notes are NOT + # appended: a commit list under a version heading reads as a product + # update, and this release is the opposite of one. + - name: Generate the release body from the messaging spec shell: bash - run: | - set -euo pipefail - # The linux-x64 archive from the build matrix, by its exact name — - # the archives are named by target triple, and a glob that matches - # nothing fails only at release time (first hit: v0.37.1). - tar -xzf artifacts/basilisk-x86_64-unknown-linux-gnu.tar.gz basilisk - chmod +x basilisk - python3 scripts/gen_release_notes.py ./basilisk "$GITHUB_REF_NAME" > release-notes-block.md + run: python3 scripts/gen_release_notes.py "$GITHUB_REF_NAME" > release-notes.md # Create the release up front and attach the platform archives to it. # Every downstream job (VSIX, Homebrew, Scoop) pulls its binaries from @@ -226,11 +223,8 @@ jobs: - name: Create release with platform binaries uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2 with: - generate_release_notes: true - # The generated component block ([LSPFMT-RELEASE-NOTES]) is appended - # to the auto-generated notes rather than replacing them. - body_path: release-notes-block.md - append_body: true + generate_release_notes: false + body_path: release-notes.md # Publish immediately — never leave the release as a draft. The action # defaults to draft=false, but it only WRITES the fields you give it: # on a re-run over a tag whose release already exists as a draft (a @@ -264,42 +258,15 @@ jobs: echo "Release ${GITHUB_REF_NAME} is published (isDraft=false)." vsix: - name: VSIX - ${{ matrix.platform }}-${{ matrix.arch }} + name: VSIX needs: release - runs-on: ${{ matrix.os }} - timeout-minutes: 15 - strategy: - fail-fast: false - matrix: - include: - - os: macos-15 - platform: darwin - arch: arm64 - npm_config_arch: arm64 - archive: basilisk-aarch64-apple-darwin.zip - - os: ubuntu-latest - platform: linux - arch: x64 - npm_config_arch: x64 - archive: basilisk-x86_64-unknown-linux-gnu.tar.gz - - os: ubuntu-latest - platform: linux - arch: arm64 - npm_config_arch: arm64 - cross: true - archive: basilisk-aarch64-unknown-linux-gnu.tar.gz - - os: windows-latest - platform: win32 - arch: x64 - npm_config_arch: x64 - archive: basilisk-x86_64-pc-windows-msvc.zip - - os: windows-latest - platform: win32 - arch: arm64 - npm_config_arch: arm64 - cross: true - archive: basilisk-aarch64-pc-windows-msvc.zip - + runs-on: ubuntu-latest + timeout-minutes: 10 + # ONE package, not five. The extension is a notice ([WITHDRAWAL-SURFACES]): + # it bundles no `basilisk` binary and no vendored debugger, so there is + # nothing platform-specific left and no per-target matrix to build. The + # release-archive download, the runtime staging, the Shipwright bundle + # verification and the debugpy vendoring all went with the checker. steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -313,98 +280,18 @@ jobs: cache: npm cache-dependency-path: vscode-extension/package-lock.json - # Needed to vendor debugpy (pip install --target) into the VSIX bundle. - - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 - with: - python-version: "3.12" - - name: Install dependencies working-directory: vscode-extension run: npm ci env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - npm_config_arch: ${{ matrix.npm_config_arch }} - - name: Validate Shipwright manifest + - name: Validate manifest and licences working-directory: vscode-extension run: npm run test:shipwright - - name: Set vsce target - shell: pwsh - run: echo "target=${{ matrix.platform }}-${{ matrix.arch }}" >> $env:GITHUB_ENV - - # Pull the per-platform binary from the release published by the `release` - # job — the single source of truth — instead of recompiling here. The - # release archives preserve the Unix executable bit, so the staged binary - # is runnable (a raw Actions-artifact hand-off would strip it -> EACCES). - - name: Download release archive (POSIX) - if: matrix.platform != 'win32' - shell: bash - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - set -euo pipefail - rm -rf runtime-bin - mkdir -p runtime-bin - gh release download "${GITHUB_REF_NAME}" --repo "${GITHUB_REPOSITORY}" \ - --pattern "${{ matrix.archive }}" --dir runtime-bin - case "${{ matrix.archive }}" in - *.tar.gz) tar -xzf "runtime-bin/${{ matrix.archive }}" -C runtime-bin ;; - # ditto zips nest under a top dir; -j flattens, -o preserves perms. - *.zip) unzip -j -o "runtime-bin/${{ matrix.archive }}" -d runtime-bin ;; - esac - - - name: Download release archive (Windows) - if: matrix.platform == 'win32' - shell: pwsh - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - $ErrorActionPreference = "Stop" - Remove-Item -Recurse -Force runtime-bin -ErrorAction SilentlyContinue - New-Item -ItemType Directory -Force -Path runtime-bin | Out-Null - gh release download "$env:GITHUB_REF_NAME" --repo "$env:GITHUB_REPOSITORY" --pattern "${{ matrix.archive }}" --dir runtime-bin - Expand-Archive -Path "runtime-bin/${{ matrix.archive }}" -DestinationPath runtime-bin -Force - - - name: Stage Shipwright manifest and runtime binaries - shell: bash - run: | - set -euo pipefail - # Stage exactly the manifest-declared bundled binaries for this target - # via the shared stage-runtime helper — the SAME single-source staging - # the Makefile (_release_vsix / _test_vsix) uses, so the published VSIX - # and the e2e-tested bundle can never diverge. stage-runtime clears - # bin/, copies each bundled binary from the extracted release archive, - # and re-asserts the exec bit on POSIX targets (the archives already - # carry it; Windows .exe needs no bit). Implements - # [VSIX-PACKAGING-PARITY]. - node vscode-extension/scripts/stage-runtime.mjs runtime-bin "${{ env.target }}" - cp shipwright.json vscode-extension/shipwright.json - cp VSCODE-DISTRIBUTION-LICENSE vscode-extension/LICENSE.txt - cp NOTICES THIRD-PARTY-LICENSES RUST-DEPENDENCY-LICENSES \ - VSCODE-DEPENDENCY-LICENSES vscode-extension/ - - # Skip on cross-compiled targets: the runner's CPU arch can't execute - # the produced binary (x64 host -> aarch64 target). The VSIX contents - # check below still runs and validates the zipped layout. - - name: Verify runtime version contracts - if: ${{ !matrix.cross }} - working-directory: vscode-extension - shell: bash - run: | - set -euo pipefail - exe="" - if [ "${{ matrix.platform }}" = "win32" ]; then exe=".exe"; fi - args=("bin/${{ env.target }}/basilisk${exe}") - if [ "${{ matrix.platform }}" = "darwin" ]; then - args+=("bin/${{ env.target }}/basilisk-profiler-helper") - fi - node scripts/verify-shipwright.mjs versions "${args[@]}" - - - name: Vendor debugpy into the VSIX bundle - working-directory: vscode-extension - shell: bash - run: node scripts/vendor-debugpy.mjs + - name: Stage the distribution licence + run: cp VSCODE-DISTRIBUTION-LICENSE vscode-extension/LICENSE.txt - name: Package VSIX working-directory: vscode-extension @@ -418,33 +305,19 @@ jobs: if [[ "${GITHUB_REF_NAME}" == *-* ]]; then flag="--pre-release" fi - npx vsce package $flag --target "${{ env.target }}" --ignore-other-target-folders --out "basilisk-${{ env.target }}.vsix" + npx vsce package $flag --out "basilisk.vsix" - - name: Verify VSIX contents - working-directory: vscode-extension - shell: bash - run: | - set -euo pipefail - exe="" - if [ "${{ matrix.platform }}" = "win32" ]; then exe=".exe"; fi - unzip -l "basilisk-${{ env.target }}.vsix" | grep -F "bin/${{ env.target }}/basilisk${exe}" - unzip -l "basilisk-${{ env.target }}.vsix" | grep -F "shipwright.json" - unzip -l "basilisk-${{ env.target }}.vsix" | grep -F "extension/LICENSE.txt" - unzip -l "basilisk-${{ env.target }}.vsix" | grep -F "extension/NOTICES" - unzip -l "basilisk-${{ env.target }}.vsix" | grep -F "extension/THIRD-PARTY-LICENSES" - unzip -l "basilisk-${{ env.target }}.vsix" | grep -F "extension/RUST-DEPENDENCY-LICENSES" - unzip -l "basilisk-${{ env.target }}.vsix" | grep -F "extension/VSCODE-DEPENDENCY-LICENSES" - if [ "${{ matrix.platform }}" = "darwin" ]; then - unzip -l "basilisk-${{ env.target }}.vsix" | grep -F "bin/${{ env.target }}/basilisk-profiler-helper" - fi - node scripts/verify-shipwright.mjs vsix "basilisk-${{ env.target }}.vsix" "${{ env.target }}" + # The packaged zip is inspected, not trusted: shipping the type checker + # again is the one failure that must be impossible. + - name: Verify the VSIX ships no checker + run: bash scripts/verify-vsix-inert.sh vscode-extension/basilisk.vsix # Transient in-run hand-off to `release-assets`, which uploads the VSIX to # the GitHub Release (permanent, free). retention-days: 1 (the floor) keeps # build output out of billed storage ([GITHUB-NO-ARTIFACTS]). - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: ${{ env.target }} + name: vsix path: vscode-extension/*.vsix retention-days: 1 @@ -508,10 +381,7 @@ jobs: else echo "Detected stable tag ${GITHUB_REF_NAME}; publishing to stable channel" fi - # One publish per platform-specific VSIX. vsce requires a separate - # publish call for each --target platform; globbing all into one - # publish silently uses only the first. - # Same hardening as the Open VSX step, for the same reason: an + # One VSIX, published once. The loop and its retry are kept: an # unguarded loop under `set -e` aborts on the first failure and # strands every target behind it. Retry with backoff, treat an # already-published version as done so re-runs are safe, and try @@ -609,10 +479,7 @@ jobs: else echo "Detected stable tag ${GITHUB_REF_NAME}; publishing to stable channel" fi - # One publish per platform-specific VSIX. The target is baked into - # each VSIX, so no --target flag is needed, but each must be pushed - # separately — a single glob would publish only the first. - # ovsx is version-pinned: a floating `npx ovsx` would fetch and run + # One VSIX, published once. ovsx is version-pinned: a floating `npx ovsx` would fetch and run # the latest release at publish time, inside the job that holds the # token — a supply-chain risk. Bump deliberately. # Open VSX returns transient 5xx under load. v0.41.0 published three diff --git a/CLAUDE.md b/CLAUDE.md index b0a56f019..83a4995ff 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,179 +1,45 @@ - # CLAUDE.md -Code here must comfortably pass review at a top-tier engineering org. Fix shortcomings as you find them. +## Where Basilisk stands -# Accuracy Is the Prime Directive +Basilisk's type checker was producing incorrect results. We asked for it to be removed from the `python/typing` conformance results, and it has been removed ([python/typing#2330](https://github.com/python/typing/pull/2330)). The code responsible is not isolated to a known set of rules, and we cannot say how many rules are affected. -Basilisk must be correct on Python it has never seen. Every rule decides from the resolved AST — bindings, types, symbol identity — never from how the source happens to be spelled. `from typing import Final as F` behaves identically to `typing.Final`; reformatting a file changes no diagnostic. +A code-quality tool that does not produce correct results is worse than useless. -Basilisk was **removed from the python/typing conformance results** on 2026-08-05, at its own author's request — [python/typing#2330](https://github.com/python/typing/pull/2330), reverting [#2316](https://github.com/python/typing/pull/2316). The reason: *"Many of Basilisk's rules match against raw source text and hard-coded typing symbol names instead of resolved symbols on the AST."* Semantics-preserving edits to the suite — renaming imports, adjusting whitespace — broke **113 of 141 test files**. The score was real; the checker under it was not. +**So: unlist first, no rebuilding until the unlisting is complete.** Basilisk is being unlisted everywhere and then rebuilt from the ground up as a new product — not fixed, not audited, not salvaged. It will ship only what can be trusted, which most likely will not include type checking. If type checking ever returns, it will be externally audited before release. -Conformance is a downstream sample of that accuracy, and one this codebase is overfitted to. A change that lifts the conformance number without improving analysis of arbitrary Python is a regression. +One vocabulary, in this order: **incorrect results → removed from `python/typing` → unlisted from every channel → the CLI is inert → rebuilt from the ground up as a new product.** "Unlisted" throughout — never delisted, withdrawn, deprecated, or discontinued as synonyms. -## Text-matched logic — test, delete, report +Full messaging, including every approved word of public copy: [`docs/specs/DOCS-WITHDRAWAL-MESSAGING-SPEC.md`](docs/specs/DOCS-WITHDRAWAL-MESSAGING-SPEC.md). It is the single source of truth. Copy from it; never write your own version. -A rule whose behaviour depends on the *spelling* of its input rather than the *meaning* is broken, whatever it scores. Finding and removing it outranks every other task here. Find it by: +## The current job -- Raw source-text matching — `.contains` / `starts_with` / `ends_with` on user code (131 of 145 rule files today). -- Hard-coded symbol spellings instead of resolved identity: `t == "typing.Final"`, `text.starts_with("Callable[")`, `import.module == "typing"`. -- Any regex over Python source. -- Logic keyed to a test fixture: rule files named after conformance tests (`generics_base_class_2.rs`, `constructors_call_init`), branches for shapes only the suite contains, comments citing a test file as justification. -- Detection that fires on formatting — line breaks, spacing, quote style, comment text, statement order. +1. **Unlist** every channel — Marketplace, Open VSX, Zed, PyPI, Homebrew, Scoop. +2. **Strip** every public surface to the messaging: READMEs, website, listings. +3. **Make the CLI inert** ([WITHDRAWAL-INERT](docs/specs/DOCS-WITHDRAWAL-MESSAGING-SPEC.md#WITHDRAWAL-INERT)). -On encountering it, do exactly three things — **do not fix it, do not rewrite it, do not leave a TODO**: +Nothing else is in scope until that is done. -1. **Write a test that fails** because of the incorrect code — pin the real defect: an aliased import, a reformatted source, a shape the conformance suite never contains. -2. **Delete the offending code.** -3. **Tell the user what you deleted and why**, and that the test is now failing. +While the eventual plan is to rebuild from the ground up as a new product, you are not currently allowed to contribute to that end. -Replacing it is not your call. The point is to surface every one of these so the user can acknowledge it and decide what gets built back. A checker with fewer rules and visible failing tests is the correct outcome; a diagnostic that only fires on one spelling looks like coverage and isn't. +## Do not -**A failing test that pins real incorrect behaviour is worth more than a passing fixture carried by logic that does not analyse code.** The first is an accurate map of what Basilisk cannot yet do; the second is a false claim that it can. Given the choice, take the failing test — every time. +- **Do not fix, improve, audit, or extend the type checker.** Not a rule, not a diagnostic, not a false positive. That code is finished. Deleting is fine; repairing is not. +- **Do not touch conformance.** Don't run it, quote it, restore it, or resubmit. Never publish a conformance or benchmark figure, in any tense. +- **Do not extract "the good parts" yet.** The code is too contaminated to separate; an extraction now carries the problem into the new product. +- **Do not market anything** — no feature lists, rule counts, or per-rule docs, including for parts that never touched the checker. +- **Do not reassure about scope.** Never "only a few rules", "the language server is unaffected, keep using it". We cannot scope it; saying so is the point. +- **Do not quote the apology.** Link it, neutrally, and nothing more. +- **Do not blame anyone outside the project. Do not give a timeline.** -## What a correct rule looks like +## Still standing -The yardstick for judging code — not licence to go and fix it: - -- Decides on the **resolved semantic model** from `basilisk-resolver`, never tokens or text. Parses with `ruff_python_parser`. -- Named for the **typing-spec concept** it implements, not a test file. -- Survives **semantics-preserving mutation**: aliased imports, reformatting, reordering → identical diagnostics. Rules without that coverage are unverified. -- Tested against Python the conformance suite has never contained. - -## Direction of travel - -Background, not a directive: strip text-matched logic, establish which rules genuinely analyse code, and rebuild around those — deliberately, with the user, not as a side effect of some other task. Anything that can't be made to work on the AST gets removed rather than propped up; a smaller trustworthy checker beats a large unreliable one. Analysis Basilisk can't do reliably may be delegated to an external engine. Deletion is a legitimate outcome. - -## Conformance's role - -`python3 conformance/run_conformance.py` stays honest: fresh `git clone` from `python/typing@main`, clean `cargo build --release` from THIS checkout, the suite's own unmodified `src/main.py --only-run basilisk` via `BASILISK_BIN`. A vendored scorer, injected adapter, cached fixtures, or committed results standing in for a live run is a **BUILD FAILURE**. The number is a regression detector, never an objective: - -- **Never publish, quote, or market a conformance figure** — nothing may imply Basilisk is in the official results. -- **Never re-submit to python/typing** until the mutation harness passes clean and an external audit has run. -- Never move the number by touching the scoreboard: rule-suppressing config, deleting source to dodge a failure, hand-editing `conformance/conformance_status.csv`, loosening `coverage-thresholds.json` ([CHKARCH-CONFORMANCE]). -- **A drop caused by removing text-matched logic is progress.** Record it and say so plainly — never restore the code or fake a pass to hold a ratchet. The boundary is intent: deleting a rule to reach a number hides the loss; deleting text-matched logic leaves a failing test behind and reports the drop. -- `coverage-thresholds.json` still gates the pass percentage at 100 with zero false positives, so the first honest deletion fails `make test`. That floor is the incentive that caused the fitting; removing it is the user's call. Until they decide: **delete anyway, report the drop and the failing gate, and stop there.** - -# Design Principles - -One IDE extension = a complete, fast Python workflow. The LSP drives all functionality — extensions only react to LSP signals and NEVER register a command the LSP doesn't advertise. - -**No modes** — behaviour is per-rule configuration ([CHKARCH-CONFIGURATION-ONLY]). Default: every PEP typing-spec rule and nothing else; house-style rules (`BSK-0001/0002/0004` require-annotation, `BSK-0025` require-`@override`, `BSK-0050` redundant-annotation, `BSK-0014` explicit-`Any`) are opt-in. Every diagnostic teaches — why, not just what. - -# Documentation Honesty - -Trust is the product. Applies everywhere — specs, plans, README, website, marketing, code comments. - -- **Every claim about the outside world** (stats, adoption, competitor numbers, market facts, quotes) carries an inline link to the source making it. Link it or delete it. Drifting values link live, never frozen. -- **Self-measured metrics** state how they're measured, are reproducible, and are never compared across methodologies. Conformance isn't publishable at all (above). -- **Book screenshots are release evidence** — captured from the book's pinned released build; never mocked, redrawn, generated, or hand-composed, not even labelled "diagram". Crop and resize freely; never repaint product pixels. No real capture → omit it. See [`book/VISUAL-DESIGN-SYSTEM.md`](book/VISUAL-DESIGN-SYSTEM.md#screenshot-contract). - -# Documentation Structure - -The spec-ID web is non-negotiable: - -- Every spec section has a unique, non-numeric, hierarchical ID (`[GROUP-TOPIC-DETAIL]`). -- Code cites its spec ID in comments (`// Implements [LSP-HOVER]`) so `grep [LSP-` walks spec → code → tests; tests cross-reference both. Anything unlinked gets the missing ID. -- `docs/INDEX.md` indexes `docs/specs/[COMPONENT]-[FEATURE]-SPEC.md` and `docs/plans/[COMPONENT]-[FEATURE]-PLAN.md`. `docs/specs/LSP-ARCHITECTURE-SPEC.md` is the **single source of truth** for shared LSP/DAP/config/commands. - -# Rules - -Build scripts live in the Makefile. [Pyrefly](https://pyrefly.org/en/docs/) and [Pyright](https://microsoft.github.io/pyright/#/) are references to compare against — NEVER copy their code. - -- **Never parse with strings or regex** — `ruff_python_parser` and the resolver only. -- **After correctness, reduce duplication.** `deslop:find-similar` before writing new code, `deslop:top-offenders` after. Merge duplicates. -- Hoist shared code into shared crates/modules. Use [lspkit](https://crates.io/crates/lspkit) where possible. -- One global-state file per app. All mutable state uses Signals — no stale state on screen. -- Keep dependency versions in sync across `.github/workflows/ci.yml` and `.devcontainer/Dockerfile`. -- Define spec models in [typeDiagram markup](https://typediagram.dev/docs/language-reference.html); generate ADTs with its [code generator](https://typediagram.dev/docs/cli.html). -- Don't use Git unless asked. -- Legacy code is code to be removed; there is none here. -- Files under 500 LOC. Move files rather than copying. -- Use your judgment — do NOT stop to ask questions. (Reporting a deletion isn't a question; report and continue.) -- NEVER kill a VS Code process — it disrupts active debugging and test sessions. -- Bug Fix Process: [fix bug skill](.claude/skills/fix-bug/SKILL.md) - -## Git & Branch Discipline - -Off-limits unless explicitly asked. When git IS used: - -- **NEVER push to `main`** — every change ships via PR → CI green → merge. -- **NEVER list the agent as co-author** — no `Co-Authored-By`, no agent attribution. -- **Exactly ONE branch.** Reuse the feature branch; merge multiples into one first. -- **Worktrees are forbidden.** -- **NEVER close anything you did not open** — write `Refs #123`, never `Closes/Fixes #123`. - -## Testing - -Tests must **enforce behaviour**, not work around the gaps in it. Judge a test by what it would catch, never by whether it's green. - -- Tests exercise **meaning, not spelling**: every rule test gets an aliased-import and a reformatted variant, with identical diagnostics. The harness that would enforce this across the suite ([CHKARCH-TESTING-SEMANTIC-MUTATION]) **does not exist yet** — until it does, every rule is unverified and must be described that way. -- Test against Python the conformance suite has never contained. A test copied from `conformance/tests/` cannot detect a rule fitted to `conformance/tests/`. -- NEVER delete a failing test, remove a failure-causing assertion, reduce assertiveness, or ignore tests. Broken functionality gets MORE failing tests, never fewer. -- Target 100% coverage on every measure. Each PR MUST increase overall coverage. Line coverage proves execution, never assertion — a rule at 100% coverage and zero real assertions is the normal failure, not an edge case. -- Mutation score only increases; widen scope with `#[mutation_safe]` tests. The gate ([CHKARCH-TESTING-MUTATION-RATCHET], `mutation_testing/mutation_scores.json`) fails CI if the mutant pool shrinks, caught drops, missed/timeout rise, or kill rate drops. **Read the denominator before the rate:** scope is opt-in, so the committed 100% covers 161 mutants out of an ~82k-LOC crate; timeouts are credited as kills; survivors are aggregated into a count. Never narrow scope to protect a rate, and never kill a mutant by asserting on incidental output instead of the behaviour it changed. -- `make test` is FAIL-FAST — never `--no-fail-fast`. It enforces coverage from `coverage-thresholds.json` at the repo root, not env vars or CI YAML. Ratchet only. -- VSIX tests must not call `whenCommandReady` or `getCommands(true)` to check existence — assert through the UI, or worst case internal VSIX state. - -## Benchmarks - -The benchmark is **indicative, not a gate** ([CHKARCH-TESTING-BENCH]) — it runs on a workstation against whatever else that machine is doing, shifting absolute times by tens of percent between identical runs. **Nothing in CI passes or fails on a benchmark number, and no gate is to be reintroduced.** - -- Run `make bench` when touching checker hot paths. Each run does `cargo clean` + a fresh `--release` build and pulls the latest release of each competitor (pyright, mypy, ty, pyrefly, zuban). -- **Write always.** Numbers go to `benchmarks/status/.csv` after every fixture and at the end (`benchmarks/summarize.py`). Measuring without recording is a lie. -- **Read correctly.** Compare tools *within* one run, never across machines or times. See `website/src/docs/benchmarks.njk`. - -## Logging - -- **Structured only** — `tracing` + `tracing-subscriber`, never `println!`/`eprintln!`. Can't see what's happening? Add more logging. -- Log entry/exit of significant operations with structured fields: `tracing::info!(user_id = 42, action = "checkout")`. -- VS Code extension: detailed logs to a file in the extension's state folder AND the Output Channel. -- **NEVER log PII** or secrets — log `"key: present"` or a truncated hash. - -## Rust Quality - -- Clippy and fmt routinely. All lints at highest strictness (Cargo.toml `[lints]`). Add lints if in doubt; never remove them. -- `unsafe` is forbidden (`unsafe_code = "deny"`). `unwrap()` is always a violation — use `?` with proper error types. No `panic!`, `todo!`, `unimplemented!`. -- `Result` / `Option` everywhere; early returns with `?`. Expressions over statements. Pattern matching over casting. Pure functions. -- Functions <20 lines, low cognitive complexity. Descriptive names (no single letters outside closures). Group into modules; document public APIs. - -# Too Many Cooks — Multi-Agent Coordination - -Register before starting work. The coordinator dictates orders through plans and messages; others follow and check messages regularly. Lock files before editing, never edit locked files, respond promptly. - -# Website - -**Minimize CSS classes**; name them after what the element IS, not what section it's in. Avoid LLM-default colors (e.g. purple) — use RNG and color wheels. - -## Per-diagnostic error pages (`/errors/BSK-XXXX/`) - -Every diagnostic ends with `see: https://www.basilisk-python.dev/errors/BSK-XXXX` (each rule's `ErrorCode.docs_url`). Pages generate from checker source — `[WEBSITE-ERROR-PAGES]`. Single source is `website/src/_data/rules.json`: - -```bash -python3 scripts/gen_rules_reference.py --data -``` - -It extracts the `//! BSK-XXXX:` summary + doc-comment body from each `crates/basilisk-checker/src/rules/*.rs`. **Rerun after adding or renaming a rule** — CI regenerates and `diff`s it (`[WEBSITE-ERROR-PAGES-DRIFT]`). The same data drives `/docs/rules/`. Pages render via `website/src/errors/error.njk`; screenshots appear for any code in `screenshots/shots.mjs`. - -# Architecture - -Strict-by-default Python type checker and comprehensive LSP in **Rust**. Users can flick errors down to warnings and adopt type safety incrementally, or use the LSP alone for autofixes, formatting, debugging, and profiling. - -- **Parser**: `ruff_python_parser`. **Incremental**: Salsa — sub-10ms incremental checks. -- **Formatting**: `ruff_python_formatter` in-process ([LSPFMT-ENGINE]); import hygiene native on the Ruff AST ([LSPFMT-IMPORTS]). The `ruff` CLI is NOT a runtime dependency — never spawn it. -- **Concurrency**: Tokio in the LSP server; analysis single-threaded on one dedicated large-stack thread ([LSPARCH-ARCH-STACK]). -- **No Pyright/mypy/Node.js** — zero TypeScript or Python runtime. - -## Migration to `lspkit` - -LSP scaffolding is being distilled into the `lspkit-*` workspace in [`Nimblesite/lsp_toolkit`](https://github.com/Nimblesite/lsp_toolkit). Prefer `lspkit-*` for new infrastructure; flag in the PR if a patch duplicates it. - -| Current path | Toolkit crate | -|---|---| -| `crates/basilisk-lsp/src/server/mod.rs:96` tower-lsp `Server` | `lspkit-server` | -| `crates/basilisk-lsp/src/workspace.rs:39–116` `WorkspaceIndex` | `lspkit-vfs` + consumer-side index | -| `crates/basilisk-lsp/src/server/handlers/{navigation,features}.rs` | `lspkit-server::Dispatcher::register` | -| `crates/basilisk-lsp/src/server/init.rs:224–242` diagnostics | `lspkit-server::diagnostics::DiagnosticsBus` | -| `crates/basilisk-lsp/src/server/mod.rs:61,64` debounce + watcher | `lspkit-live::watcher` + `lspkit-live::scheduler` | -| `crates/basilisk-lsp/src/config.rs:35–100` `WorkspaceConfig` | `lspkit-config::load_from_ancestor` | -| `crates/basilisk-lsp/tests/lsp/ws_test_common.rs` E2E fixture | not yet in toolkit | +- **Honesty is the product.** Every external claim carries an inline link to its source. Self-measured numbers state their method or don't exist. Screenshots are real captures or absent. +- Internal specs, plans, and [`docs/CONFORMANCE-INTEGRITY-AUDIT.md`](docs/CONFORMANCE-INTEGRITY-AUDIT.md) are the record of what went wrong. Keep them; they are not marketing surfaces. +- Spec IDs stay: `[GROUP-TOPIC-DETAIL]`, cited from code, indexed in [`docs/INDEX.md`](docs/INDEX.md). +- Rust: no `unsafe`, no `unwrap()`, no `panic!`/`todo!`. `Result`/`Option`, early `?`. Clippy and fmt clean. Files under 500 LOC, functions under 20 lines. +- Structured logging via `tracing` only — never `println!`/`eprintln!`, never PII. (The inert CLI notice is the one deliberate direct write to stderr.) +- Never delete a failing test, weaken an assertion, or skip a test to go green. +- Don't use git unless asked. Never push to `main`; never list an agent as co-author; no worktrees; one branch. +- Use your judgment — don't stop to ask questions. +- Never kill a VS Code process. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 54bc373c7..e9aa554ba 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,122 +1,28 @@ # Contributing to Basilisk -

English · 简体中文

+**Basilisk is unlisted and is not accepting contributions.** -Basilisk is built by a **human + AI partnership**, split on purpose. AI agents do the mechanical, verifiable engineering. Humans do what needs taste, judgment, accountability, and trust. +Basilisk's type checker was producing incorrect results. We asked for it to be removed from the `python/typing` conformance results, and it has been removed ([python/typing#2330](https://github.com/python/typing/pull/2330)). The code responsible is not isolated to a known set of rules, so we cannot say how many rules are affected. A code-quality tool that does not produce correct results is worse than useless. -- [**For Humans**](#for-humans) — judgment, taste, trust, and everything an agent can't be held accountable for. Express it in the specs first. -- [**For AI**](#for-ai) — technical execution under the rules in [`CLAUDE.md`](CLAUDE.md). +The full statement: [www.basilisk-python.dev](https://www.basilisk-python.dev/). The author's public account: [an apology](https://www.christianfindlay.com/blog/basilisk-conformance-apology). -> Every TODO in [`docs/plans/ROADMAP-NEXT-STEPS-PLAN.md`](docs/plans/ROADMAP-NEXT-STEPS-PLAN.md) carries the same split: -> -> | Tag | Meaning | -> |---|---| -> | `[AGENT]` | Mechanical, verifiable code/test/docs work an agent drives end-to-end. | -> | `[HUMAN]` | Needs human discretion — accounts, secrets, money, brand voice, strategy, native-speaker judgment. | -> | `[HYBRID]` | Agent drafts and prepares; a human reviews, approves, or supplies credentials. | +## What that means for a pull request ---- +**No fix to the type checker will be merged** — not a rule, not a diagnostic, not a false positive. The problem is not a list of bugs waiting for patches; it is that we cannot say which results were ever trustworthy. Repairing individual rules would produce a checker that is wrong in ways nobody has enumerated, and shipping that again is the thing we are stopping. -## For Humans +There is nothing to contribute to here yet. What comes next is a new product, rebuilt from the ground up, shipping only what can be trusted. That most likely will not include type checking. If type checking ever returns, it will be externally audited before release. -You don't need to write Rust to make Basilisk better. **The highest-leverage thing a human can do here is verify that the checker actually analyses code** — not that a number went up. +## What this repository is now -This isn't hypothetical. Checker logic was fitted to the conformance fixtures, the resulting score was published, and we didn't catch it until much later; both published numbers — conformance and performance — are now **withdrawn**. See the [conformance correction](https://www.basilisk-python.dev/docs/conformance/), the [integrity audit](docs/CONFORMANCE-INTEGRITY-AUDIT.md), and the author's [personal account and apology](https://www.christianfindlay.com/blog/basilisk-conformance-apology). None of it was deliberate — nobody set out to game the suite; the instructions named the score as the goal, matching text moves a score faster than analysing code does, and nothing verified the difference. In rough order of impact: +The record. It stays public because taking it down would erase what happened. -### 1. Verify the metrics yourself +- [`docs/CONFORMANCE-INTEGRITY-AUDIT.md`](docs/CONFORMANCE-INTEGRITY-AUDIT.md) — how checker logic came to be fitted to the conformance fixtures, and how it went unnoticed. +- [`docs/specs/`](docs/specs/) and [`docs/plans/`](docs/plans/) — what was specified and what was built. They describe a product that is withdrawn; they are kept as evidence, not as promises. +- [`docs/specs/DOCS-WITHDRAWAL-MESSAGING-SPEC.md`](docs/specs/DOCS-WITHDRAWAL-MESSAGING-SPEC.md) — every word Basilisk says publicly, and the single source each surface copies from. +- [`delist/`](delist/README.md) — the unlisting runbook. -Agents optimise whatever you measure, and every number here is reachable without doing the underlying work: **conformance, coverage, mutation score, assertions, lint, benchmarks.** Re-derive any metric change before you believe it — agents cannot grade their own homework. What to look for: +## If you found something wrong in the record -- **Text-matched logic** — the big one. A rule keyed on raw source text or hard-coded symbol spellings instead of resolved AST symbols scores well and fails on real code. Rename an import (`from typing import Final as F`) or reformat a file: the diagnostics must not change. -- **Silence instead of analysis** — a rule disabled or quietly unregistered so it stops firing, with the loss undisclosed. Deleting a text-matched rule is the opposite and is what we want: it comes with a failing test and a report saying what went. Judge it by whether the hole is visible afterwards. -- **Weakened tests** — failing tests deleted, assertions cut or watered down so "green" means nothing. -- **Scoreboard or gate edits** — a hand-edited `conformance_status.csv`, or a lowered threshold (`coverage-thresholds.json`, the mutation or benchmark baselines). -- **Measuring less** — excluded diagnostic codes, skipped fixtures, narrowed mutation scope. A high percentage over part of the suite is not a percentage. Ask for the denominator every time: the mutation score is 100% over 161 mutants of an ~82k-LOC crate, because scope is opt-in. +That is worth an issue. Corrections to the audit, the specs, or the statement — anywhere the account of what happened is inaccurate or incomplete — are welcome, and they are the only changes being reviewed. -Metrics move only the *honest* way — because the work got better, never because someone changed how we count ([CHKARCH-CONFORMANCE]). The one number expected to **fall** is conformance: removing rules that never analysed anything lowers it, and that drop is progress, reported rather than avoided. - -### 2. Test it for real — on real, large codebases - -Automated tests prove the code does what we told it to. They can't tell you whether it holds up against a million lines somebody else wrote. **Point Basilisk at the real world:** - -- **Run it on large production and open-source codebases** — CPython's `Lib/`, Django, pandas, Home Assistant, SymPy, Sentry, *and your own biggest repos*. Fixtures are tidy; real code is not, and that's where false positives, crashes, slow paths, and missed errors surface. -- **Install a published artifact** (not a dev build) on a clean machine, open a real project, and confirm diagnostics, hover, go-to-definition, debugging, and profiling all light up — in **each** editor. UX and platform breakage are found by humans driving the real UI. -- **Get your team using it daily and harvest their feedback.** Turn every "this fired on perfectly good code" or "this missed an obvious bug" into an issue (§6) and a failing test. - -### 3. Maintain and improve code quality - -Review AI-authored PRs against the bar in [`CLAUDE.md`](CLAUDE.md): *code here should comfortably pass review at a top-tier engineering organization.* Catch over-engineering, premature abstraction, duplicated logic, and the subtly-wrong-but-plausible. An agent will happily ship something that compiles and passes tests but hides a landmine. - -### 4. Strengthen tests and the mutation score - -Coverage percentage is the floor, not the goal. Judge whether assertions actually *prove* something or just execute lines. Push for stronger assertions, widen the mutation-testing scope ([CHKARCH-TESTING-MUTATION-RATCHET]), and call out tests that would still pass if the code were broken. Both ratchets move one way only. - -### 5. Guard the performance numbers - -Performance is a feature, but the benchmark is **indicative, not a gate** ([CHKARCH-TESTING-BENCH]). It runs on a workstation against whatever else that machine is doing, so background load moves every tool together. Nothing in CI passes or fails on a benchmark number, and no gate is to be reintroduced. - -Only a human can do this: run `make bench` on a quiet machine, compare the tools *within* that single run (timed back to back, so machine speed cancels), and dig into anything that looks off. Never compare against a number from a different machine or time. Every run writes to `benchmarks/status/.csv` immediately — measuring without recording is a lie. - -### 6. Report GitHub issues - -You're the one running real-world Python through Basilisk. When something is wrong — a false positive, a missed error, a crash, a slow path, a clumsy editor interaction — file a precise, reproducible issue with the smallest snippet that triggers it. A good bug report becomes a failing test, which becomes a fix. - -### 7. Check plans and specs against reality - -Specs and plans are the fabric of this repo (see [`docs/INDEX.md`](docs/INDEX.md)). Does every section have a non-numeric, hierarchical ID? Does the implementing code reference it? Does the implementation *match* the spec, or has it drifted? `/spec-check` helps, but the judgment — *is this spec still telling the truth?* — is yours. - -### 8. Ensure feature parity across IDE extensions - -The promise is **one seamless experience in every editor**: VS Code (plus Cursor/Windsurf via Open VSX), Zed, and Neovim. A feature in one extension but not the others is a parity bug. Audit them side by side and file the gaps. The **LSP drives functionality** — extensions only react to what it advertises. - -### 9. Security auditing - -Threat-model the checker, the LSP, the extensions, the release pipeline, and the dependency tree. Review what `/security-review` and Dependabot surface with a human's sense of *what actually matters*. Single binary, no runtime, no telemetry is a security posture — help keep it true. - -### 10. Improve the AI instructions - -The highest-**compounding** lever: better instructions produce better output on every future task. Tighten [`CLAUDE.md`](CLAUDE.md), the specs, and the skills under `.claude/`. When an agent goes wrong, the fix usually isn't the code — it's the instruction that allowed it. - -### 11. Everything humans are simply best at - -Brand voice and naming. Outreach and community. Strategic prioritization — *what should we even build next?* Native-speaker and design judgment. Anything involving accounts, secrets, tokens, or money. If a test can't check it, it's your call. - -### How to contribute as a human - -1. **Open an issue** for a bug, parity gap, spec drift, or checker inaccuracy. Be specific and reproducible. -2. **Open a PR** for fixes or docs — fill out the [pull request template](.github/pull_request_template.md) honestly. "Tests pass" is not an answer to *how do the tests prove it works?* -3. **Review PRs** — a first-class contribution, often the most valuable one. - ---- - -## For AI - -You convert the specs to code and tests and keep all three in sync. The standing rules live in [**`CLAUDE.md`**](CLAUDE.md) and **override default behavior** — read it first and follow it exactly. - -**Before you touch anything:** read [`CLAUDE.md`](CLAUDE.md) in full, orient via [`docs/INDEX.md`](docs/INDEX.md) and [`docs/specs/LSP-ARCHITECTURE-SPEC.md`](docs/specs/LSP-ARCHITECTURE-SPEC.md), then register with the **too-many-cooks** coordinator and **lock files** before editing them. Never edit a locked file. - -**Accuracy is the prime directive.** Basilisk must be correct on Python it has never seen. Every rule decides from the resolved AST, never from how the source happens to be spelled. When you find a rule keyed on raw text, hard-coded symbol spellings, or a conformance fixture, do exactly three things — **do not fix it, do not rewrite it, do not leave a TODO**: - -1. **Write a test that fails** because of that code. -2. **Delete the offending code.** -3. **Tell the user what you deleted and why.** - -What gets built back is the user's call, not yours. **A failing test that pins real incorrect behaviour is worth more than a passing fixture carried by logic that does not analyse code** — the first records what Basilisk can't do, the second falsely claims it can. - -**The non-negotiables** (full detail in `CLAUDE.md`): - -- **Git is off-limits unless explicitly asked.** Never push to `main`, never list an agent as co-author, never use worktrees, work on exactly one branch. -- **Spec IDs are the fabric.** Every spec section has a non-numeric, hierarchical ID; code references it (`// Implements [LSP-…]`); tests cross-reference both. Missing link → fix it. -- **DRY, ruthlessly.** `deslop` MCP: `find-similar` before writing, `top-offenders` after. Search for existing code before adding new. -- **Ratchets move one way** — coverage and mutation score up, false positives down. Conformance is a regression detector, not a target; benchmark times gate nothing ([CHKARCH-TESTING-BENCH]). -- **Never touch the scoreboard.** Conformance runs the binary with **every rule enabled**: no config file, no per-rule override, no skipped fixtures, no deleting source to dodge a failure, no removing rules from `all_rules()`. Equally forbidden: hand-editing `conformance/conformance_status.csv` or loosening `coverage-thresholds.json`. Never publish or quote a conformance figure ([CHKARCH-CONFORMANCE]). -- **`make` is the interface.** `make build | test | lint | fmt | clean | ci | setup` — exactly seven targets, don't add more. `make test` is fail-fast and enforces the coverage threshold. -- **Rust quality bar:** no `unwrap`, `panic!`, `todo!`, `unimplemented!`, `unsafe`, or `allow(clippy::…)`. `Result`/`Option` everywhere, small pure functions, files under 500 LOC. -- **No CI artifacts.** Storage is billed even on this public repo — see [GITHUB-NO-ARTIFACTS]. - -**How you work:** - -- **Test-driven, always.** Failing test → confirm it fails *for the right reason* → fix the code (never the test) → confirm it passes. Coarse e2e tests only. Never delete a failing test or weaken an assertion. -- **Use judgment; don't stop to ask.** (Reporting a deletion isn't a question — report it and continue.) -- **Pick up `[AGENT]` work** from [`docs/plans/ROADMAP-NEXT-STEPS-PLAN.md`](docs/plans/ROADMAP-NEXT-STEPS-PLAN.md). Leave `[HUMAN]` work for a human. Draft the agent half of `[HYBRID]` items and hand them off. -- **Defer to the human signals** here. A human's issue report becomes your failing test. +Report a security issue in the usual private channel rather than an issue: [SECURITY.md](SECURITY.md). diff --git a/CONTRIBUTING.zh.md b/CONTRIBUTING.zh.md deleted file mode 100644 index 456767f90..000000000 --- a/CONTRIBUTING.zh.md +++ /dev/null @@ -1,124 +0,0 @@ -# 为 Basilisk 做贡献 - -

English · 简体中文

- -Basilisk 由**人类 + AI 协作**构建,分工是刻意设计的。AI 智能体承担机械的、可验证的工程工作;人类负责需要品味、判断力、责任感和信任的部分。 - -- [**给人类**](#给人类) —— 判断力、品味、信任,以及一切无法由智能体承担责任的事。首先把它表达在规格文档里。 -- [**给 AI**](#给-ai) —— 在 [`CLAUDE.md`](CLAUDE.md) 规则约束下的技术执行。 - -> [`docs/plans/ROADMAP-NEXT-STEPS-PLAN.md`](docs/plans/ROADMAP-NEXT-STEPS-PLAN.md) 中的每一条 TODO 都带着同样的分工标记: -> -> | 标记 | 含义 | -> |---|---| -> | `[AGENT]` | 机械的、可验证的代码/测试/文档工作,智能体端到端完成。 | -> | `[HUMAN]` | 需要人类裁量 —— 账号、密钥、金钱、品牌语调、战略、母语者判断。 | -> | `[HYBRID]` | 智能体起草和准备;人类审阅、批准或提供凭据。 | - ---- - -## 给人类 - -你不需要会写 Rust 就能让 Basilisk 变得更好。**人类在这里能做的最高杠杆的事,是验证检查器是否真的在分析代码** —— 而不是验证某个数字涨了。 - -这不是假设。检查器逻辑曾被拟合到一致性测试夹具的具体内容上,由此得到的 100% 被公布出去,很久之后才被发现;两个已公布的数字 —— 一致性与性能 —— 现均已**撤回**。参见[一致性更正说明](https://www.basilisk-python.dev/docs/conformance/)与[完整性审计](docs/CONFORMANCE-INTEGRITY-AUDIT.md)。这一切都不是蓄意的 —— 是指令把分数定为目标,而匹配文本比分析代码更快地推高分数。按影响力大致排序: - -### 1. 亲自复核每一项指标 - -智能体会优化你所度量的一切,而这里的每个数字都可以在不做底层工作的情况下被推高:**一致性、覆盖率、变异分数、断言、lint、基准测试。** 相信任何指标变化之前,先自己重新推导一遍 —— 智能体不能给自己的作业打分。 - -需要留意的: - -- **基于文本匹配的逻辑** —— 最要命的一类。一条规则如果依据原始源文本或硬编码的符号拼写来判断,而不是依据 AST 上已解析的符号,它会得高分并在真实代码上失效。改一个导入别名(`from typing import Final as F`)或重新格式化文件:诊断结果必须不变。 -- **用沉默代替分析** —— 规则被禁用或悄悄取消注册,好让它不再触发,而这个损失没有被披露。删除依据文本判断的规则恰恰相反,正是我们想要的:它会附带一个失败的测试和一份说明删了什么的报告。判断标准是:事后这个缺口是否可见。 -- **被削弱的测试** —— 删掉失败的测试、砍掉断言,或把断言弱化到"绿灯"毫无意义。 -- **改动记分板或门禁** —— 手工编辑 `conformance_status.csv`,或调低阈值(`coverage-thresholds.json`、变异或基准基线)。 -- **少测一点** —— 排除诊断码、跳过夹具、收窄变异范围。在部分测试集上得到的高百分比不是百分比。每次都要问分母:变异分数 100% 只覆盖了 161 个变异体,而那个 crate 有约 8.2 万行代码,因为范围是选择性加入的。 - -指标只能以*诚实*的方式移动 —— 因为工作确实变好了,而不是因为有人改了计数方式([CHKARCH-CONFORMANCE])。唯一预期会**下降**的数字是一致性:删除那些本来就没在做分析的规则会把它拉低,而这个下降是进展,应当如实报告而不是设法回避。 - -### 2. 用真实的大型代码库真刀真枪地测 - -自动化测试只能证明代码做了我们让它做的事,无法告诉你它在别人写的上百万行代码面前是否站得住。**把 Basilisk 对准真实世界:** - -- **在大型生产与开源代码库上运行** —— CPython 的 `Lib/`、Django、pandas、Home Assistant、SymPy、Sentry,*以及你自己最大的仓库*。夹具是整洁的,真实代码不是,而误报、崩溃、慢路径和漏报恰恰在那里浮现。 -- **安装已发布的构件**(不是开发构建)到一台干净的机器上,打开一个真实项目,确认诊断、悬停、跳转定义、调试和性能分析都能正常工作 —— 在**每一个**编辑器里。UX 与平台层面的问题只有人类操作真实界面才能发现。 -- **让你的团队每天用它,并收集反馈。** 把每一句"这在完全正确的代码上报错了"或"这漏掉了一个明显的错误"都变成 issue(§6)和一个失败的测试。 - -### 3. 维护并提升代码质量 - -按 [`CLAUDE.md`](CLAUDE.md) 中的标准审阅 AI 编写的 PR:*这里的代码应当能从容通过一流工程组织的评审。* 揪出过度设计、过早抽象、重复逻辑,以及那些"看着合理其实微妙地错了"的东西。智能体很乐意交付能编译、能过测试却埋着地雷的代码。 - -### 4. 强化测试与变异分数 - -覆盖率百分比是下限,不是目标。判断断言究竟是在*证明*什么,还是只是把代码行跑了一遍。推动更强的断言,扩大变异测试范围([CHKARCH-TESTING-MUTATION-RATCHET]),并指出那些即使代码坏掉也照样通过的测试。两个棘轮都只能朝一个方向走。 - -### 5. 守住性能数字 - -性能是一项功能,但基准测试是**指示性的,不是门禁**([CHKARCH-TESTING-BENCH])。它跑在一台开发工作站上,与机器上其他一切负载共存,后台负载会让表中所有工具一起漂移。CI 中没有任何东西以基准数字判定成败,也不得重新引入这样的门禁。 - -只有人类能做这件事:在一台安静的机器上运行 `make bench`,在*同一次运行内*比较各个工具(它们背靠背计时,机器速度因此相互抵消),并深挖任何看起来不对劲的地方。绝不要拿一个数字去和另一台机器或另一个时间记录的数字相比。每次运行都会立即把结果写入 `benchmarks/status/.csv` —— 测了却不记录就是撒谎。 - -### 6. 提交 GitHub issue - -你才是那个用真实世界的 Python 跑 Basilisk 的人。当出现问题时 —— 误报、漏报、崩溃、慢路径、别扭的编辑器交互 —— 请用能触发它的最小代码片段提交一份精确、可复现的 issue。一份好的缺陷报告会变成一个失败的测试,再变成一个修复。 - -### 7. 用现实检验计划与规格 - -规格与计划是本仓库的骨架(见 [`docs/INDEX.md`](docs/INDEX.md))。每个章节都有非数字的层级化 ID 吗?实现代码引用了它吗?实现与规格*一致*,还是已经漂移?`/spec-check` 能帮上忙,但判断 —— *这份规格是否仍在说真话?* —— 属于你。 - -### 8. 确保各 IDE 扩展的功能对齐 - -我们的承诺是**在每个编辑器里都有同样顺滑的体验**:VS Code(以及通过 Open VSX 的 Cursor/Windsurf)、Zed、Neovim。一个功能只落在一个扩展里而其他没有,就是对齐缺陷。把它们并排审计,把缺口提出来。**LSP 驱动功能** —— 扩展只对它所声明的能力做出反应。 - -### 9. 安全审计 - -对检查器、LSP、编辑器扩展、发布流水线和依赖树做威胁建模。用人类对*什么才真正重要*的判断力,审视 `/security-review` 与 Dependabot 暴露出来的东西。单一二进制、无运行时、无遥测本身就是一种安全姿态 —— 帮我们让它保持为真。 - -### 10. 改进给 AI 的指令 - -这是**复利最高**的杠杆:更好的指令会让此后每一项任务的产出都更好。打磨 [`CLAUDE.md`](CLAUDE.md)、规格文档,以及 `.claude/` 下的技能。当你看到智能体走偏时,要修的通常不是代码 —— 而是那条允许它走偏的指令。 - -### 11. 一切人类天生更擅长的事 - -品牌语调与命名。对外联络与社区。战略优先级 —— *我们接下来到底该做什么?* 母语者与设计判断。任何涉及账号、密钥、令牌或金钱的事。如果一个测试查不了它,那大概率就该你来定。 - -### 人类该如何贡献 - -1. **提交 issue** —— 缺陷、功能对齐缺口、规格漂移或检查器不准确。要具体、可复现。 -2. **提交 PR** —— 修复或文档,请诚实地填写 [pull request 模板](.github/pull_request_template.md)。对*这些测试如何证明它能工作?*,"测试通过了"不是答案。 -3. **评审 PR** —— 一等的贡献,往往也是最有价值的一种。 - ---- - -## 给 AI - -你把规格转化为代码和测试,并让三者保持同步。约束你的规则位于 [**`CLAUDE.md`**](CLAUDE.md),它们**覆盖默认行为** —— 先读它,并严格照做。 - -**动手之前:** 完整读完 [`CLAUDE.md`](CLAUDE.md),再通过 [`docs/INDEX.md`](docs/INDEX.md) 和 [`docs/specs/LSP-ARCHITECTURE-SPEC.md`](docs/specs/LSP-ARCHITECTURE-SPEC.md) 建立方位感,然后到 **too-many-cooks** 协调器注册,并在编辑前**锁定文件**。绝不要编辑已被锁定的文件。 - -**准确性是最高指令。** Basilisk 必须在它从未见过的 Python 上给出正确结果。每条规则都从已解析的 AST 做判断,绝不依据源码碰巧怎么写。当你发现一条规则依赖原始文本、硬编码的符号拼写或某个一致性测试夹具时,只做三件事 —— **不要修它,不要重写它,不要留 TODO**: - -1. **写一个会失败的测试**,让它因为这段错误代码而失败。 -2. **删除这段有问题的代码。** -3. **告诉用户你删了什么、为什么删。** - -要重建什么,由用户决定,不由你决定。**一个因真实错误行为而失败的测试,比一个由不做分析的代码撑起来的通过用例更有价值** —— 前者如实记录了 Basilisk 做不到什么,后者则是在宣称它做得到。 - -**不可协商的底线**(详见 `CLAUDE.md`): - -- **未经明确要求,不许碰 Git。** 绝不推送到 `main`,绝不把智能体列为共同作者,绝不使用 worktree,只在一个分支上工作。 -- **规格 ID 是骨架。** 每个规格章节都有非数字的层级化 ID;代码引用它(`// Implements [LSP-…]`);测试同时交叉引用两者。发现链接缺失就补上。 -- **无情地 DRY。** `deslop` MCP:写代码前 `find-similar`,改完后 `top-offenders`。新增代码前先搜索已有代码。 -- **棘轮只朝一个方向走** —— 覆盖率与变异分数向上,误报向下。一致性是回归探测器,不是目标;基准时间不构成任何门禁([CHKARCH-TESTING-BENCH])。 -- **绝不碰记分板。** 一致性测试运行的二进制**启用全部规则**:没有配置文件,没有按规则的覆写,不跳过夹具,不为躲避失败而删除源码,不从 `all_rules()` 中移除规则。同样禁止:手工编辑 `conformance/conformance_status.csv` 或放宽 `coverage-thresholds.json`。绝不发布或引用任何一致性数字([CHKARCH-CONFORMANCE])。 -- **`make` 就是接口。** `make build | test | lint | fmt | clean | ci | setup` —— 恰好七个目标,不要再加。`make test` 快速失败并强制执行覆盖率阈值。 -- **Rust 质量标准:** 不许 `unwrap`、`panic!`、`todo!`、`unimplemented!`、`unsafe` 或 `allow(clippy::…)`。到处用 `Result`/`Option`,小而纯的函数,文件不超过 500 行。 -- **不产出 CI 构件。** 即便是公开仓库,存储也是计费的 —— 见 [GITHUB-NO-ARTIFACTS]。 - -**你的工作方式:** - -- **永远测试驱动。** 失败的测试 → 确认它*因为正确的原因*失败 → 修代码(绝不修测试)→ 确认通过。只写粗粒度的端到端测试。绝不删除失败的测试或弱化断言。 -- **自己判断,不要停下来提问。**(报告一次删除不算提问 —— 报告完继续做。) -- **认领 `[AGENT]` 工作**,来源 [`docs/plans/ROADMAP-NEXT-STEPS-PLAN.md`](docs/plans/ROADMAP-NEXT-STEPS-PLAN.md)。把 `[HUMAN]` 留给人类。`[HYBRID]` 项里属于智能体的那一半先起草,然后移交。 -- **服从本指南里的人类信号。** 人类报告的问题,就是你要写的那个失败测试。 diff --git a/Cargo.lock b/Cargo.lock index aeb13af53..af2c18fed 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -243,29 +243,9 @@ name = "basilisk-cli" version = "0.0.0-PLACEHOLDER" dependencies = [ "basilisk-buildinfo", - "basilisk-checker", - "basilisk-common", - "basilisk-config", - "basilisk-db", - "basilisk-lsp", - "basilisk-parser", - "basilisk-resolver", - "basilisk-stubs", - "basilisk-test-utils", - "basilisk-typeshed-fetch", - "basilisk-uv", - "clap", - "colored", - "serde", - "serde_json", "shipwright", "shipwright-manifest", "tempfile", - "tokio", - "tower-lsp", - "tracing", - "tracing-subscriber", - "walkdir", ] [[package]] @@ -465,8 +445,6 @@ dependencies = [ name = "basilisk-zed" version = "0.0.0-PLACEHOLDER" dependencies = [ - "basilisk-common", - "serde_json", "zed_extension_api", ] @@ -729,15 +707,6 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" -[[package]] -name = "colored" -version = "3.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" -dependencies = [ - "windows-sys 0.61.2", -] - [[package]] name = "compact_str" version = "0.10.0" @@ -2831,15 +2800,6 @@ dependencies = [ "syn 2.0.119", ] -[[package]] -name = "same-file" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" -dependencies = [ - "winapi-util", -] - [[package]] name = "scopeguard" version = "1.2.0" @@ -3672,16 +3632,6 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" -[[package]] -name = "walkdir" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" -dependencies = [ - "same-file", - "winapi-util", -] - [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -3861,15 +3811,6 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" -[[package]] -name = "winapi-util" -version = "0.1.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" -dependencies = [ - "windows-sys 0.61.2", -] - [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" diff --git a/Makefile b/Makefile index 23315eb8d..ba692f76f 100644 --- a/Makefile +++ b/Makefile @@ -23,6 +23,10 @@ endif # Configuration # --------------------------------------------------------------------------- _EXTENSION_DIR := vscode-extension +# Where THIS Makefile lives. Recipes run in the caller's cwd (the release +# attribution tests drive them from a temp tree), so repo scripts are located +# relative to the Makefile rather than relative to `pwd`. +_MK_DIR := $(dir $(abspath $(lastword $(MAKEFILE_LIST)))) _ZED_DIR := basilisk-zed _NVIM_DIR := basilisk.nvim _BOOK_DIR := book @@ -344,69 +348,30 @@ _build_vsix: cd $(_EXTENSION_DIR) && npm ci && npm run compile && \ echo -e '\033[0;32m✓ VS Code extension compiled\033[0m' -# _release_vsix: build a host-targeted VSIX — the EXACT artifact the release.yml -# `vsix` job ships for that platform. Single recipe shared by reinstall-vsix, -# reinstall-vsix-macos, and the e2e gate (_test_vsix), so tests, local installs, -# and the published package can never diverge. Set BSK_VSIX_TARGET (e.g. -# darwin-arm64) to pin the platform regardless of host; unset auto-detects from -# uname. Implements [VSIX-PACKAGING-PARITY]. -# [STUBRES-TYPESHED-LICENSE] Every binary-bearing package carries the Basilisk -# license and the exact third-party attribution files. +# _release_vsix: build the VSIX — the EXACT artifact the release.yml `vsix` job +# ships. Single recipe shared by reinstall-vsix and the e2e gate (_test_vsix), so +# tests, local installs, and the published package can never diverge. +# Implements [VSIX-PACKAGING-PARITY]. +# +# ONE package, no `--target`: the extension is a notice ([WITHDRAWAL-SURFACES]) +# and bundles no binary, so there is nothing platform-specific left to build. +# The Rust build, the runtime staging, the debugpy vendoring, the Shipwright +# bundle verification and the third-party attribution files are all gone with +# it — a VSIX carrying none of their content must not claim any of it. The +# packaged tree is asserted afterwards rather than assumed: shipping the type +# checker again is the one failure that must be impossible. _release_vsix: @set -e; \ - python3 scripts/verify_release_attribution.py --policy-only; \ - if [ -n "$${BSK_VSIX_TARGET:-}" ]; then \ - target="$$BSK_VSIX_TARGET"; \ - plat="$${target%-*}"; arch="$${target##*-}"; \ - else \ - case "$$(uname -s)" in \ - Darwin) plat=darwin ;; \ - Linux) plat=linux ;; \ - MINGW*|MSYS*|CYGWIN*) plat=win32 ;; \ - *) echo "Unsupported OS: $$(uname -s)" >&2; exit 1 ;; \ - esac; \ - case "$$(uname -m)" in \ - arm64|aarch64) arch=arm64 ;; \ - x86_64|amd64) arch=x64 ;; \ - *) echo "Unsupported arch: $$(uname -m)" >&2; exit 1 ;; \ - esac; \ - target="$$plat-$$arch"; \ - fi; \ - case "$$arch" in \ - arm64) rust_arch=aarch64 ;; \ - x64) rust_arch=x86_64 ;; \ - *) echo "Unsupported arch: $$arch" >&2; exit 1 ;; \ - esac; \ - exe=""; \ - case "$$plat" in \ - darwin) rust_target="$$rust_arch-apple-darwin" ;; \ - linux) rust_target="$$rust_arch-unknown-linux-gnu" ;; \ - win32) rust_target="$$rust_arch-pc-windows-msvc"; exe=".exe" ;; \ - *) echo "Unsupported platform: $$plat" >&2; exit 1 ;; \ - esac; \ - echo -e "\033[1m\033[0;36m▶ Building VSIX for $$target ($$rust_target)\033[0m"; \ - cargo build --release --target "$$rust_target" --bin basilisk; \ - if [ "$$plat" = "darwin" ]; then \ - cargo build --release --target "$$rust_target" --bin basilisk-profiler-helper; \ - fi; \ - node $(_EXTENSION_DIR)/scripts/stage-runtime.mjs "target/$$rust_target/release" "$$target"; \ - cp shipwright.json $(_EXTENSION_DIR)/shipwright.json; \ - cp VSCODE-DISTRIBUTION-LICENSE $(_EXTENSION_DIR)/LICENSE.txt; \ - cp NOTICES THIRD-PARTY-LICENSES RUST-DEPENDENCY-LICENSES \ - VSCODE-DEPENDENCY-LICENSES $(_EXTENSION_DIR)/; \ repo_root="$$(pwd)"; \ - cd $(_EXTENSION_DIR) && npm ci && npm run licenses:check && \ - npm run compile && npm run sync:shipwright; \ - echo -e "\033[1m\033[0;36m▶ Validating Shipwright manifest\033[0m"; \ - node scripts/verify-shipwright.mjs manifest; \ - echo -e "\033[1m\033[0;36m▶ Vendoring debugpy into the VSIX bundle\033[0m"; \ - node scripts/vendor-debugpy.mjs; \ + echo -e "\033[1m\033[0;36m▶ Building the notice VSIX\033[0m"; \ + cp VSCODE-DISTRIBUTION-LICENSE $(_EXTENSION_DIR)/LICENSE.txt; \ + cd $(_EXTENSION_DIR) && npm ci && npm run licenses:check && npm run compile; \ prerelease_flag=""; \ if [ -n "$(VSCE_PRERELEASE)" ]; then prerelease_flag="--pre-release"; fi; \ - npx vsce package $$prerelease_flag --target "$$target" --ignore-other-target-folders --out "$$repo_root/basilisk-$$target.vsix"; \ - echo -e "\033[1m\033[0;36m▶ Verifying VSIX bundles every manifest component\033[0m"; \ - node scripts/verify-shipwright.mjs vsix "$$repo_root/basilisk-$$target.vsix" "$$target"; \ - echo -e "\033[0;32m✓ VSIX built at basilisk-$$target.vsix$${prerelease_flag:+ (pre-release)}\033[0m" + npx vsce package $$prerelease_flag --out "$$repo_root/basilisk.vsix"; \ + echo -e "\033[1m\033[0;36m▶ Verifying the VSIX ships no checker\033[0m"; \ + bash "$(_MK_DIR)scripts/verify-vsix-inert.sh" "$$repo_root/basilisk.vsix"; \ + echo -e "\033[0;32m✓ VSIX built at basilisk.vsix$${prerelease_flag:+ (pre-release)}\033[0m" _uninstall_vsix: @echo -e '\033[1m\033[0;36m▶ Uninstalling VSIX\033[0m' && \ @@ -445,17 +410,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. +# Generated-documentation drift gates. The published READMEs are rendered from +# docs/readme/ ([README]), and the site's copy is extracted from the messaging +# spec ([WITHDRAWAL-COPY]) — editing either output by hand, or editing a source +# without regenerating, fails here as it does in CI. The withdrawal gate is the +# load-bearing one: it is what stops the site saying something the messaging +# spec does not. _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; } && \ + python3 scripts/gen_withdrawal_copy.py --check && \ + python3 scripts/test_published_readmes.py && \ + python3 scripts/check_public_copy.py && \ + python3 scripts/test_check_public_copy.py && \ echo -e '\033[0;32m✓ Generated documentation is in sync\033[0m' _fmt_rust: diff --git a/README-pypi.md b/README-pypi.md index 56fcfa987..f31943fc2 100644 --- a/README-pypi.md +++ b/README-pypi.md @@ -1,208 +1,38 @@ -

- Basilisk -

- -

Basilisk

- -

English · 简体中文

- -

- An open-source Python type checker and language server, built in Rust.
- One extension for the whole workflow — diagnostics, autocomplete, refactoring, formatting, debugging, and profiling — driven by a single bundled binary. -

- -> **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  •  - GitHub -

- -

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

- -> ## ⚠️ Do not use Basilisk's type checker in your pipeline -> -> **The type checker still contains code that isn't doing real type checking, and -> it is not yet trustworthy.** Some rules decide from the way code is *spelled* -> rather than what it means, so they can be wrong in both directions — a false -> error on correct code, or silence where there is a genuine bug. Until the audit -> below is finished, don't gate CI on `basilisk check`, don't block a merge with -> it, and don't read a clean run as a clean codebase. -> -> The rest of Basilisk — language server, refactoring, formatting, debugging, -> profiling — does not depend on those rules and is unaffected. - -## Restoring trust: audit, delete, and lean on a checker that works - -We withdrew our former conformance claim and our benchmark figures, and asked to be -[removed from the official `python/typing` results](https://github.com/python/typing/blob/main/conformance/results/results.html). -The cause was checker logic fitted to the contents of conformance test files -instead of implementing the typing specification generally: rules that matched -the *spelling* of code rather than its meaning. Rename an import or reformat a -file and the answer changed. A score produced that way is not evidence. - -**This was a mistake and a failure to verify.** Our process treated the score as -the goal, matching text raises a score faster than real analysis does, and we -published without ever asking whether a rule still held when the same program was -spelled differently. Basilisk's author has published a -[personal account and apology](https://www.christianfindlay.com/blog/basilisk-conformance-apology). - -**So we are auditing every rule and deleting the ones that don't do real type -checking.** Not rewriting them, not patching them, not marking them TODO — -deleting them, with a failing test left behind so the gap is visible instead of -hidden. A rule stays only if it decides from the resolved syntax tree and gives -the same answer when the code is spelled differently. - -**Where a rule can't be made reliable in a straightforward way, we will depend on -a different, established type checker rather than ship our own unreliable version -of it.** An answer from an engine that has earned trust is worth more to you than -a Basilisk-branded one that hasn't. No replacement figure gets published until it -survives off-suite and mutation testing. - -That means Basilisk gets **smaller** before it gets better. Expect fewer rules, -fewer diagnostics, and a lower conformance number. We will report each drop -rather than avoid it. What is left will be code that is honest about what it -does — nothing else. - -### Basilisk is much more than a type checker - -Type checking is one part of it. The rest is a complete Python workflow in a -single Rust binary — language server, refactoring, formatting, integrated -debugging, profiling, and the editor extensions — and none of it rests on the -rules under audit. That is what we are sharpening while the audit runs: make the -parts that are genuinely useful solid, and remove anything that could hand you a -misleading result. The point of getting smaller is to end up with a tool you can -believe. - -[Read the full correction →](https://www.basilisk-python.dev/docs/conformance/)  •  -[Integrity audit →](https://github.com/Nimblesite/Basilisk/blob/main/docs/CONFORMANCE-INTEGRITY-AUDIT.md) - -## What you get - -One extension covers the whole Python workflow. A single bundled Rust binary -drives it — no Node.js, no npm, no `pip install`: - -- **Diagnostics as you type** — incremental analysis powered by [Salsa](https://github.com/salsa-rs/salsa) -- **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](https://github.com/microsoft/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 - -Strictness is configured **per rule**, never by a mode: the unconfigured default -enables the typing-spec rule set, and each rule can be graded down to -`warning`/`info` so a codebase can adopt type safety incrementally. Every -diagnostic carries a `help`, a `note`, and a link to a per-rule explainer, so a -red squiggle tells you *why*. - -## 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 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/). +# Basilisk is unlisted -## Development +> **You are reading the `basilisk-python` wheel listing** — the Basilisk CLI packaged for `pip`/`uv`. -```sh -cargo build # build all crates -cargo test # run all tests -cargo clippy # lint (zero warnings policy) -cargo fmt # format -``` +**Basilisk's type checker was producing incorrect results.** Rules decided from the way code was *spelled* rather than what it meant, so they could be wrong in both directions — a false error on correct code, or silence on a real bug. -Rust 1.87+ required. +**We asked for Basilisk to be removed from the `python/typing` conformance results, and it has been removed** ([python/typing#2330](https://github.com/python/typing/pull/2330)). That score did not demonstrate correctness. -## Contributing +**We cannot tell you how much of the checker this affects.** The code responsible is not isolated to a known set of rules. We will not estimate. That uncertainty is the reason for everything below. -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)). +**A code-quality tool that does not produce correct results is worse than useless.** Basilisk is being unlisted everywhere it was published — the VS Code Marketplace, Open VSX, the Zed registry, PyPI, the Homebrew tap, and the Scoop bucket — and the type checker is inert. Remove it from your pipeline; it checks nothing, and every invocation fails rather than reporting a clean run. -## Acknowledgments +**We are not fixing Basilisk's type checker code. We are rebuilding from the ground up as a new product.** It will ship only what can be trusted. That most likely will not include type checking. Nothing is relisted until it has been rebuilt from components we can vouch for. If type checking ever returns, it will be externally audited before release. + +Basilisk's author has published a full public account: [an apology](https://www.christianfindlay.com/blog/basilisk-conformance-apology). + +## What to do now -Basilisk builds on the open-source community — with thanks to: +**Remove Basilisk from your pipeline, your pre-commit hooks, and your editor.** Uninstall the CLI and the extension. -- **[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. +The type checker is inert: it checks nothing, and every invocation fails. It prints this statement and exits non-zero, so a build that still calls it fails loudly rather than reporting a clean run. Do not treat that failure as a finding about your code. -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. +**Treat every result Basilisk gave you as unverified.** A clean run was never evidence that your code was clean, and an error it reported may never have been real. + +Every distribution channel is being unlisted. Nothing will be relisted until it has been rebuilt from components we can vouch for. + +## Acknowledgments ---- +Basilisk is built on [Ruff](https://github.com/astral-sh/ruff) by [Astral](https://astral.sh/), whose parser, AST, and formatter crates it embeds (MIT), and on standard-library type stubs from [typeshed](https://github.com/python/typeshed) (Apache-2.0, with MIT-licensed parts). Neither project is responsible for how Basilisk used them. Full component list 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). ## License -Basilisk source code is MIT licensed. Binary distributions also contain -third-party components under the licenses shipped beside each artifact. +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 f4b6ed1eb..425f7a190 100644 --- a/README.md +++ b/README.md @@ -1,208 +1,38 @@ -

- Basilisk -

+# Basilisk is unlisted -

Basilisk

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

English · 简体中文

+**Basilisk's type checker was producing incorrect results.** Rules decided from the way code was *spelled* rather than what it meant, so they could be wrong in both directions — a false error on correct code, or silence on a real bug. -

- An open-source Python type checker and language server, built in Rust.
- One extension for the whole workflow — diagnostics, autocomplete, refactoring, formatting, debugging, and profiling — driven by a single bundled binary. -

+**We asked for Basilisk to be removed from the `python/typing` conformance results, and it has been removed** ([python/typing#2330](https://github.com/python/typing/pull/2330)). That score did not demonstrate correctness. -> **You are reading the Basilisk source repository** — the checker, language server, editor extensions, and website all live here. +**We cannot tell you how much of the checker this affects.** The code responsible is not isolated to a known set of rules. We will not estimate. That uncertainty is the reason for everything below. -

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

- -

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

- -> ## ⚠️ Do not use Basilisk's type checker in your pipeline -> -> **The type checker still contains code that isn't doing real type checking, and -> it is not yet trustworthy.** Some rules decide from the way code is *spelled* -> rather than what it means, so they can be wrong in both directions — a false -> error on correct code, or silence where there is a genuine bug. Until the audit -> below is finished, don't gate CI on `basilisk check`, don't block a merge with -> it, and don't read a clean run as a clean codebase. -> -> The rest of Basilisk — language server, refactoring, formatting, debugging, -> profiling — does not depend on those rules and is unaffected. - -## Restoring trust: audit, delete, and lean on a checker that works - -We withdrew our former conformance claim and our benchmark figures, and asked to be -[removed from the official `python/typing` results](https://github.com/python/typing/blob/main/conformance/results/results.html). -The cause was checker logic fitted to the contents of conformance test files -instead of implementing the typing specification generally: rules that matched -the *spelling* of code rather than its meaning. Rename an import or reformat a -file and the answer changed. A score produced that way is not evidence. - -**This was a mistake and a failure to verify.** Our process treated the score as -the goal, matching text raises a score faster than real analysis does, and we -published without ever asking whether a rule still held when the same program was -spelled differently. Basilisk's author has published a -[personal account and apology](https://www.christianfindlay.com/blog/basilisk-conformance-apology). - -**So we are auditing every rule and deleting the ones that don't do real type -checking.** Not rewriting them, not patching them, not marking them TODO — -deleting them, with a failing test left behind so the gap is visible instead of -hidden. A rule stays only if it decides from the resolved syntax tree and gives -the same answer when the code is spelled differently. - -**Where a rule can't be made reliable in a straightforward way, we will depend on -a different, established type checker rather than ship our own unreliable version -of it.** An answer from an engine that has earned trust is worth more to you than -a Basilisk-branded one that hasn't. No replacement figure gets published until it -survives off-suite and mutation testing. - -That means Basilisk gets **smaller** before it gets better. Expect fewer rules, -fewer diagnostics, and a lower conformance number. We will report each drop -rather than avoid it. What is left will be code that is honest about what it -does — nothing else. - -### Basilisk is much more than a type checker - -Type checking is one part of it. The rest is a complete Python workflow in a -single Rust binary — language server, refactoring, formatting, integrated -debugging, profiling, and the editor extensions — and none of it rests on the -rules under audit. That is what we are sharpening while the audit runs: make the -parts that are genuinely useful solid, and remove anything that could hand you a -misleading result. The point of getting smaller is to end up with a tool you can -believe. - -[Read the full correction →](https://www.basilisk-python.dev/docs/conformance/)  •  -[Integrity audit →](docs/CONFORMANCE-INTEGRITY-AUDIT.md) - -## What you get - -One extension covers the whole Python workflow. A single bundled Rust binary -drives it — no Node.js, no npm, no `pip install`: - -- **Diagnostics as you type** — incremental analysis powered by [Salsa](https://github.com/salsa-rs/salsa) -- **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](https://github.com/microsoft/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 - -Strictness is configured **per rule**, never by a mode: the unconfigured default -enables the typing-spec rule set, and each rule can be graded down to -`warning`/`info` so a codebase can adopt type safety incrementally. Every -diagnostic carries a `help`, a `note`, and a link to a per-rule explainer, so a -red squiggle tells you *why*. - -## 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/). +**A code-quality tool that does not produce correct results is worse than useless.** Basilisk is being unlisted everywhere it was published — the VS Code Marketplace, Open VSX, the Zed registry, PyPI, the Homebrew tap, and the Scoop bucket — and the type checker is inert. Remove it from your pipeline; it checks nothing, and every invocation fails rather than reporting a clean run. -## Development +**We are not fixing Basilisk's type checker code. We are rebuilding from the ground up as a new product.** It will ship only what can be trusted. That most likely will not include type checking. Nothing is relisted until it has been rebuilt from components we can vouch for. If type checking ever returns, it will be externally audited before release. -```sh -cargo build # build all crates -cargo test # run all tests -cargo clippy # lint (zero warnings policy) -cargo fmt # format -``` +Basilisk's author has published a full public account: [an apology](https://www.christianfindlay.com/blog/basilisk-conformance-apology). -Rust 1.87+ required. +## What to do now -## Contributing +**Remove Basilisk from your pipeline, your pre-commit hooks, and your editor.** Uninstall the CLI and the extension. -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)). +The type checker is inert: it checks nothing, and every invocation fails. It prints this statement and exits non-zero, so a build that still calls it fails loudly rather than reporting a clean run. Do not treat that failure as a finding about your code. -## Acknowledgments +**Treat every result Basilisk gave you as unverified.** A clean run was never evidence that your code was clean, and an error it reported may never have been real. -Basilisk builds on the open-source community — with thanks to: +Every distribution channel is being unlisted. Nothing will be relisted until it has been rebuilt from components we can vouch for. -- **[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. +## Acknowledgments ---- +Basilisk is built on [Ruff](https://github.com/astral-sh/ruff) by [Astral](https://astral.sh/), whose parser, AST, and formatter crates it embeds (MIT), and on standard-library type stubs from [typeshed](https://github.com/python/typeshed) (Apache-2.0, with MIT-licensed parts). Neither project is responsible for how Basilisk used them. Full component list and required notices: [NOTICES](NOTICES) and [RUST-DEPENDENCY-LICENSES](RUST-DEPENDENCY-LICENSES). ## License -Basilisk source code is MIT licensed. Binary distributions also contain -third-party components under the licenses shipped beside each artifact. +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.zh.md b/README.zh.md deleted file mode 100644 index 85ebcfe03..000000000 --- a/README.zh.md +++ /dev/null @@ -1,193 +0,0 @@ - -

- Basilisk -

- -

Basilisk

- -

English · 简体中文

- -

- 用 Rust 打造的开源 Python 类型检查器与语言服务器。
- 一个扩展覆盖整套工作流 —— 诊断、自动补全、重构、格式化、调试与性能分析 —— 全部由单一捆绑的二进制文件驱动。 -

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

- 网站  •  - 安装  •  - 快速上手  •  - 规则  •  - 重构  •  - GitHub -

- -

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

- -> ## ⚠️ 请勿在流水线中使用 Basilisk 的类型检查器 -> -> **类型检查器中仍然存在没有做真正类型检查的代码,它目前还不值得信任。** 有些规则 -> 依据的是代码的**写法**而不是含义,因此两个方向上都可能出错 —— 既可能对正确的代码 -> 报出虚假错误,也可能对真实的缺陷保持沉默。在下文所述的审计完成之前,请不要用 -> `basilisk check` 作为 CI 的门禁,不要用它拦截合并,也不要把一次干净的运行结果当作 -> 代码库是干净的。 -> -> Basilisk 的其余部分 —— 语言服务器、重构、格式化、调试、性能分析 —— 并不依赖这些 -> 规则,因此不受影响。 - -## 重建信任:审计、删除,并倚重真正可靠的检查器 - -我们撤回了此前的一致性宣称与基准测试数字,并主动请求 -[从官方 `python/typing` 结果中移除](https://github.com/python/typing/blob/main/conformance/results/results.html)。 -原因是检查器中存在针对一致性测试文件内容而写的逻辑,而不是对类型规范的通用实现: -那些规则匹配的是代码的**写法**,而不是代码的含义。改一个导入别名或重新格式化文件, -结论就会变。这样得出的分数并不能作为证据。 - -**这是一个错误、一次验证上的失职。** 我们的流程把分数当成了目标,而匹配文本比真正做 -分析更快地提高分数;我们在发布之前,始终没有问过这样一个问题 —— 同一个程序换一种 -写法时,这条规则是否依然成立。Basilisk 作者已发表 -[个人说明与致歉](https://www.christianfindlay.com/blog/basilisk-conformance-apology)。 - -**因此,我们正在逐条审计规则,并删除那些没有做真正类型检查的规则。** 不是重写,不是 -打补丁,也不是标一个 TODO —— 是删除,并留下一个失败的测试,让这个缺口可见而不是被 -掩盖。一条规则只有在依据已解析的语法树做判断、并且在代码换一种写法时给出相同结论的 -情况下,才会保留。 - -**如果一条规则无法以直截了当的方式做到可靠,我们会转而依赖另一个成熟的类型检查器, -而不是端出我们自己那份不可靠的实现。** 一个已经赢得信任的引擎给出的答案,对你而言 -比一个挂着 Basilisk 名号却没有赢得信任的答案更有价值。在通过套件之外的用例与变异 -测试之前,我们不会发布任何替代数字。 - -这意味着 Basilisk 会**先变小,再变好**。规则会更少,诊断会更少,一致性数字也会更低。 -每一次下降我们都会如实报告,而不是设法回避。留下来的,将是对自己所做之事诚实的代码 -—— 仅此而已。 - -### Basilisk 远不只是一个类型检查器 - -类型检查只是其中一部分。其余部分是装在单个 Rust 二进制文件里的完整 Python 工作流 -—— 语言服务器、重构、格式化、集成调试、性能分析,以及各个编辑器扩展 —— 它们都不 -建立在正在接受审计的规则之上。这正是我们在审计期间着力打磨的地方:把真正有用的部分 -做扎实,并移除任何可能给出误导性结果的东西。变小的意义,是最终得到一个你可以信赖的 -工具。 - -[阅读完整更正 →](https://www.basilisk-python.dev/zh/docs/conformance/)  •  -[完整性审计 →](docs/CONFORMANCE-INTEGRITY-AUDIT.md) - -## 你能得到什么 - -一个扩展即可覆盖整套 Python 工作流。一切由单一捆绑的 Rust 二进制文件驱动 —— -无需 Node.js、无需 npm、无需 `pip install`: - -- **随输入实时诊断** —— 由 [Salsa](https://github.com/salsa-rs/salsa) 提供增量分析 -- **自动补全、悬停信息、跳转到定义、查找引用、重命名** -- **重构代码操作** —— 提取、内联、移动符号、整理导入 -- **集成调试** —— 按 F5 即可通过捆绑的 [debugpy](https://github.com/microsoft/debugpy) 调试;无需额外扩展 -- **集成性能分析** —— CPU 热力图、火焰图,以及带泄漏检测的内存面板 -- **活动面板** —— 模块树与逐模块的类型健康度覆盖率,并可切换功能开关 -- 内置 **Inlay hints** 与 **Ruff** 格式化/导入整理 -- **来自 [typeshed](https://github.com/python/typeshed) 的标准库类型** —— 完整的 `stdlib/` 快照已编译进二进制文件,因此悬停与诊断在离线且零配置的情况下依然可用 - -严格程度按**规则**配置,而不是靠模式切换:未配置的默认值即启用类型规范规则集, -每条规则都可以降级为 `warning`/`info`,让代码库能够渐进地采用类型安全。每条诊断 -都附带 `help`、`note` 以及指向每条规则详解页的链接,因此一条红色波浪线总能告诉你 -*为什么*。 - -## 安装 - -**编辑器扩展** —— 从 [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/RUST-DEPENDENCY-LICENSES b/RUST-DEPENDENCY-LICENSES index 455886f3b..962de0da3 100644 --- a/RUST-DEPENDENCY-LICENSES +++ b/RUST-DEPENDENCY-LICENSES @@ -10,693 +10,18 @@ the five supported release targets. Regenerate with: Components ---------- -addr2line 0.26.1 - Source: https://github.com/gimli-rs/addr2line - License: Apache-2.0 OR MIT -adler2 2.0.1 - Source: https://github.com/oyvindln/adler2 - License: 0BSD OR MIT OR Apache-2.0 -ahash 0.8.12 - Source: https://github.com/tkaitchuck/ahash - License: MIT OR Apache-2.0 -aho-corasick 1.1.4 - Source: https://github.com/BurntSushi/aho-corasick - License: Unlicense OR MIT -allocator-api2 0.2.21 - Source: https://github.com/zakarumych/allocator-api2 - License: MIT OR Apache-2.0 -anstream 1.0.0 - Source: https://github.com/rust-cli/anstyle.git - License: MIT OR Apache-2.0 -anstyle 1.0.13 - Source: https://github.com/rust-cli/anstyle.git - License: MIT OR Apache-2.0 -anstyle-parse 1.0.0 - Source: https://github.com/rust-cli/anstyle.git - License: MIT OR Apache-2.0 -anstyle-query 1.1.5 - Source: https://github.com/rust-cli/anstyle.git - License: MIT OR Apache-2.0 -anstyle-wincon 3.0.11 - Source: https://github.com/rust-cli/anstyle.git - License: MIT OR Apache-2.0 -anyhow 1.0.102 - Source: https://github.com/dtolnay/anyhow - License: MIT OR Apache-2.0 -arc-swap 1.9.2 - Source: https://github.com/vorner/arc-swap - License: MIT OR Apache-2.0 -arrayvec 0.7.6 - Source: https://github.com/bluss/arrayvec - License: MIT OR Apache-2.0 -async-trait 0.1.89 - Source: https://github.com/dtolnay/async-trait - License: MIT OR Apache-2.0 -attribute-derive 0.10.5 - Source: https://github.com/ModProg/attribute-derive - License: MIT OR Apache-2.0 -attribute-derive-macro 0.10.5 - Source: https://github.com/ModProg/attribute-derive - License: MIT -auto_impl 1.3.0 - Source: https://github.com/auto-impl-rs/auto_impl/ - License: MIT OR Apache-2.0 -base64 0.22.1 - Source: https://github.com/marshallpierce/rust-base64 - License: MIT OR Apache-2.0 -bitflags 1.3.2 - Source: https://github.com/bitflags/bitflags - License: MIT OR Apache-2.0 -bitflags 2.11.0 - Source: https://github.com/bitflags/bitflags - License: MIT OR Apache-2.0 -block-buffer 0.12.1 - Source: https://github.com/RustCrypto/utils - License: MIT OR Apache-2.0 -block2 0.6.2 - Source: https://github.com/madsmtm/objc2 - License: MIT -boxcar 0.2.14 - Source: https://github.com/ibraheemdev/boxcar - License: MIT -bstr 1.12.1 - Source: https://github.com/BurntSushi/bstr - License: MIT OR Apache-2.0 -bumpalo 3.20.2 - Source: https://github.com/fitzgen/bumpalo - License: MIT OR Apache-2.0 -bytemuck 1.25.0 - Source: https://github.com/Lokathor/bytemuck - License: Zlib OR Apache-2.0 OR MIT -bytes 1.11.1 - Source: https://github.com/tokio-rs/bytes - License: MIT -camino 1.2.4 - Source: https://github.com/camino-rs/camino - License: MIT OR Apache-2.0 -castaway 0.2.4 - Source: https://github.com/sagebind/castaway - License: MIT -cfg-if 1.0.4 - Source: https://github.com/rust-lang/cfg-if - License: MIT OR Apache-2.0 -chacha20 0.10.1 - Source: https://github.com/RustCrypto/stream-ciphers - License: MIT OR Apache-2.0 -char_str 0.0.2 - Source: https://github.com/astral-sh/char_str - License: MIT -chrono 0.4.44 - Source: https://github.com/chronotope/chrono - License: MIT OR Apache-2.0 -clap 4.6.1 - Source: https://github.com/clap-rs/clap - License: MIT OR Apache-2.0 -clap_builder 4.6.0 - Source: https://github.com/clap-rs/clap - License: MIT OR Apache-2.0 -clap_complete 4.6.5 - Source: https://github.com/clap-rs/clap - License: MIT OR Apache-2.0 -clap_derive 4.6.1 - Source: https://github.com/clap-rs/clap - License: MIT OR Apache-2.0 -clap_lex 1.0.0 - Source: https://github.com/clap-rs/clap - License: MIT OR Apache-2.0 -collection_literals 1.0.3 - Source: https://github.com/staedoix/collection_literals - License: MIT -colorchoice 1.0.4 - Source: https://github.com/rust-cli/anstyle.git - License: MIT OR Apache-2.0 -colored 3.1.1 - Source: https://github.com/mackwic/colored - License: MPL-2.0 -compact_str 0.10.0 - Source: https://github.com/ParkMyCar/compact_str - License: MIT -console 0.16.3 - Source: https://github.com/console-rs/console - License: MIT -const-oid 0.10.2 - Source: https://github.com/RustCrypto/formats - License: Apache-2.0 OR MIT -core-foundation-sys 0.8.7 - Source: https://github.com/servo/core-foundation-rs - License: MIT OR Apache-2.0 -countme 3.0.1 - Source: https://github.com/matklad/countme - License: MIT OR Apache-2.0 -cpp_demangle 0.5.1 - Source: https://github.com/gimli-rs/cpp_demangle - License: MIT OR Apache-2.0 -cpufeatures 0.3.0 - Source: https://github.com/RustCrypto/utils - License: MIT OR Apache-2.0 -crc32fast 1.5.0 - Source: https://github.com/srijs/rust-crc32fast - License: MIT OR Apache-2.0 -crossbeam-channel 0.5.15 - Source: https://github.com/crossbeam-rs/crossbeam - License: MIT OR Apache-2.0 -crossbeam-deque 0.8.6 - Source: https://github.com/crossbeam-rs/crossbeam - License: MIT OR Apache-2.0 -crossbeam-epoch 0.9.20 - Source: https://github.com/crossbeam-rs/crossbeam - License: MIT OR Apache-2.0 -crossbeam-queue 0.3.12 - Source: https://github.com/crossbeam-rs/crossbeam - License: MIT OR Apache-2.0 -crossbeam-utils 0.8.21 - Source: https://github.com/crossbeam-rs/crossbeam - License: MIT OR Apache-2.0 -crypto-common 0.2.2 - Source: https://github.com/RustCrypto/traits - License: MIT OR Apache-2.0 -ctrlc 3.5.2 - Source: https://github.com/Detegr/rust-ctrlc.git - License: MIT OR Apache-2.0 -dashmap 5.5.3 - Source: https://github.com/xacrimon/dashmap - License: MIT -dashmap 6.2.1 - Source: https://github.com/xacrimon/dashmap - License: MIT -data-encoding 2.10.0 - Source: https://github.com/ia0/data-encoding - License: MIT -derive-where 1.6.0 - Source: https://github.com/ModProg/derive-where - License: MIT OR Apache-2.0 -digest 0.11.3 - Source: https://github.com/RustCrypto/traits - License: MIT OR Apache-2.0 -dispatch2 0.3.1 - Source: https://github.com/madsmtm/objc2 - License: Zlib OR Apache-2.0 OR MIT -displaydoc 0.2.5 - Source: https://github.com/yaahc/displaydoc - License: MIT OR Apache-2.0 -drop_bomb 0.1.5 - Source: https://github.com/matklad/drop_bomb - License: MIT OR Apache-2.0 -dunce 1.0.5 - Source: https://gitlab.com/kornelski/dunce - License: CC0-1.0 OR MIT-0 OR Apache-2.0 -either 1.15.0 - Source: https://github.com/rayon-rs/either - License: MIT OR Apache-2.0 -encode_unicode 1.0.0 - Source: https://github.com/tormol/encode_unicode - License: Apache-2.0 OR MIT -env_filter 1.0.1 - Source: https://github.com/rust-cli/env_logger - License: MIT OR Apache-2.0 -env_logger 0.11.10 - Source: https://github.com/rust-cli/env_logger - License: MIT OR Apache-2.0 -equivalent 1.0.2 - Source: https://github.com/indexmap-rs/equivalent - License: Apache-2.0 OR MIT -errno 0.3.14 - Source: https://github.com/lambda-fairy/rust-errno - License: MIT OR Apache-2.0 -fallible-iterator 0.3.0 - Source: https://github.com/sfackler/rust-fallible-iterator - License: MIT OR Apache-2.0 -fastrand 2.3.0 - Source: https://github.com/smol-rs/fastrand - License: Apache-2.0 OR MIT -filetime 0.2.29 - Source: https://github.com/alexcrichton/filetime - License: MIT OR Apache-2.0 -flate2 1.1.9 - Source: https://github.com/rust-lang/flate2-rs - License: MIT OR Apache-2.0 -foldhash 0.2.0 - Source: https://github.com/orlp/foldhash - License: Zlib -form_urlencoded 1.2.2 - Source: https://github.com/servo/rust-url - License: MIT OR Apache-2.0 -futures 0.3.32 - Source: https://github.com/rust-lang/futures-rs - License: MIT OR Apache-2.0 -futures-channel 0.3.33 - Source: https://github.com/rust-lang/futures-rs - License: MIT OR Apache-2.0 -futures-core 0.3.33 - Source: https://github.com/rust-lang/futures-rs - License: MIT OR Apache-2.0 -futures-io 0.3.33 - Source: https://github.com/rust-lang/futures-rs - License: MIT OR Apache-2.0 -futures-macro 0.3.33 - Source: https://github.com/rust-lang/futures-rs - License: MIT OR Apache-2.0 -futures-sink 0.3.33 - Source: https://github.com/rust-lang/futures-rs - License: MIT OR Apache-2.0 -futures-task 0.3.33 - Source: https://github.com/rust-lang/futures-rs - License: MIT OR Apache-2.0 -futures-util 0.3.33 - Source: https://github.com/rust-lang/futures-rs - License: MIT OR Apache-2.0 -get-size-derive2 0.10.3 - Source: https://github.com/bircni/get-size2/tree/main/crates/get-size-derive2 - License: MIT OR Apache-2.0 -get-size2 0.10.3 - Source: https://github.com/bircni/get-size2 - License: MIT OR Apache-2.0 -getrandom 0.2.17 - Source: https://github.com/rust-random/getrandom - License: MIT OR Apache-2.0 -getrandom 0.3.4 - Source: https://github.com/rust-random/getrandom - License: MIT OR Apache-2.0 -getrandom 0.4.2 - Source: https://github.com/rust-random/getrandom - License: MIT OR Apache-2.0 -gimli 0.33.0 - Source: https://github.com/gimli-rs/gimli - License: MIT OR Apache-2.0 -glob 0.3.3 - Source: https://github.com/rust-lang/glob - License: MIT OR Apache-2.0 -globset 0.4.18 - Source: https://github.com/BurntSushi/ripgrep/tree/master/crates/globset - License: Unlicense OR MIT -goblin 0.10.5 - Source: https://github.com/m4b/goblin - License: MIT -hashbrown 0.14.5 - Source: https://github.com/rust-lang/hashbrown - License: MIT OR Apache-2.0 -hashbrown 0.17.1 - Source: https://github.com/rust-lang/hashbrown - License: MIT OR Apache-2.0 -hashlink 0.12.0 - Source: https://github.com/djc/hashlink - License: MIT OR Apache-2.0 -heck 0.5.0 - Source: https://github.com/withoutboats/heck - License: MIT OR Apache-2.0 -http 1.4.0 - Source: https://github.com/hyperium/http - License: MIT OR Apache-2.0 -httparse 1.10.1 - Source: https://github.com/seanmonstar/httparse - License: MIT OR Apache-2.0 -hybrid-array 0.4.13 - Source: https://github.com/RustCrypto/hybrid-array - License: MIT OR Apache-2.0 -iana-time-zone 0.1.65 - Source: https://github.com/strawlab/iana-time-zone - License: MIT OR Apache-2.0 -icu_collections 2.1.1 - Source: https://github.com/unicode-org/icu4x - License: Unicode-3.0 -icu_locale_core 2.1.1 - Source: https://github.com/unicode-org/icu4x - License: Unicode-3.0 -icu_normalizer 2.1.1 - Source: https://github.com/unicode-org/icu4x - License: Unicode-3.0 -icu_normalizer_data 2.1.1 - Source: https://github.com/unicode-org/icu4x - License: Unicode-3.0 -icu_properties 2.1.2 - Source: https://github.com/unicode-org/icu4x - License: Unicode-3.0 -icu_properties_data 2.1.2 - Source: https://github.com/unicode-org/icu4x - License: Unicode-3.0 -icu_provider 2.1.1 - Source: https://github.com/unicode-org/icu4x - License: Unicode-3.0 -idna 1.1.0 - Source: https://github.com/servo/rust-url/ - License: MIT OR Apache-2.0 -idna_adapter 1.2.1 - Source: https://github.com/hsivonen/idna_adapter - License: Apache-2.0 OR MIT -indexmap 2.14.0 - Source: https://github.com/indexmap-rs/indexmap - License: Apache-2.0 OR MIT -indicatif 0.18.4 - Source: https://github.com/console-rs/indicatif - License: MIT -inferno 0.12.8 - Source: https://github.com/jonhoo/inferno.git - License: CDDL-1.0 -interpolator 0.5.0 - Source: https://github.com/ModProg/interpolator - License: MIT OR Apache-2.0 -intrusive-collections 0.10.2 - Source: https://github.com/Amanieu/intrusive-rs - License: MIT OR Apache-2.0 -inventory 0.3.24 - Source: https://github.com/dtolnay/inventory - License: MIT OR Apache-2.0 -is-macro 0.3.7 - Source: https://github.com/dudykr/ddbase.git - License: Apache-2.0 -is_terminal_polyfill 1.70.2 - Source: https://github.com/polyfill-rs/is_terminal_polyfill - License: MIT OR Apache-2.0 -itertools 0.15.0 - Source: https://github.com/rust-itertools/itertools - License: MIT OR Apache-2.0 itoa 1.0.17 Source: https://github.com/dtolnay/itoa License: MIT OR Apache-2.0 -jiff 0.2.23 - Source: https://github.com/BurntSushi/jiff - License: Unlicense OR MIT -lazy_static 1.5.0 - Source: https://github.com/rust-lang-nursery/lazy-static.rs - License: MIT OR Apache-2.0 -libc 0.2.182 - Source: https://github.com/rust-lang/libc - License: MIT OR Apache-2.0 -libm 0.2.16 - Source: https://github.com/rust-lang/compiler-builtins - License: MIT -libproc 0.14.11 - Source: https://github.com/andrewdavidmackenzie/libproc-rs - License: MIT -linux-raw-sys 0.12.1 - Source: https://github.com/sunfishcode/linux-raw-sys - License: Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT -litemap 0.8.1 - Source: https://github.com/unicode-org/icu4x - License: Unicode-3.0 -lock_api 0.4.14 - Source: https://github.com/Amanieu/parking_lot - License: MIT OR Apache-2.0 -log 0.4.29 - Source: https://github.com/rust-lang/log - License: MIT OR Apache-2.0 -lru 0.17.0 - Source: https://github.com/jeromefroe/lru-rs.git - License: MIT -lsp-types 0.94.1 - Source: https://github.com/gluon-lang/lsp-types - License: MIT -mach 0.3.2 - Source: https://github.com/fitzgen/mach - License: BSD-2-Clause -mach2 0.4.3 - Source: https://github.com/JohnTitor/mach2 - License: BSD-2-Clause OR MIT OR Apache-2.0 -mach_o_sys 0.1.1 - Source: https://github.com/fitzgen/mach_o_sys - License: Apache-2.0 OR MIT -manyhow 0.11.4 - Source: https://github.com/ModProg/manyhow - License: MIT OR Apache-2.0 -manyhow-macros 0.11.4 - Source: https://github.com/ModProg/manyhow - License: MIT OR Apache-2.0 -matchers 0.2.0 - Source: https://github.com/hawkw/matchers - License: MIT -matchit 0.9.2 - Source: https://github.com/ibraheemdev/matchit - License: MIT AND BSD-3-Clause memchr 2.8.0 Source: https://github.com/BurntSushi/memchr License: Unlicense OR MIT -memmap2 0.9.10 - Source: https://github.com/RazrFalcon/memmap2-rs - License: MIT OR Apache-2.0 -memoffset 0.9.1 - Source: https://github.com/Gilnaa/memoffset - License: MIT -miniz_oxide 0.8.9 - Source: https://github.com/Frommi/miniz_oxide/tree/master/miniz_oxide - License: MIT OR Zlib OR Apache-2.0 -mio 1.1.1 - Source: https://github.com/tokio-rs/mio - License: MIT -nix 0.31.2 - Source: https://github.com/nix-rust/nix - License: MIT -ntapi 0.4.3 - Source: https://github.com/MSxDOS/ntapi - License: Apache-2.0 OR MIT -nu-ansi-term 0.50.3 - Source: https://github.com/nushell/nu-ansi-term - License: MIT -num-format 0.4.4 - Source: https://github.com/bcmyers/num-format - License: MIT OR Apache-2.0 -num-traits 0.2.19 - Source: https://github.com/rust-num/num-traits - License: MIT OR Apache-2.0 -objc2 0.6.4 - Source: https://github.com/madsmtm/objc2 - License: MIT -objc2-core-foundation 0.3.2 - Source: https://github.com/madsmtm/objc2 - License: Zlib OR Apache-2.0 OR MIT -objc2-encode 4.1.0 - Source: https://github.com/madsmtm/objc2 - License: MIT -objc2-foundation 0.3.2 - Source: https://github.com/madsmtm/objc2 - License: MIT -objc2-io-kit 0.3.2 - Source: https://github.com/madsmtm/objc2 - License: Zlib OR Apache-2.0 OR MIT -objc2-open-directory 0.3.2 - Source: https://github.com/madsmtm/objc2 - License: Zlib OR Apache-2.0 OR MIT -object 0.39.1 - Source: https://github.com/gimli-rs/object - License: Apache-2.0 OR MIT -once_cell 1.21.4 - Source: https://github.com/matklad/once_cell - License: MIT OR Apache-2.0 -once_cell_polyfill 1.70.2 - Source: https://github.com/polyfill-rs/once_cell_polyfill - License: MIT OR Apache-2.0 -ordermap 1.2.0 - Source: https://github.com/indexmap-rs/ordermap - License: Apache-2.0 OR MIT -page_size 0.6.0 - Source: https://github.com/Elzair/page_size_rs - License: MIT OR Apache-2.0 -parking_lot 0.12.5 - Source: https://github.com/Amanieu/parking_lot - License: MIT OR Apache-2.0 -parking_lot_core 0.9.12 - Source: https://github.com/Amanieu/parking_lot - License: MIT OR Apache-2.0 -path-slash 0.2.1 - Source: https://github.com/rhysd/path-slash - License: MIT -pathdiff 0.2.3 - Source: https://github.com/Manishearth/pathdiff - License: MIT OR Apache-2.0 -percent-encoding 2.3.2 - Source: https://github.com/servo/rust-url/ - License: MIT OR Apache-2.0 -phf 0.11.3 - Source: https://github.com/rust-phf/rust-phf - License: MIT -phf_shared 0.11.3 - Source: https://github.com/rust-phf/rust-phf - License: MIT -pin-project 1.1.11 - Source: https://github.com/taiki-e/pin-project - License: Apache-2.0 OR MIT -pin-project-internal 1.1.11 - Source: https://github.com/taiki-e/pin-project - License: Apache-2.0 OR MIT -pin-project-lite 0.2.17 - Source: https://github.com/taiki-e/pin-project-lite - License: Apache-2.0 OR MIT -plain 0.2.3 - Source: https://github.com/randomites/plain - License: MIT OR Apache-2.0 -portable-atomic 1.13.1 - Source: https://github.com/taiki-e/portable-atomic - License: Apache-2.0 OR MIT -potential_utf 0.1.4 - Source: https://github.com/unicode-org/icu4x - License: Unicode-3.0 -ppv-lite86 0.2.21 - Source: https://github.com/cryptocorrosion/cryptocorrosion - License: MIT OR Apache-2.0 -proc-macro-utils 0.10.0 - Source: https://github.com/ModProg/proc-macro-utils - License: MIT OR Apache-2.0 proc-macro2 1.0.107 Source: https://github.com/dtolnay/proc-macro2 License: MIT OR Apache-2.0 -proc-maps 0.4.0 - Source: https://github.com/rbspy/proc-maps - License: MIT -py-spy 0.4.2 - Source: https://github.com/benfred/py-spy - License: MIT -quick-xml 0.41.0 - Source: https://github.com/tafia/quick-xml - License: MIT quote 1.0.47 Source: https://github.com/dtolnay/quote License: MIT OR Apache-2.0 -quote-use 0.8.4 - Source: https://github.com/ModProg/quote-use - License: MIT -quote-use-macros 0.8.4 - Source: https://github.com/ModProg/quote-use - License: MIT -rand 0.9.4 - Source: https://github.com/rust-random/rand - License: MIT OR Apache-2.0 -rand 0.10.2 - Source: https://github.com/rust-random/rand - License: MIT OR Apache-2.0 -rand_chacha 0.9.0 - Source: https://github.com/rust-random/rand - License: MIT OR Apache-2.0 -rand_core 0.9.5 - Source: https://github.com/rust-random/rand - License: MIT OR Apache-2.0 -rand_core 0.10.1 - Source: https://github.com/rust-random/rand_core - License: MIT OR Apache-2.0 -rand_distr 0.5.1 - Source: https://github.com/rust-random/rand_distr - License: MIT OR Apache-2.0 -rayon 1.12.0 - Source: https://github.com/rayon-rs/rayon - License: MIT OR Apache-2.0 -rayon-core 1.13.0 - Source: https://github.com/rayon-rs/rayon - License: MIT OR Apache-2.0 -read-process-memory 0.1.6 - Source: https://github.com/rbspy/read-process-memory - License: MIT -regex 1.12.3 - Source: https://github.com/rust-lang/regex - License: MIT OR Apache-2.0 -regex-automata 0.4.14 - Source: https://github.com/rust-lang/regex - License: MIT OR Apache-2.0 -regex-syntax 0.8.10 - Source: https://github.com/rust-lang/regex - License: MIT OR Apache-2.0 -remoteprocess 0.5.2 - Source: https://github.com/benfred/remoteprocess - License: MIT -rgb 0.8.53 - Source: https://github.com/kornelski/rust-rgb - License: MIT -ring 0.17.14 - Source: https://github.com/briansmith/ring - License: Apache-2.0 AND ISC -ruff_annotate_snippets 0.0.7 - Source: https://github.com/astral-sh/ruff - License: MIT OR Apache-2.0 -ruff_cache 0.0.7 - Source: https://github.com/astral-sh/ruff - License: MIT -ruff_db 0.0.7 - Source: https://github.com/astral-sh/ruff - License: MIT -ruff_diagnostics 0.0.7 - Source: https://github.com/astral-sh/ruff - License: MIT -ruff_formatter 0.0.7 - Source: https://github.com/astral-sh/ruff - License: MIT -ruff_macros 0.0.7 - Source: https://github.com/astral-sh/ruff - License: MIT -ruff_memory_usage 0.0.7 - Source: https://github.com/astral-sh/ruff - License: MIT -ruff_notebook 0.0.7 - Source: https://github.com/astral-sh/ruff - License: MIT -ruff_python_ast 0.0.7 - Source: https://github.com/astral-sh/ruff - License: MIT -ruff_python_formatter 0.0.7 - Source: https://github.com/astral-sh/ruff - License: MIT -ruff_python_parser 0.0.7 - Source: https://github.com/astral-sh/ruff - License: MIT -ruff_python_stdlib 0.0.7 - Source: https://github.com/astral-sh/ruff - License: MIT -ruff_python_trivia 0.0.7 - Source: https://github.com/astral-sh/ruff - License: MIT -ruff_source_file 0.0.7 - Source: https://github.com/astral-sh/ruff - License: MIT -ruff_text_size 0.0.7 - Source: https://github.com/astral-sh/ruff - License: MIT -rustc-demangle 0.1.27 - Source: https://github.com/rust-lang/rustc-demangle - License: MIT OR Apache-2.0 -rustc-hash 2.1.1 - Source: https://github.com/rust-lang/rustc-hash - License: Apache-2.0 OR MIT -rustix 1.1.4 - Source: https://github.com/bytecodealliance/rustix - License: Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT -rustls 0.23.43 - Source: https://github.com/rustls/rustls - License: Apache-2.0 OR MIT OR ISC -rustls-pki-types 1.15.1 - Source: https://github.com/rustls/pki-types - License: MIT OR Apache-2.0 -rustls-webpki 0.103.13 - Source: https://github.com/rustls/webpki - License: ISC -rustversion 1.0.22 - Source: https://github.com/dtolnay/rustversion - License: MIT OR Apache-2.0 -ruzstd 0.8.2 - Source: https://github.com/KillingSpark/zstd-rs - License: MIT -ryu 1.0.23 - Source: https://github.com/dtolnay/ryu - License: Apache-2.0 OR BSL-1.0 -salsa 0.28.1 - Source: https://github.com/salsa-rs/salsa - License: Apache-2.0 OR MIT -salsa-macro-rules 0.28.1 - Source: https://github.com/salsa-rs/salsa - License: Apache-2.0 OR MIT -salsa-macros 0.28.1 - Source: https://github.com/salsa-rs/salsa - License: Apache-2.0 OR MIT -same-file 1.0.6 - Source: https://github.com/BurntSushi/same-file - License: Unlicense OR MIT -scopeguard 1.2.0 - Source: https://github.com/bluss/scopeguard - License: MIT OR Apache-2.0 -scroll 0.13.0 - Source: https://github.com/m4b/scroll - License: MIT -scroll_derive 0.13.1 - Source: https://github.com/m4b/scroll - License: MIT -seahash 4.1.0 - Source: https://gitlab.redox-os.org/redox-os/seahash - License: MIT serde 1.0.229 Source: https://github.com/serde-rs/serde License: MIT OR Apache-2.0 @@ -709,578 +34,128 @@ serde_derive 1.0.229 serde_json 1.0.151 Source: https://github.com/serde-rs/json License: MIT OR Apache-2.0 -serde_repr 0.1.20 - Source: https://github.com/dtolnay/serde-repr - License: MIT OR Apache-2.0 -serde_spanned 1.1.1 - Source: https://github.com/toml-rs/toml - License: MIT OR Apache-2.0 -sha1 0.11.0 - Source: https://github.com/RustCrypto/hashes - License: MIT OR Apache-2.0 -sha2 0.11.0 - Source: https://github.com/RustCrypto/hashes - License: MIT OR Apache-2.0 -sharded-slab 0.1.7 - Source: https://github.com/hawkw/sharded-slab - License: MIT shipwright 0.10.0 Source: https://github.com/Nimblesite/Shipwright License: MIT shipwright-manifest 0.10.0 Source: https://github.com/Nimblesite/Shipwright License: MIT -signal-hook-registry 1.4.8 - Source: https://github.com/vorner/signal-hook - License: MIT OR Apache-2.0 -simd-adler32 0.3.8 - Source: https://github.com/mcountryman/simd-adler32 - License: MIT -similar 3.1.1 - Source: https://github.com/mitsuhiko/similar - License: Apache-2.0 -siphasher 1.0.2 - Source: https://github.com/jedisct1/rust-siphash - License: MIT OR Apache-2.0 -slab 0.4.12 - Source: https://github.com/tokio-rs/slab - License: MIT -smallvec 1.15.1 - Source: https://github.com/servo/rust-smallvec - License: MIT OR Apache-2.0 -socket2 0.6.2 - Source: https://github.com/rust-lang/socket2 - License: MIT OR Apache-2.0 -stable_deref_trait 1.2.1 - Source: https://github.com/storyyeller/stable_deref_trait - License: MIT OR Apache-2.0 -static_assertions 1.1.0 - Source: https://github.com/nvzqz/static-assertions-rs - License: MIT OR Apache-2.0 -str_stack 0.1.0 - Source: https://github.com/Stebalien/str_stack - License: MIT OR Apache-2.0 -strsim 0.11.1 - Source: https://github.com/rapidfuzz/strsim-rs - License: MIT -subtle 2.6.1 - Source: https://github.com/dalek-cryptography/subtle - License: BSD-3-Clause -supports-hyperlinks 3.2.0 - Source: https://github.com/zkat/supports-hyperlinks - License: Apache-2.0 -syn 2.0.119 - Source: https://github.com/dtolnay/syn - License: MIT OR Apache-2.0 syn 3.0.3 Source: https://github.com/dtolnay/syn License: MIT OR Apache-2.0 -synstructure 0.13.2 - Source: https://github.com/mystor/synstructure - License: MIT -sysinfo 0.39.6 - Source: https://github.com/GuillaumeGomez/sysinfo - License: MIT -tempfile 3.27.0 - Source: https://github.com/Stebalien/tempfile - License: MIT OR Apache-2.0 -terminal_size 0.4.4 - Source: https://github.com/eminence/terminal-size - License: MIT OR Apache-2.0 -termios 0.3.3 - Source: https://github.com/dcuddeback/termios-rs - License: MIT -thin-vec 0.2.18 - Source: https://github.com/mozilla/thin-vec - License: MIT OR Apache-2.0 thiserror 2.0.19 Source: https://github.com/dtolnay/thiserror License: MIT OR Apache-2.0 thiserror-impl 2.0.19 Source: https://github.com/dtolnay/thiserror License: MIT OR Apache-2.0 -thread_local 1.1.9 - Source: https://github.com/Amanieu/thread_local-rs - License: MIT OR Apache-2.0 -tinystr 0.8.2 - Source: https://github.com/unicode-org/icu4x - License: Unicode-3.0 -tinyvec 1.10.0 - Source: https://github.com/Lokathor/tinyvec - License: Zlib OR Apache-2.0 OR MIT -tinyvec_macros 0.1.1 - Source: https://github.com/Soveu/tinyvec_macros - License: MIT OR Apache-2.0 OR Zlib -tokio 1.50.0 - Source: https://github.com/tokio-rs/tokio - License: MIT -tokio-macros 2.6.0 - Source: https://github.com/tokio-rs/tokio - License: MIT -tokio-tungstenite 0.30.0 - Source: https://github.com/snapview/tokio-tungstenite - License: MIT -tokio-util 0.7.18 - Source: https://github.com/tokio-rs/tokio - License: MIT -toml 1.1.3+spec-1.1.0 - Source: https://github.com/toml-rs/toml - License: MIT OR Apache-2.0 -toml_datetime 1.1.1+spec-1.1.0 - Source: https://github.com/toml-rs/toml - License: MIT OR Apache-2.0 -toml_edit 0.25.13+spec-1.1.0 - Source: https://github.com/toml-rs/toml - License: MIT OR Apache-2.0 -toml_parser 1.1.2+spec-1.1.0 - Source: https://github.com/toml-rs/toml - License: MIT OR Apache-2.0 -toml_writer 1.1.2+spec-1.1.0 - Source: https://github.com/toml-rs/toml - License: MIT OR Apache-2.0 -tower 0.4.13 - Source: https://github.com/tower-rs/tower - License: MIT -tower-layer 0.3.3 - Source: https://github.com/tower-rs/tower - License: MIT -tower-lsp 0.20.0 - Source: https://github.com/ebkalderon/tower-lsp - License: MIT OR Apache-2.0 -tower-lsp-macros 0.9.0 - Source: https://github.com/ebkalderon/tower-lsp - License: MIT OR Apache-2.0 -tower-service 0.3.3 - Source: https://github.com/tower-rs/tower - License: MIT -tracing 0.1.44 - Source: https://github.com/tokio-rs/tracing - License: MIT -tracing-attributes 0.1.31 - Source: https://github.com/tokio-rs/tracing - License: MIT -tracing-core 0.1.36 - Source: https://github.com/tokio-rs/tracing - License: MIT -tracing-log 0.2.0 - Source: https://github.com/tokio-rs/tracing - License: MIT -tracing-subscriber 0.3.23 - Source: https://github.com/tokio-rs/tracing - License: MIT -tungstenite 0.30.0 - Source: https://github.com/snapview/tungstenite-rs - License: MIT OR Apache-2.0 -twox-hash 2.1.2 - Source: https://github.com/shepmaster/twox-hash - License: MIT -ty_static 0.0.7 - Source: https://github.com/astral-sh/ruff - License: MIT -typed-arena 2.0.2 - Source: https://github.com/SimonSapin/rust-typed-arena - License: MIT -typed-path 0.12.3 - Source: https://github.com/chipsenkbeil/typed-path - License: MIT OR Apache-2.0 -typeid 1.0.3 - Source: https://github.com/dtolnay/typeid - License: MIT OR Apache-2.0 -typenum 1.20.1 - Source: https://github.com/paholg/typenum - License: MIT OR Apache-2.0 unicode-ident 1.0.24 Source: https://github.com/dtolnay/unicode-ident License: (MIT OR Apache-2.0) AND Unicode-3.0 -unicode-normalization 0.1.25 - Source: https://github.com/unicode-rs/unicode-normalization - License: MIT OR Apache-2.0 -unicode-width 0.2.2 - Source: https://github.com/unicode-rs/unicode-width - License: MIT OR Apache-2.0 -unicode_names2 1.3.0 - Source: https://github.com/progval/unicode_names2 - License: (MIT OR Apache-2.0) AND Unicode-DFS-2016 -unit-prefix 0.5.2 - Source: https://codeberg.org/commons-rs/unit-prefix - License: MIT -untrusted 0.9.0 - Source: https://github.com/briansmith/untrusted - License: ISC -ureq 3.3.0 - Source: https://github.com/algesten/ureq - License: MIT OR Apache-2.0 -ureq-proto 0.6.0 - Source: https://github.com/algesten/ureq-proto - License: MIT OR Apache-2.0 -url 2.5.8 - Source: https://github.com/servo/rust-url - License: MIT OR Apache-2.0 -utf8-zero 0.8.1 - Source: https://github.com/algesten/utf8-zero - License: MIT OR Apache-2.0 -utf8_iter 1.0.4 - Source: https://github.com/hsivonen/utf8_iter - License: Apache-2.0 OR MIT -utf8parse 0.2.2 - Source: https://github.com/alacritty/vte - License: Apache-2.0 OR MIT -uuid 1.23.4 - Source: https://github.com/uuid-rs/uuid - License: Apache-2.0 OR MIT -walkdir 2.5.0 - Source: https://github.com/BurntSushi/walkdir - License: Unlicense OR MIT -webpki-roots 1.0.9 - Source: https://github.com/rustls/webpki-roots - License: CDLA-Permissive-2.0 -winapi 0.3.9 - Source: https://github.com/retep998/winapi-rs - License: MIT OR Apache-2.0 -winapi-util 0.1.11 - Source: https://github.com/BurntSushi/winapi-util - License: Unlicense OR MIT -windows 0.62.2 - Source: https://github.com/microsoft/windows-rs - License: MIT OR Apache-2.0 -windows-collections 0.3.2 - Source: https://github.com/microsoft/windows-rs - License: MIT OR Apache-2.0 -windows-core 0.62.2 - Source: https://github.com/microsoft/windows-rs - License: MIT OR Apache-2.0 -windows-future 0.3.2 - Source: https://github.com/microsoft/windows-rs - License: MIT OR Apache-2.0 -windows-implement 0.60.2 - Source: https://github.com/microsoft/windows-rs - License: MIT OR Apache-2.0 -windows-interface 0.59.3 - Source: https://github.com/microsoft/windows-rs - License: MIT OR Apache-2.0 -windows-link 0.2.1 - Source: https://github.com/microsoft/windows-rs - License: MIT OR Apache-2.0 -windows-numerics 0.3.1 - Source: https://github.com/microsoft/windows-rs - License: MIT OR Apache-2.0 -windows-result 0.4.1 - Source: https://github.com/microsoft/windows-rs - License: MIT OR Apache-2.0 -windows-strings 0.5.1 - Source: https://github.com/microsoft/windows-rs - License: MIT OR Apache-2.0 -windows-sys 0.52.0 - Source: https://github.com/microsoft/windows-rs - License: MIT OR Apache-2.0 -windows-sys 0.60.2 - Source: https://github.com/microsoft/windows-rs - License: MIT OR Apache-2.0 -windows-sys 0.61.2 - Source: https://github.com/microsoft/windows-rs - License: MIT OR Apache-2.0 -windows-targets 0.52.6 - Source: https://github.com/microsoft/windows-rs - License: MIT OR Apache-2.0 -windows-targets 0.53.5 - Source: https://github.com/microsoft/windows-rs - License: MIT OR Apache-2.0 -windows-threading 0.2.1 - Source: https://github.com/microsoft/windows-rs - License: MIT OR Apache-2.0 -windows_aarch64_msvc 0.52.6 - Source: https://github.com/microsoft/windows-rs - License: MIT OR Apache-2.0 -windows_aarch64_msvc 0.53.1 - Source: https://github.com/microsoft/windows-rs - License: MIT OR Apache-2.0 -windows_x86_64_gnu 0.52.6 - Source: https://github.com/microsoft/windows-rs - License: MIT OR Apache-2.0 -windows_x86_64_gnu 0.53.1 - Source: https://github.com/microsoft/windows-rs - License: MIT OR Apache-2.0 -windows_x86_64_msvc 0.52.6 - Source: https://github.com/microsoft/windows-rs - License: MIT OR Apache-2.0 -windows_x86_64_msvc 0.53.1 - Source: https://github.com/microsoft/windows-rs - License: MIT OR Apache-2.0 -winnow 1.0.3 - Source: https://github.com/winnow-rs/winnow - License: MIT -writeable 0.6.2 - Source: https://github.com/unicode-org/icu4x - License: Unicode-3.0 -yoke 0.8.1 - Source: https://github.com/unicode-org/icu4x - License: Unicode-3.0 -yoke-derive 0.8.1 - Source: https://github.com/unicode-org/icu4x - License: Unicode-3.0 -zerocopy 0.8.40 - Source: https://github.com/google/zerocopy - License: BSD-2-Clause OR Apache-2.0 OR MIT -zerofrom 0.1.6 - Source: https://github.com/unicode-org/icu4x - License: Unicode-3.0 -zerofrom-derive 0.1.6 - Source: https://github.com/unicode-org/icu4x - License: Unicode-3.0 -zeroize 1.9.0 - Source: https://github.com/RustCrypto/utils - License: Apache-2.0 OR MIT -zerotrie 0.2.3 - Source: https://github.com/unicode-org/icu4x - License: Unicode-3.0 -zerovec 0.11.5 - Source: https://github.com/unicode-org/icu4x - License: Unicode-3.0 -zerovec-derive 0.11.2 - Source: https://github.com/unicode-org/icu4x - License: Unicode-3.0 -zip 8.6.0 - Source: https://github.com/zip-rs/zip2 - License: MIT -zlib-rs 0.6.5 - Source: https://github.com/trifectatechfoundation/zlib-rs - License: Zlib zmij 1.0.21 Source: https://github.com/dtolnay/zmij License: MIT -zopfli 0.8.3 - Source: https://github.com/zopfli-rs/zopfli - License: Apache-2.0 License texts and notices ------------------------- =============================================================================== Apache License 2.0 =============================================================================== +Apache License +Version 2.0, January 2004 +http://www.apache.org/licenses/ - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION +1. Definitions. - 1. Definitions. +"License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. +"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. +"Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. +"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. +"Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. +"Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. +"Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). +"Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. +"Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." +"Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. +2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. +3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. +4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: + (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and + (b) You must cause any modified files to carry prominent notices stating that You changed the files; and - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and + (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and + (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. + You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. +5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. +6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. +7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. +8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. +9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. +END OF TERMS AND CONDITIONS - END OF TERMS AND CONDITIONS +APPENDIX: How to apply the Apache License to your work. - APPENDIX: How to apply the Apache License to your work. +To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. +Copyright [yyyy] [name of copyright owner] - Copyright [yyyy] [name of copyright owner] +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at +http://www.apache.org/licenses/LICENSE-2.0 - http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. =============================================================================== MIT License =============================================================================== -Copyright (c) 2014 Carl Lerche and other MIO contributors +MIT License + +Copyright (c) -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and +associated documentation files (the "Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the +following conditions: -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. +The above copyright notice and this permission notice shall be included in all copies or substantial +portions of the Software. -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT +LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO +EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +USE OR OTHER DEALINGS IN THE SOFTWARE. =============================================================================== Unicode License v3 @@ -1325,959 +200,3 @@ not be used in advertising or otherwise to promote the sale, use or other dealings in these Data Files or Software without prior written authorization of the copyright holder. -=============================================================================== -ISC License -=============================================================================== -// Copyright 2015-2016 Brian Smith. -// -// Permission to use, copy, modify, and/or distribute this software for any -// purpose with or without fee is hereby granted, provided that the above -// copyright notice and this permission notice appear in all copies. -// -// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHORS DISCLAIM ALL WARRANTIES -// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF -// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR -// ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES -// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN -// ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF -// OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. - -=============================================================================== -BSD 3-Clause "New" or "Revised" License -=============================================================================== -BSD 3-Clause License - -Copyright (c) 2013, Julien Schmidt -All rights reserved. - -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are met: - -1. Redistributions of source code must retain the above copyright notice, this - list of conditions and the following disclaimer. - -2. Redistributions in binary form must reproduce the above copyright notice, - this list of conditions and the following disclaimer in the documentation - and/or other materials provided with the distribution. - -3. Neither the name of the copyright holder nor the names of its - contributors may be used to endorse or promote products derived from - this software without specific prior written permission. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" -AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE -IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE -FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL -DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR -SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER -CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, -OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - -=============================================================================== -zlib License -=============================================================================== -(C) 2024 Trifecta Tech Foundation - -This software is provided 'as-is', without any express or implied -warranty. In no event will the authors be held liable for any damages -arising from the use of this software. - -Permission is granted to anyone to use this software for any purpose, -including commercial applications, and to alter it and redistribute it -freely, subject to the following restrictions: - -1. The origin of this software must not be misrepresented; you must not - claim that you wrote the original software. If you use this software - in a product, an acknowledgment in the product documentation would be - appreciated but is not required. - -2. Altered source versions must be plainly marked as such, and must not be - misrepresented as being the original software. - -3. This notice may not be removed or altered from any source distribution. - -=============================================================================== -BSD 2-Clause "Simplified" License -=============================================================================== -Copyright (c) 2015, Nick Fitzgerald -All rights reserved. - -Redistribution and use in source and binary forms, with or without modification, -are permitted provided that the following conditions are met: - -1. Redistributions of source code must retain the above copyright notice, this - list of conditions and the following disclaimer. - -2. Redistributions in binary form must reproduce the above copyright notice, - this list of conditions and the following disclaimer in the documentation - and/or other materials provided with the distribution. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND -ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED -WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR -ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES -(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; -LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON -ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS -SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - -=============================================================================== -Common Development and Distribution License 1.0 -=============================================================================== -Unless otherwise noted, all files in this distribution are released -under the Common Development and Distribution License (CDDL). -Exceptions are noted within the associated source files. - --------------------------------------------------------------------- - - -COMMON DEVELOPMENT AND DISTRIBUTION LICENSE Version 1.0 - -1. Definitions. - - 1.1. "Contributor" means each individual or entity that creates - or contributes to the creation of Modifications. - - 1.2. "Contributor Version" means the combination of the Original - Software, prior Modifications used by a Contributor (if any), - and the Modifications made by that particular Contributor. - - 1.3. "Covered Software" means (a) the Original Software, or (b) - Modifications, or (c) the combination of files containing - Original Software with files containing Modifications, in - each case including portions thereof. - - 1.4. "Executable" means the Covered Software in any form other - than Source Code. - - 1.5. "Initial Developer" means the individual or entity that first - makes Original Software available under this License. - - 1.6. "Larger Work" means a work which combines Covered Software or - portions thereof with code not governed by the terms of this - License. - - 1.7. "License" means this document. - - 1.8. "Licensable" means having the right to grant, to the maximum - extent possible, whether at the time of the initial grant or - subsequently acquired, any and all of the rights conveyed - herein. - - 1.9. "Modifications" means the Source Code and Executable form of - any of the following: - - A. Any file that results from an addition to, deletion from or - modification of the contents of a file containing Original - Software or previous Modifications; - - B. Any new file that contains any part of the Original - Software or previous Modifications; or - - C. Any new file that is contributed or otherwise made - available under the terms of this License. - - 1.10. "Original Software" means the Source Code and Executable - form of computer software code that is originally released - under this License. - - 1.11. "Patent Claims" means any patent claim(s), now owned or - hereafter acquired, including without limitation, method, - process, and apparatus claims, in any patent Licensable by - grantor. - - 1.12. "Source Code" means (a) the common form of computer software - code in which modifications are made and (b) associated - documentation included in or with such code. - - 1.13. "You" (or "Your") means an individual or a legal entity - exercising rights under, and complying with all of the terms - of, this License. For legal entities, "You" includes any - entity which controls, is controlled by, or is under common - control with You. For purposes of this definition, - "control" means (a) the power, direct or indirect, to cause - the direction or management of such entity, whether by - contract or otherwise, or (b) ownership of more than fifty - percent (50%) of the outstanding shares or beneficial - ownership of such entity. - -2. License Grants. - - 2.1. The Initial Developer Grant. - - Conditioned upon Your compliance with Section 3.1 below and - subject to third party intellectual property claims, the Initial - Developer hereby grants You a world-wide, royalty-free, - non-exclusive license: - - (a) under intellectual property rights (other than patent or - trademark) Licensable by Initial Developer, to use, - reproduce, modify, display, perform, sublicense and - distribute the Original Software (or portions thereof), - with or without Modifications, and/or as part of a Larger - Work; and - - (b) under Patent Claims infringed by the making, using or - selling of Original Software, to make, have made, use, - practice, sell, and offer for sale, and/or otherwise - dispose of the Original Software (or portions thereof). - - (c) The licenses granted in Sections 2.1(a) and (b) are - effective on the date Initial Developer first distributes - or otherwise makes the Original Software available to a - third party under the terms of this License. - - (d) Notwithstanding Section 2.1(b) above, no patent license is - granted: (1) for code that You delete from the Original - Software, or (2) for infringements caused by: (i) the - modification of the Original Software, or (ii) the - combination of the Original Software with other software - or devices. - - 2.2. Contributor Grant. - - Conditioned upon Your compliance with Section 3.1 below and - subject to third party intellectual property claims, each - Contributor hereby grants You a world-wide, royalty-free, - non-exclusive license: - - (a) under intellectual property rights (other than patent or - trademark) Licensable by Contributor to use, reproduce, - modify, display, perform, sublicense and distribute the - Modifications created by such Contributor (or portions - thereof), either on an unmodified basis, with other - Modifications, as Covered Software and/or as part of a - Larger Work; and - - (b) under Patent Claims infringed by the making, using, or - selling of Modifications made by that Contributor either - alone and/or in combination with its Contributor Version - (or portions of such combination), to make, use, sell, - offer for sale, have made, and/or otherwise dispose of: - (1) Modifications made by that Contributor (or portions - thereof); and (2) the combination of Modifications made by - that Contributor with its Contributor Version (or portions - of such combination). - - (c) The licenses granted in Sections 2.2(a) and 2.2(b) are - effective on the date Contributor first distributes or - otherwise makes the Modifications available to a third - party. - - (d) Notwithstanding Section 2.2(b) above, no patent license is - granted: (1) for any code that Contributor has deleted - from the Contributor Version; (2) for infringements caused - by: (i) third party modifications of Contributor Version, - or (ii) the combination of Modifications made by that - Contributor with other software (except as part of the - Contributor Version) or other devices; or (3) under Patent - Claims infringed by Covered Software in the absence of - Modifications made by that Contributor. - -3. Distribution Obligations. - - 3.1. Availability of Source Code. - - Any Covered Software that You distribute or otherwise make - available in Executable form must also be made available in Source - Code form and that Source Code form must be distributed only under - the terms of this License. You must include a copy of this - License with every copy of the Source Code form of the Covered - Software You distribute or otherwise make available. You must - inform recipients of any such Covered Software in Executable form - as to how they can obtain such Covered Software in Source Code - form in a reasonable manner on or through a medium customarily - used for software exchange. - - 3.2. Modifications. - - The Modifications that You create or to which You contribute are - governed by the terms of this License. You represent that You - believe Your Modifications are Your original creation(s) and/or - You have sufficient rights to grant the rights conveyed by this - License. - - 3.3. Required Notices. - - You must include a notice in each of Your Modifications that - identifies You as the Contributor of the Modification. You may - not remove or alter any copyright, patent or trademark notices - contained within the Covered Software, or any notices of licensing - or any descriptive text giving attribution to any Contributor or - the Initial Developer. - - 3.4. Application of Additional Terms. - - You may not offer or impose any terms on any Covered Software in - Source Code form that alters or restricts the applicable version - of this License or the recipients' rights hereunder. You may - choose to offer, and to charge a fee for, warranty, support, - indemnity or liability obligations to one or more recipients of - Covered Software. However, you may do so only on Your own behalf, - and not on behalf of the Initial Developer or any Contributor. - You must make it absolutely clear that any such warranty, support, - indemnity or liability obligation is offered by You alone, and You - hereby agree to indemnify the Initial Developer and every - Contributor for any liability incurred by the Initial Developer or - such Contributor as a result of warranty, support, indemnity or - liability terms You offer. - - 3.5. Distribution of Executable Versions. - - You may distribute the Executable form of the Covered Software - under the terms of this License or under the terms of a license of - Your choice, which may contain terms different from this License, - provided that You are in compliance with the terms of this License - and that the license for the Executable form does not attempt to - limit or alter the recipient's rights in the Source Code form from - the rights set forth in this License. If You distribute the - Covered Software in Executable form under a different license, You - must make it absolutely clear that any terms which differ from - this License are offered by You alone, not by the Initial - Developer or Contributor. You hereby agree to indemnify the - Initial Developer and every Contributor for any liability incurred - by the Initial Developer or such Contributor as a result of any - such terms You offer. - - 3.6. Larger Works. - - You may create a Larger Work by combining Covered Software with - other code not governed by the terms of this License and - distribute the Larger Work as a single product. In such a case, - You must make sure the requirements of this License are fulfilled - for the Covered Software. - -4. Versions of the License. - - 4.1. New Versions. - - Sun Microsystems, Inc. is the initial license steward and may - publish revised and/or new versions of this License from time to - time. Each version will be given a distinguishing version number. - Except as provided in Section 4.3, no one other than the license - steward has the right to modify this License. - - 4.2. Effect of New Versions. - - You may always continue to use, distribute or otherwise make the - Covered Software available under the terms of the version of the - License under which You originally received the Covered Software. - If the Initial Developer includes a notice in the Original - Software prohibiting it from being distributed or otherwise made - available under any subsequent version of the License, You must - distribute and make the Covered Software available under the terms - of the version of the License under which You originally received - the Covered Software. Otherwise, You may also choose to use, - distribute or otherwise make the Covered Software available under - the terms of any subsequent version of the License published by - the license steward. - - 4.3. Modified Versions. - - When You are an Initial Developer and You want to create a new - license for Your Original Software, You may create and use a - modified version of this License if You: (a) rename the license - and remove any references to the name of the license steward - (except to note that the license differs from this License); and - (b) otherwise make it clear that the license contains terms which - differ from this License. - -5. DISCLAIMER OF WARRANTY. - - COVERED SOFTWARE IS PROVIDED UNDER THIS LICENSE ON AN "AS IS" - BASIS, WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, - INCLUDING, WITHOUT LIMITATION, WARRANTIES THAT THE COVERED - SOFTWARE IS FREE OF DEFECTS, MERCHANTABLE, FIT FOR A PARTICULAR - PURPOSE OR NON-INFRINGING. THE ENTIRE RISK AS TO THE QUALITY AND - PERFORMANCE OF THE COVERED SOFTWARE IS WITH YOU. SHOULD ANY - COVERED SOFTWARE PROVE DEFECTIVE IN ANY RESPECT, YOU (NOT THE - INITIAL DEVELOPER OR ANY OTHER CONTRIBUTOR) ASSUME THE COST OF ANY - NECESSARY SERVICING, REPAIR OR CORRECTION. THIS DISCLAIMER OF - WARRANTY CONSTITUTES AN ESSENTIAL PART OF THIS LICENSE. NO USE OF - ANY COVERED SOFTWARE IS AUTHORIZED HEREUNDER EXCEPT UNDER THIS - DISCLAIMER. - -6. TERMINATION. - - 6.1. This License and the rights granted hereunder will terminate - automatically if You fail to comply with terms herein and fail to - cure such breach within 30 days of becoming aware of the breach. - Provisions which, by their nature, must remain in effect beyond - the termination of this License shall survive. - - 6.2. If You assert a patent infringement claim (excluding - declaratory judgment actions) against Initial Developer or a - Contributor (the Initial Developer or Contributor against whom You - assert such claim is referred to as "Participant") alleging that - the Participant Software (meaning the Contributor Version where - the Participant is a Contributor or the Original Software where - the Participant is the Initial Developer) directly or indirectly - infringes any patent, then any and all rights granted directly or - indirectly to You by such Participant, the Initial Developer (if - the Initial Developer is not the Participant) and all Contributors - under Sections 2.1 and/or 2.2 of this License shall, upon 60 days - notice from Participant terminate prospectively and automatically - at the expiration of such 60 day notice period, unless if within - such 60 day period You withdraw Your claim with respect to the - Participant Software against such Participant either unilaterally - or pursuant to a written agreement with Participant. - - 6.3. In the event of termination under Sections 6.1 or 6.2 above, - all end user licenses that have been validly granted by You or any - distributor hereunder prior to termination (excluding licenses - granted to You by any distributor) shall survive termination. - -7. LIMITATION OF LIABILITY. - - UNDER NO CIRCUMSTANCES AND UNDER NO LEGAL THEORY, WHETHER TORT - (INCLUDING NEGLIGENCE), CONTRACT, OR OTHERWISE, SHALL YOU, THE - INITIAL DEVELOPER, ANY OTHER CONTRIBUTOR, OR ANY DISTRIBUTOR OF - COVERED SOFTWARE, OR ANY SUPPLIER OF ANY OF SUCH PARTIES, BE - LIABLE TO ANY PERSON FOR ANY INDIRECT, SPECIAL, INCIDENTAL, OR - CONSEQUENTIAL DAMAGES OF ANY CHARACTER INCLUDING, WITHOUT - LIMITATION, DAMAGES FOR LOST PROFITS, LOSS OF GOODWILL, WORK - STOPPAGE, COMPUTER FAILURE OR MALFUNCTION, OR ANY AND ALL OTHER - COMMERCIAL DAMAGES OR LOSSES, EVEN IF SUCH PARTY SHALL HAVE BEEN - INFORMED OF THE POSSIBILITY OF SUCH DAMAGES. THIS LIMITATION OF - LIABILITY SHALL NOT APPLY TO LIABILITY FOR DEATH OR PERSONAL - INJURY RESULTING FROM SUCH PARTY'S NEGLIGENCE TO THE EXTENT - APPLICABLE LAW PROHIBITS SUCH LIMITATION. SOME JURISDICTIONS DO - NOT ALLOW THE EXCLUSION OR LIMITATION OF INCIDENTAL OR - CONSEQUENTIAL DAMAGES, SO THIS EXCLUSION AND LIMITATION MAY NOT - APPLY TO YOU. - -8. U.S. GOVERNMENT END USERS. - - The Covered Software is a "commercial item," as that term is - defined in 48 C.F.R. 2.101 (Oct. 1995), consisting of "commercial - computer software" (as that term is defined at 48 - C.F.R. 252.227-7014(a)(1)) and "commercial computer software - documentation" as such terms are used in 48 C.F.R. 12.212 - (Sept. 1995). Consistent with 48 C.F.R. 12.212 and 48 - C.F.R. 227.7202-1 through 227.7202-4 (June 1995), all - U.S. Government End Users acquire Covered Software with only those - rights set forth herein. This U.S. Government Rights clause is in - lieu of, and supersedes, any other FAR, DFAR, or other clause or - provision that addresses Government rights in computer software - under this License. - -9. MISCELLANEOUS. - - This License represents the complete agreement concerning subject - matter hereof. If any provision of this License is held to be - unenforceable, such provision shall be reformed only to the extent - necessary to make it enforceable. This License shall be governed - by the law of the jurisdiction specified in a notice contained - within the Original Software (except to the extent applicable law, - if any, provides otherwise), excluding such jurisdiction's - conflict-of-law provisions. Any litigation relating to this - License shall be subject to the jurisdiction of the courts located - in the jurisdiction and venue specified in a notice contained - within the Original Software, with the losing party responsible - for costs, including, without limitation, court costs and - reasonable attorneys' fees and expenses. The application of the - United Nations Convention on Contracts for the International Sale - of Goods is expressly excluded. Any law or regulation which - provides that the language of a contract shall be construed - against the drafter shall not apply to this License. You agree - that You alone are responsible for compliance with the United - States export administration regulations (and the export control - laws and regulation of any other countries) when You use, - distribute or otherwise make available any Covered Software. - -10. RESPONSIBILITY FOR CLAIMS. - - As between Initial Developer and the Contributors, each party is - responsible for claims and damages arising, directly or - indirectly, out of its utilization of rights under this License - and You agree to work with Initial Developer and Contributors to - distribute such responsibility on an equitable basis. Nothing - herein is intended or shall be deemed to constitute any admission - of liability. - --------------------------------------------------------------------- - -NOTICE PURSUANT TO SECTION 9 OF THE COMMON DEVELOPMENT AND -DISTRIBUTION LICENSE (CDDL) - -For Covered Software in this distribution, this License shall -be governed by the laws of the State of California (excluding -conflict-of-law provisions). - -Any litigation relating to this License shall be subject to the -jurisdiction of the Federal Courts of the Northern District of -California and the state courts of the State of California, with -venue lying in Santa Clara County, California. - -=============================================================================== -Community Data License Agreement Permissive 2.0 -=============================================================================== -# Community Data License Agreement - Permissive - Version 2.0 - -This is the Community Data License Agreement - Permissive, Version -2.0 (the "agreement"). Data Provider(s) and Data Recipient(s) agree -as follows: - -## 1. Provision of the Data - -1.1. A Data Recipient may use, modify, and share the Data made -available by Data Provider(s) under this agreement if that Data -Recipient follows the terms of this agreement. - -1.2. This agreement does not impose any restriction on a Data -Recipient's use, modification, or sharing of any portions of the -Data that are in the public domain or that may be used, modified, -or shared under any other legal exception or limitation. - -## 2. Conditions for Sharing Data - -2.1. A Data Recipient may share Data, with or without modifications, so -long as the Data Recipient makes available the text of this agreement -with the shared Data. - -## 3. No Restrictions on Results - -3.1. This agreement does not impose any restriction or obligations -with respect to the use, modification, or sharing of Results. - -## 4. No Warranty; Limitation of Liability - -4.1. All Data Recipients receive the Data subject to the following -terms: - -THE DATA IS PROVIDED ON AN "AS IS" BASIS, WITHOUT REPRESENTATIONS, -WARRANTIES OR CONDITIONS OF ANY KIND, EITHER EXPRESS OR IMPLIED -INCLUDING, WITHOUT LIMITATION, ANY WARRANTIES OR CONDITIONS OF TITLE, -NON-INFRINGEMENT, MERCHANTABILITY OR FITNESS FOR A PARTICULAR PURPOSE. - -NO DATA PROVIDER SHALL HAVE ANY LIABILITY FOR ANY DIRECT, INDIRECT, -INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING -WITHOUT LIMITATION LOST PROFITS), HOWEVER CAUSED AND ON ANY THEORY OF -LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING -NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE DATA OR RESULTS, -EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. - -## 5. Definitions - -5.1. "Data" means the material received by a Data Recipient under -this agreement. - -5.2. "Data Provider" means any person who is the source of Data -provided under this agreement and in reliance on a Data Recipient's -agreement to its terms. - -5.3. "Data Recipient" means any person who receives Data directly -or indirectly from a Data Provider and agrees to the terms of this -agreement. - -5.4. "Results" means any outcome obtained by computational analysis -of Data, including for example machine learning models and models' -insights. - -=============================================================================== -Mozilla Public License 2.0 -=============================================================================== -Mozilla Public License Version 2.0 -================================== - -1. Definitions --------------- - -1.1. "Contributor" - means each individual or legal entity that creates, contributes to - the creation of, or owns Covered Software. - -1.2. "Contributor Version" - means the combination of the Contributions of others (if any) used - by a Contributor and that particular Contributor's Contribution. - -1.3. "Contribution" - means Covered Software of a particular Contributor. - -1.4. "Covered Software" - means Source Code Form to which the initial Contributor has attached - the notice in Exhibit A, the Executable Form of such Source Code - Form, and Modifications of such Source Code Form, in each case - including portions thereof. - -1.5. "Incompatible With Secondary Licenses" - means - - (a) that the initial Contributor has attached the notice described - in Exhibit B to the Covered Software; or - - (b) that the Covered Software was made available under the terms of - version 1.1 or earlier of the License, but not also under the - terms of a Secondary License. - -1.6. "Executable Form" - means any form of the work other than Source Code Form. - -1.7. "Larger Work" - means a work that combines Covered Software with other material, in - a separate file or files, that is not Covered Software. - -1.8. "License" - means this document. - -1.9. "Licensable" - means having the right to grant, to the maximum extent possible, - whether at the time of the initial grant or subsequently, any and - all of the rights conveyed by this License. - -1.10. "Modifications" - means any of the following: - - (a) any file in Source Code Form that results from an addition to, - deletion from, or modification of the contents of Covered - Software; or - - (b) any new file in Source Code Form that contains any Covered - Software. - -1.11. "Patent Claims" of a Contributor - means any patent claim(s), including without limitation, method, - process, and apparatus claims, in any patent Licensable by such - Contributor that would be infringed, but for the grant of the - License, by the making, using, selling, offering for sale, having - made, import, or transfer of either its Contributions or its - Contributor Version. - -1.12. "Secondary License" - means either the GNU General Public License, Version 2.0, the GNU - Lesser General Public License, Version 2.1, the GNU Affero General - Public License, Version 3.0, or any later versions of those - licenses. - -1.13. "Source Code Form" - means the form of the work preferred for making modifications. - -1.14. "You" (or "Your") - means an individual or a legal entity exercising rights under this - License. For legal entities, "You" includes any entity that - controls, is controlled by, or is under common control with You. For - purposes of this definition, "control" means (a) the power, direct - or indirect, to cause the direction or management of such entity, - whether by contract or otherwise, or (b) ownership of more than - fifty percent (50%) of the outstanding shares or beneficial - ownership of such entity. - -2. License Grants and Conditions --------------------------------- - -2.1. Grants - -Each Contributor hereby grants You a world-wide, royalty-free, -non-exclusive license: - -(a) under intellectual property rights (other than patent or trademark) - Licensable by such Contributor to use, reproduce, make available, - modify, display, perform, distribute, and otherwise exploit its - Contributions, either on an unmodified basis, with Modifications, or - as part of a Larger Work; and - -(b) under Patent Claims of such Contributor to make, use, sell, offer - for sale, have made, import, and otherwise transfer either its - Contributions or its Contributor Version. - -2.2. Effective Date - -The licenses granted in Section 2.1 with respect to any Contribution -become effective for each Contribution on the date the Contributor first -distributes such Contribution. - -2.3. Limitations on Grant Scope - -The licenses granted in this Section 2 are the only rights granted under -this License. No additional rights or licenses will be implied from the -distribution or licensing of Covered Software under this License. -Notwithstanding Section 2.1(b) above, no patent license is granted by a -Contributor: - -(a) for any code that a Contributor has removed from Covered Software; - or - -(b) for infringements caused by: (i) Your and any other third party's - modifications of Covered Software, or (ii) the combination of its - Contributions with other software (except as part of its Contributor - Version); or - -(c) under Patent Claims infringed by Covered Software in the absence of - its Contributions. - -This License does not grant any rights in the trademarks, service marks, -or logos of any Contributor (except as may be necessary to comply with -the notice requirements in Section 3.4). - -2.4. Subsequent Licenses - -No Contributor makes additional grants as a result of Your choice to -distribute the Covered Software under a subsequent version of this -License (see Section 10.2) or under the terms of a Secondary License (if -permitted under the terms of Section 3.3). - -2.5. Representation - -Each Contributor represents that the Contributor believes its -Contributions are its original creation(s) or it has sufficient rights -to grant the rights to its Contributions conveyed by this License. - -2.6. Fair Use - -This License is not intended to limit any rights You have under -applicable copyright doctrines of fair use, fair dealing, or other -equivalents. - -2.7. Conditions - -Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted -in Section 2.1. - -3. Responsibilities -------------------- - -3.1. Distribution of Source Form - -All distribution of Covered Software in Source Code Form, including any -Modifications that You create or to which You contribute, must be under -the terms of this License. You must inform recipients that the Source -Code Form of the Covered Software is governed by the terms of this -License, and how they can obtain a copy of this License. You may not -attempt to alter or restrict the recipients' rights in the Source Code -Form. - -3.2. Distribution of Executable Form - -If You distribute Covered Software in Executable Form then: - -(a) such Covered Software must also be made available in Source Code - Form, as described in Section 3.1, and You must inform recipients of - the Executable Form how they can obtain a copy of such Source Code - Form by reasonable means in a timely manner, at a charge no more - than the cost of distribution to the recipient; and - -(b) You may distribute such Executable Form under the terms of this - License, or sublicense it under different terms, provided that the - license for the Executable Form does not attempt to limit or alter - the recipients' rights in the Source Code Form under this License. - -3.3. Distribution of a Larger Work - -You may create and distribute a Larger Work under terms of Your choice, -provided that You also comply with the requirements of this License for -the Covered Software. If the Larger Work is a combination of Covered -Software with a work governed by one or more Secondary Licenses, and the -Covered Software is not Incompatible With Secondary Licenses, this -License permits You to additionally distribute such Covered Software -under the terms of such Secondary License(s), so that the recipient of -the Larger Work may, at their option, further distribute the Covered -Software under the terms of either this License or such Secondary -License(s). - -3.4. Notices - -You may not remove or alter the substance of any license notices -(including copyright notices, patent notices, disclaimers of warranty, -or limitations of liability) contained within the Source Code Form of -the Covered Software, except that You may alter any license notices to -the extent required to remedy known factual inaccuracies. - -3.5. Application of Additional Terms - -You may choose to offer, and to charge a fee for, warranty, support, -indemnity or liability obligations to one or more recipients of Covered -Software. However, You may do so only on Your own behalf, and not on -behalf of any Contributor. You must make it absolutely clear that any -such warranty, support, indemnity, or liability obligation is offered by -You alone, and You hereby agree to indemnify every Contributor for any -liability incurred by such Contributor as a result of warranty, support, -indemnity or liability terms You offer. You may include additional -disclaimers of warranty and limitations of liability specific to any -jurisdiction. - -4. Inability to Comply Due to Statute or Regulation ---------------------------------------------------- - -If it is impossible for You to comply with any of the terms of this -License with respect to some or all of the Covered Software due to -statute, judicial order, or regulation then You must: (a) comply with -the terms of this License to the maximum extent possible; and (b) -describe the limitations and the code they affect. Such description must -be placed in a text file included with all distributions of the Covered -Software under this License. Except to the extent prohibited by statute -or regulation, such description must be sufficiently detailed for a -recipient of ordinary skill to be able to understand it. - -5. Termination --------------- - -5.1. The rights granted under this License will terminate automatically -if You fail to comply with any of its terms. However, if You become -compliant, then the rights granted under this License from a particular -Contributor are reinstated (a) provisionally, unless and until such -Contributor explicitly and finally terminates Your grants, and (b) on an -ongoing basis, if such Contributor fails to notify You of the -non-compliance by some reasonable means prior to 60 days after You have -come back into compliance. Moreover, Your grants from a particular -Contributor are reinstated on an ongoing basis if such Contributor -notifies You of the non-compliance by some reasonable means, this is the -first time You have received notice of non-compliance with this License -from such Contributor, and You become compliant prior to 30 days after -Your receipt of the notice. - -5.2. If You initiate litigation against any entity by asserting a patent -infringement claim (excluding declaratory judgment actions, -counter-claims, and cross-claims) alleging that a Contributor Version -directly or indirectly infringes any patent, then the rights granted to -You by any and all Contributors for the Covered Software under Section -2.1 of this License shall terminate. - -5.3. In the event of termination under Sections 5.1 or 5.2 above, all -end user license agreements (excluding distributors and resellers) which -have been validly granted by You or Your distributors under this License -prior to termination shall survive termination. - -************************************************************************ -* * -* 6. Disclaimer of Warranty * -* ------------------------- * -* * -* Covered Software is provided under this License on an "as is" * -* basis, without warranty of any kind, either expressed, implied, or * -* statutory, including, without limitation, warranties that the * -* Covered Software is free of defects, merchantable, fit for a * -* particular purpose or non-infringing. The entire risk as to the * -* quality and performance of the Covered Software is with You. * -* Should any Covered Software prove defective in any respect, You * -* (not any Contributor) assume the cost of any necessary servicing, * -* repair, or correction. This disclaimer of warranty constitutes an * -* essential part of this License. No use of any Covered Software is * -* authorized under this License except under this disclaimer. * -* * -************************************************************************ - -************************************************************************ -* * -* 7. Limitation of Liability * -* -------------------------- * -* * -* Under no circumstances and under no legal theory, whether tort * -* (including negligence), contract, or otherwise, shall any * -* Contributor, or anyone who distributes Covered Software as * -* permitted above, be liable to You for any direct, indirect, * -* special, incidental, or consequential damages of any character * -* including, without limitation, damages for lost profits, loss of * -* goodwill, work stoppage, computer failure or malfunction, or any * -* and all other commercial damages or losses, even if such party * -* shall have been informed of the possibility of such damages. This * -* limitation of liability shall not apply to liability for death or * -* personal injury resulting from such party's negligence to the * -* extent applicable law prohibits such limitation. Some * -* jurisdictions do not allow the exclusion or limitation of * -* incidental or consequential damages, so this exclusion and * -* limitation may not apply to You. * -* * -************************************************************************ - -8. Litigation -------------- - -Any litigation relating to this License may be brought only in the -courts of a jurisdiction where the defendant maintains its principal -place of business and such litigation shall be governed by laws of that -jurisdiction, without reference to its conflict-of-law provisions. -Nothing in this Section shall prevent a party's ability to bring -cross-claims or counter-claims. - -9. Miscellaneous ----------------- - -This License represents the complete agreement concerning the subject -matter hereof. If any provision of this License is held to be -unenforceable, such provision shall be reformed only to the extent -necessary to make it enforceable. Any law or regulation which provides -that the language of a contract shall be construed against the drafter -shall not be used to construe this License against a Contributor. - -10. Versions of the License ---------------------------- - -10.1. New Versions - -Mozilla Foundation is the license steward. Except as provided in Section -10.3, no one other than the license steward has the right to modify or -publish new versions of this License. Each version will be given a -distinguishing version number. - -10.2. Effect of New Versions - -You may distribute the Covered Software under the terms of the version -of the License under which You originally received the Covered Software, -or under the terms of any subsequent version published by the license -steward. - -10.3. Modified Versions - -If you create software not governed by this License, and you want to -create a new license for such software, you may create and use a -modified version of this License if you rename the license and remove -any references to the name of the license steward (except to note that -such modified license differs from this License). - -10.4. Distributing Source Code Form that is Incompatible With Secondary -Licenses - -If You choose to distribute Source Code Form that is Incompatible With -Secondary Licenses under the terms of this version of the License, the -notice described in Exhibit B of this License must be attached. - -Exhibit A - Source Code Form License Notice -------------------------------------------- - - This Source Code Form is subject to the terms of the Mozilla Public - License, v. 2.0. If a copy of the MPL was not distributed with this - file, You can obtain one at http://mozilla.org/MPL/2.0/. - -If it is not possible or desirable to put the notice in a particular -file, then You may include the notice in a location (such as a LICENSE -file in a relevant directory) where a recipient would be likely to look -for such a notice. - -You may add additional accurate notices of copyright ownership. - -Exhibit B - "Incompatible With Secondary Licenses" Notice ---------------------------------------------------------- - - This Source Code Form is "Incompatible With Secondary Licenses", as - defined by the Mozilla Public License, v. 2.0. - -=============================================================================== -Unicode License Agreement - Data Files and Software (2016) -=============================================================================== -UNICODE, INC. LICENSE AGREEMENT - DATA FILES AND SOFTWARE - -Unicode Data Files include all data files under the directories http://www.unicode.org/Public/, http://www.unicode.org/reports/, http://www.unicode.org/cldr/data/, http://source.icu-project.org/repos/icu/, and http://www.unicode.org/utility/trac/browser/. - -Unicode Data Files do not include PDF online code charts under the directory http://www.unicode.org/Public/. - -Software includes any source code published in the Unicode Standard or under the directories http://www.unicode.org/Public/, http://www.unicode.org/reports/, http://www.unicode.org/cldr/data/, http://source.icu-project.org/repos/icu/, and http://www.unicode.org/utility/trac/browser/. - -NOTICE TO USER: Carefully read the following legal agreement. BY DOWNLOADING, INSTALLING, COPYING OR OTHERWISE USING UNICODE INC.'S DATA FILES ("DATA FILES"), AND/OR SOFTWARE ("SOFTWARE"), YOU UNEQUIVOCALLY ACCEPT, AND AGREE TO BE BOUND BY, ALL OF THE TERMS AND CONDITIONS OF THIS AGREEMENT. IF YOU DO NOT AGREE, DO NOT DOWNLOAD, INSTALL, COPY, DISTRIBUTE OR USE THE DATA FILES OR SOFTWARE. - -COPYRIGHT AND PERMISSION NOTICE - -Copyright © 1991-2016 Unicode, Inc. All rights reserved. Distributed under the Terms of Use in http://www.unicode.org/copyright.html. - -Permission is hereby granted, free of charge, to any person obtaining a copy of the Unicode data files and any associated documentation (the "Data Files") or Unicode software and any associated documentation (the "Software") to deal in the Data Files or Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, and/or sell copies of the Data Files or Software, and to permit persons to whom the Data Files or Software are furnished to do so, provided that either - - (a) this copyright and permission notice appear with all copies of the Data Files or Software, or - (b) this copyright and permission notice appear in associated Documentation. - -THE DATA FILES AND SOFTWARE ARE PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF THIRD PARTY RIGHTS. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR HOLDERS INCLUDED IN THIS NOTICE BE LIABLE FOR ANY CLAIM, OR ANY SPECIAL INDIRECT OR CONSEQUENTIAL DAMAGES, OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THE DATA FILES OR SOFTWARE. - -Except as contained in this notice, the name of a copyright holder shall not be used in advertising or otherwise to promote the sale, use or other dealings in these Data Files or Software without prior written authorization of the copyright holder. - diff --git a/SECURITY.md b/SECURITY.md index bb040f113..a949eb992 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,8 +2,21 @@ +## Supported Versions + +**None.** Basilisk is unlisted and its type checker is inert +([the statement](https://www.basilisk-python.dev/)). No version receives +security fixes, and no version will. If Basilisk is still installed anywhere, +remove it — that is the only remediation this project can offer. + +| Version | Supported | +| ------- | --------- | +| all | ❌ | + ## Reporting a Vulnerability +You can still reach us, and we would rather hear about a problem than not. + **Please do not report security vulnerabilities through public GitHub issues, discussions, or pull requests.** @@ -23,20 +36,9 @@ When reporting, please include: ## What to Expect -- **Acknowledgement** within **3 business days**. -- An assessment and a remediation plan (or a reasoned decline) within **10 business days**. -- Coordinated disclosure: we will agree a disclosure timeline with you and credit - you in the advisory unless you prefer to remain anonymous. - -## Supported Versions - -Security fixes land on the latest released minor version. Older lines are -supported only as noted below. - -| Version | Supported | -| ------- | --------- | -| 0.13.x | ✅ | -| < 0.13 | ❌ | +We are not promising a response window on an unlisted project, and we will not +be shipping a patched version. What a report can still achieve: a published +advisory, so anyone who has not yet removed Basilisk knows why they should. ## References diff --git a/VSCODE-DEPENDENCY-LICENSES b/VSCODE-DEPENDENCY-LICENSES index 51494df8b..e50fd5b6a 100644 --- a/VSCODE-DEPENDENCY-LICENSES +++ b/VSCODE-DEPENDENCY-LICENSES @@ -2,431 +2,6 @@ Basilisk VS Code Production Dependency Licenses ================================================= Generated from the exact npm production graph selected by package-lock.json. -Production graph SHA-256: 393945c298e4e686471c62592a55a0ecedde9a2fe5782e696613f4f79d5f2bba +Production graph SHA-256: 37517e5f3dc66819f61f5a7bb8ace1921282415f10551d2defa5c3eb0985b570 Regenerate with: npm run licenses:update -=============================================================================== -@nimblesite/shipwright-core 0.10.0 -License: MIT -Repository: https://github.com/Nimblesite/Shipwright.git -Source: shared Shipwright repository LICENSE -SHA-256: 032c14bd0ff61c4ea546e23e8849b74a68770e9b91375d25a772b20587dc3159 - -MIT License - -Copyright (c) 2026 NIMBLESITE PTY LTD - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - -=============================================================================== -@nimblesite/shipwright-vscode 0.10.0 -License: MIT -Repository: https://github.com/Nimblesite/Shipwright.git -Source: LICENSE -SHA-256: 032c14bd0ff61c4ea546e23e8849b74a68770e9b91375d25a772b20587dc3159 - -MIT License - -Copyright (c) 2026 NIMBLESITE PTY LTD - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - -=============================================================================== -@preact/signals-core 1.14.4 -License: MIT -Repository: https://github.com/preactjs/signals -Source: LICENSE -SHA-256: a11fc89e4c6b118854c7a667734a0b2e6bf2af5e45c6686de31adbccc8f3ae8d - -The MIT License (MIT) - -Copyright (c) 2022-present Preact Team - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - -=============================================================================== -balanced-match 4.0.4 -License: MIT -Repository: git://github.com/juliangruber/balanced-match.git -Source: LICENSE.md -SHA-256: d408f38ffa3355c5faec517153295338892eb0f1ea43f57874bb23c6075979b5 - -(MIT) - -Original code Copyright Julian Gruber - -Port to TypeScript Copyright Isaac Z. Schlueter - -Permission is hereby granted, free of charge, to any person obtaining a copy of -this software and associated documentation files (the "Software"), to deal in -the Software without restriction, including without limitation the rights to -use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies -of the Software, and to permit persons to whom the Software is furnished to do -so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - -=============================================================================== -brace-expansion 5.0.9 -License: MIT -Repository: git+https://github.com/juliangruber/brace-expansion.git -Source: LICENSE -SHA-256: 9c63a23124d68cd30cd316a94a1a0bca34f032786df6df69fc4b5f136bac8d2e - -MIT License - -Copyright Julian Gruber - -TypeScript port Copyright Isaac Z. Schlueter - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - -=============================================================================== -minimatch 10.2.5 -License: BlueOak-1.0.0 -Repository: git@github.com:isaacs/minimatch -Source: LICENSE.md -SHA-256: 2c7c5d22ed5a8ee968c64757710979afcd77438c48b4a265b94e615babd8a901 - -# Blue Oak Model License - -Version 1.0.0 - -## Purpose - -This license gives everyone as much permission to work with -this software as possible, while protecting contributors -from liability. - -## Acceptance - -In order to receive this license, you must agree to its -rules. The rules of this license are both obligations -under that agreement and conditions to your license. -You must not do anything with this software that triggers -a rule that you cannot or will not follow. - -## Copyright - -Each contributor licenses you to do everything with this -software that would otherwise infringe that contributor's -copyright in it. - -## Notices - -You must ensure that everyone who gets a copy of -any part of this software from you, with or without -changes, also gets the text of this license or a link to -. - -## Excuse - -If anyone notifies you in writing that you have not -complied with [Notices](#notices), you can keep your -license by taking all practical steps to comply within 30 -days after the notice. If you do not do so, your license -ends immediately. - -## Patent - -Each contributor licenses you to do everything with this -software that would otherwise infringe any patent claims -they can license or become able to license. - -## Reliability - -No contributor can revoke this license. - -## No Liability - -**_As far as the law allows, this software comes as is, -without any warranty or condition, and no contributor -will be liable to anyone for any damages related to this -software or this license, under any kind of legal claim._** - -=============================================================================== -semver 7.8.2 -License: ISC -Repository: git+https://github.com/npm/node-semver.git -Source: LICENSE -SHA-256: 4ec3d4c66cd87f5c8d8ad911b10f99bf27cb00cdfcff82621956e379186b016b - -The ISC License - -Copyright (c) Isaac Z. Schlueter and Contributors - -Permission to use, copy, modify, and/or distribute this software for any -purpose with or without fee is hereby granted, provided that the above -copyright notice and this permission notice appear in all copies. - -THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES -WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF -MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR -ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES -WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN -ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR -IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. - -=============================================================================== -vscode-jsonrpc 9.0.1 -License: MIT -Repository: https://github.com/Microsoft/vscode-languageserver-node.git -Source: License.txt -SHA-256: ec9ee83580841e8eb687aca9867f221503809ba6426c7f876ede17d91b9fcfd0 - -Copyright (c) Microsoft Corporation - -All rights reserved. - -MIT License - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - -Source: thirdpartynotices.txt -SHA-256: a89123562fe364dc8e969e85614eb6c1f8452afe131d6e42bd1314bb2d092b04 - -NOTICES AND INFORMATION -Do Not Translate or Localize - -This software incorporates material from third parties. -Microsoft makes certain open source code available at https://3rdpartysource.microsoft.com, -or you may send a check or money order for US $5.00, including the product name, -the open source component name, platform, and version number, to: - -Source Code Compliance Team -Microsoft Corporation -One Microsoft Way -Redmond, WA 98052 -USA - -Notwithstanding any other terms, you may reverse engineer this software to the extent -required to debug changes to any libraries licensed under the GNU Lesser General Public License. - -=============================================================================== -vscode-languageclient 10.1.0 -License: MIT -Repository: https://github.com/Microsoft/vscode-languageserver-node.git -Source: License.txt -SHA-256: ec9ee83580841e8eb687aca9867f221503809ba6426c7f876ede17d91b9fcfd0 - -Copyright (c) Microsoft Corporation - -All rights reserved. - -MIT License - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - -=============================================================================== -vscode-languageserver-protocol 3.18.2 -License: MIT -Repository: https://github.com/Microsoft/vscode-languageserver-node.git -Source: License.txt -SHA-256: ec9ee83580841e8eb687aca9867f221503809ba6426c7f876ede17d91b9fcfd0 - -Copyright (c) Microsoft Corporation - -All rights reserved. - -MIT License - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - -Source: thirdpartynotices.txt -SHA-256: 9265d27cf75775aa5ae19c5ba01846fb70ecd5121f8c39c81bef7e03f007072c - -THIRD-PARTY SOFTWARE NOTICES AND INFORMATION -For Microsoft vscode-languageclient - -This project incorporates material from the project(s) listed below (collectively, “Third Party Code”). -Microsoft is not the original author of the Third Party Code. The original copyright notice and license -under which Microsoft received such Third Party Code are set out below. This Third Party Code is licensed -to you under their original license terms set forth below. Microsoft reserves all other rights not expressly -granted, whether by implication, estoppel or otherwise. - -1. DefinitelyTyped version 0.0.1 (https://github.com/borisyankov/DefinitelyTyped) - -This project is licensed under the MIT license. -Copyrights are respective of each contributor listed at the beginning of each definition file. - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. - -=============================================================================== -vscode-languageserver-textdocument 1.0.13 -License: MIT -Repository: https://github.com/Microsoft/vscode-languageserver-node.git -Source: License.txt -SHA-256: ec9ee83580841e8eb687aca9867f221503809ba6426c7f876ede17d91b9fcfd0 - -Copyright (c) Microsoft Corporation - -All rights reserved. - -MIT License - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - -Source: thirdpartynotices.txt -SHA-256: a89123562fe364dc8e969e85614eb6c1f8452afe131d6e42bd1314bb2d092b04 - -NOTICES AND INFORMATION -Do Not Translate or Localize - -This software incorporates material from third parties. -Microsoft makes certain open source code available at https://3rdpartysource.microsoft.com, -or you may send a check or money order for US $5.00, including the product name, -the open source component name, platform, and version number, to: - -Source Code Compliance Team -Microsoft Corporation -One Microsoft Way -Redmond, WA 98052 -USA - -Notwithstanding any other terms, you may reverse engineer this software to the extent -required to debug changes to any libraries licensed under the GNU Lesser General Public License. - -=============================================================================== -vscode-languageserver-types 3.18.0 -License: MIT -Repository: https://github.com/Microsoft/vscode-languageserver-node.git -Source: License.txt -SHA-256: ec9ee83580841e8eb687aca9867f221503809ba6426c7f876ede17d91b9fcfd0 - -Copyright (c) Microsoft Corporation - -All rights reserved. - -MIT License - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - -Source: thirdpartynotices.txt -SHA-256: a89123562fe364dc8e969e85614eb6c1f8452afe131d6e42bd1314bb2d092b04 - -NOTICES AND INFORMATION -Do Not Translate or Localize - -This software incorporates material from third parties. -Microsoft makes certain open source code available at https://3rdpartysource.microsoft.com, -or you may send a check or money order for US $5.00, including the product name, -the open source component name, platform, and version number, to: - -Source Code Compliance Team -Microsoft Corporation -One Microsoft Way -Redmond, WA 98052 -USA - -Notwithstanding any other terms, you may reverse engineer this software to the extent -required to debug changes to any libraries licensed under the GNU Lesser General Public License. - diff --git a/basilisk-zed/Cargo.lock b/basilisk-zed/Cargo.lock deleted file mode 100644 index 80b6676e0..000000000 --- a/basilisk-zed/Cargo.lock +++ /dev/null @@ -1,821 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "adler2" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" - -[[package]] -name = "anyhow" -version = "1.0.102" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" - -[[package]] -name = "auditable-serde" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c7bf8143dfc3c0258df908843e169b5cc5fcf76c7718bd66135ef4a9cd558c5" -dependencies = [ - "semver", - "serde", - "serde_json", - "topological-sort", -] - -[[package]] -name = "basilisk-common" -version = "0.1.0" - -[[package]] -name = "basilisk-zed" -version = "0.1.0" -dependencies = [ - "basilisk-common", - "zed_extension_api", -] - -[[package]] -name = "bitflags" -version = "2.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" - -[[package]] -name = "cfg-if" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" - -[[package]] -name = "crc32fast" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "displaydoc" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "equivalent" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" - -[[package]] -name = "flate2" -version = "1.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" -dependencies = [ - "crc32fast", - "miniz_oxide", -] - -[[package]] -name = "foldhash" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" - -[[package]] -name = "form_urlencoded" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" -dependencies = [ - "percent-encoding", -] - -[[package]] -name = "futures" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" -dependencies = [ - "futures-channel", - "futures-core", - "futures-executor", - "futures-io", - "futures-sink", - "futures-task", - "futures-util", -] - -[[package]] -name = "futures-channel" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" -dependencies = [ - "futures-core", - "futures-sink", -] - -[[package]] -name = "futures-core" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" - -[[package]] -name = "futures-executor" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" -dependencies = [ - "futures-core", - "futures-task", - "futures-util", -] - -[[package]] -name = "futures-io" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" - -[[package]] -name = "futures-macro" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "futures-sink" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" - -[[package]] -name = "futures-task" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" - -[[package]] -name = "futures-util" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" -dependencies = [ - "futures-channel", - "futures-core", - "futures-io", - "futures-macro", - "futures-sink", - "futures-task", - "memchr", - "pin-project-lite", - "slab", -] - -[[package]] -name = "hashbrown" -version = "0.15.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" -dependencies = [ - "foldhash", -] - -[[package]] -name = "hashbrown" -version = "0.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" - -[[package]] -name = "heck" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" - -[[package]] -name = "icu_collections" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" -dependencies = [ - "displaydoc", - "potential_utf", - "yoke", - "zerofrom", - "zerovec", -] - -[[package]] -name = "icu_locale_core" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" -dependencies = [ - "displaydoc", - "litemap", - "tinystr", - "writeable", - "zerovec", -] - -[[package]] -name = "icu_normalizer" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" -dependencies = [ - "icu_collections", - "icu_normalizer_data", - "icu_properties", - "icu_provider", - "smallvec", - "zerovec", -] - -[[package]] -name = "icu_normalizer_data" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" - -[[package]] -name = "icu_properties" -version = "2.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" -dependencies = [ - "icu_collections", - "icu_locale_core", - "icu_properties_data", - "icu_provider", - "zerotrie", - "zerovec", -] - -[[package]] -name = "icu_properties_data" -version = "2.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" - -[[package]] -name = "icu_provider" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" -dependencies = [ - "displaydoc", - "icu_locale_core", - "writeable", - "yoke", - "zerofrom", - "zerotrie", - "zerovec", -] - -[[package]] -name = "id-arena" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" - -[[package]] -name = "idna" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" -dependencies = [ - "idna_adapter", - "smallvec", - "utf8_iter", -] - -[[package]] -name = "idna_adapter" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" -dependencies = [ - "icu_normalizer", - "icu_properties", -] - -[[package]] -name = "indexmap" -version = "2.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" -dependencies = [ - "equivalent", - "hashbrown 0.16.1", - "serde", - "serde_core", -] - -[[package]] -name = "itoa" -version = "1.0.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" - -[[package]] -name = "leb128fmt" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" - -[[package]] -name = "litemap" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" - -[[package]] -name = "log" -version = "0.4.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" - -[[package]] -name = "memchr" -version = "2.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" - -[[package]] -name = "miniz_oxide" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" -dependencies = [ - "adler2", - "simd-adler32", -] - -[[package]] -name = "once_cell" -version = "1.21.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" - -[[package]] -name = "percent-encoding" -version = "2.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" - -[[package]] -name = "pin-project-lite" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" - -[[package]] -name = "potential_utf" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" -dependencies = [ - "zerovec", -] - -[[package]] -name = "prettyplease" -version = "0.2.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" -dependencies = [ - "proc-macro2", - "syn", -] - -[[package]] -name = "proc-macro2" -version = "1.0.106" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "quote" -version = "1.0.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "semver" -version = "1.0.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" -dependencies = [ - "serde", - "serde_core", -] - -[[package]] -name = "serde" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" -dependencies = [ - "serde_core", - "serde_derive", -] - -[[package]] -name = "serde_core" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "serde_json" -version = "1.0.149" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" -dependencies = [ - "itoa", - "memchr", - "serde", - "serde_core", - "zmij", -] - -[[package]] -name = "simd-adler32" -version = "0.3.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" - -[[package]] -name = "slab" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" - -[[package]] -name = "smallvec" -version = "1.15.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" - -[[package]] -name = "spdx" -version = "0.10.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3e17e880bafaeb362a7b751ec46bdc5b61445a188f80e0606e68167cd540fa3" -dependencies = [ - "smallvec", -] - -[[package]] -name = "stable_deref_trait" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" - -[[package]] -name = "syn" -version = "2.0.117" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "synstructure" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "tinystr" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" -dependencies = [ - "displaydoc", - "zerovec", -] - -[[package]] -name = "topological-sort" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea68304e134ecd095ac6c3574494fc62b909f416c4fca77e440530221e549d3d" - -[[package]] -name = "unicode-ident" -version = "1.0.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" - -[[package]] -name = "unicode-xid" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" - -[[package]] -name = "url" -version = "2.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" -dependencies = [ - "form_urlencoded", - "idna", - "percent-encoding", - "serde", -] - -[[package]] -name = "utf8_iter" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" - -[[package]] -name = "wasm-encoder" -version = "0.227.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "80bb72f02e7fbf07183443b27b0f3d4144abf8c114189f2e088ed95b696a7822" -dependencies = [ - "leb128fmt", - "wasmparser", -] - -[[package]] -name = "wasm-metadata" -version = "0.227.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce1ef0faabbbba6674e97a56bee857ccddf942785a336c8b47b42373c922a91d" -dependencies = [ - "anyhow", - "auditable-serde", - "flate2", - "indexmap", - "serde", - "serde_derive", - "serde_json", - "spdx", - "url", - "wasm-encoder", - "wasmparser", -] - -[[package]] -name = "wasmparser" -version = "0.227.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f51cad774fb3c9461ab9bccc9c62dfb7388397b5deda31bf40e8108ccd678b2" -dependencies = [ - "bitflags", - "hashbrown 0.15.5", - "indexmap", - "semver", -] - -[[package]] -name = "wit-bindgen" -version = "0.41.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10fb6648689b3929d56bbc7eb1acf70c9a42a29eb5358c67c10f54dbd5d695de" -dependencies = [ - "wit-bindgen-rt", - "wit-bindgen-rust-macro", -] - -[[package]] -name = "wit-bindgen-core" -version = "0.41.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92fa781d4f2ff6d3f27f3cc9b74a73327b31ca0dc4a3ef25a0ce2983e0e5af9b" -dependencies = [ - "anyhow", - "heck", - "wit-parser", -] - -[[package]] -name = "wit-bindgen-rt" -version = "0.41.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4db52a11d4dfb0a59f194c064055794ee6564eb1ced88c25da2cf76e50c5621" -dependencies = [ - "bitflags", - "futures", - "once_cell", -] - -[[package]] -name = "wit-bindgen-rust" -version = "0.41.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d0809dc5ba19e2e98661bf32fc0addc5a3ca5bf3a6a7083aa6ba484085ff3ce" -dependencies = [ - "anyhow", - "heck", - "indexmap", - "prettyplease", - "syn", - "wasm-metadata", - "wit-bindgen-core", - "wit-component", -] - -[[package]] -name = "wit-bindgen-rust-macro" -version = "0.41.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad19eec017904e04c60719592a803ee5da76cb51c81e3f6fbf9457f59db49799" -dependencies = [ - "anyhow", - "prettyplease", - "proc-macro2", - "quote", - "syn", - "wit-bindgen-core", - "wit-bindgen-rust", -] - -[[package]] -name = "wit-component" -version = "0.227.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "635c3adc595422cbf2341a17fb73a319669cc8d33deed3a48368a841df86b676" -dependencies = [ - "anyhow", - "bitflags", - "indexmap", - "log", - "serde", - "serde_derive", - "serde_json", - "wasm-encoder", - "wasm-metadata", - "wasmparser", - "wit-parser", -] - -[[package]] -name = "wit-parser" -version = "0.227.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddf445ed5157046e4baf56f9138c124a0824d4d1657e7204d71886ad8ce2fc11" -dependencies = [ - "anyhow", - "id-arena", - "indexmap", - "log", - "semver", - "serde", - "serde_derive", - "serde_json", - "unicode-xid", - "wasmparser", -] - -[[package]] -name = "writeable" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" - -[[package]] -name = "yoke" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" -dependencies = [ - "stable_deref_trait", - "yoke-derive", - "zerofrom", -] - -[[package]] -name = "yoke-derive" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" -dependencies = [ - "proc-macro2", - "quote", - "syn", - "synstructure", -] - -[[package]] -name = "zed_extension_api" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0729d50b4ca0a7e28e590bbe32e3ca0194d97ef654961451a424c661a366fca0" -dependencies = [ - "serde", - "serde_json", - "wit-bindgen", -] - -[[package]] -name = "zerofrom" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" -dependencies = [ - "zerofrom-derive", -] - -[[package]] -name = "zerofrom-derive" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" -dependencies = [ - "proc-macro2", - "quote", - "syn", - "synstructure", -] - -[[package]] -name = "zerotrie" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" -dependencies = [ - "displaydoc", - "yoke", - "zerofrom", -] - -[[package]] -name = "zerovec" -version = "0.11.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" -dependencies = [ - "yoke", - "zerofrom", - "zerovec-derive", -] - -[[package]] -name = "zerovec-derive" -version = "0.11.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "zmij" -version = "1.0.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/basilisk-zed/Cargo.toml b/basilisk-zed/Cargo.toml index c0d29b2a4..366dccbb0 100644 --- a/basilisk-zed/Cargo.toml +++ b/basilisk-zed/Cargo.toml @@ -1,7 +1,6 @@ # Crate manifest. Implements [ZED-CARGOTOML]: cdylib compiled to wasm32-wasip2. # version is 0.0.0-PLACEHOLDER on every monorepo commit and is stamped only -# during CI; the registry mirror rewrites the basilisk-common path dep — see -# [ZED-MIRROR]. +# during CI — see [ZED-MIRROR]. [package] name = "basilisk-zed" version = "0.0.0-PLACEHOLDER" @@ -14,8 +13,9 @@ crate-type = ["cdylib"] [lints] workspace = true +# The extension prints one generated file. It shares no constants with the +# language server (there is no server to share them with) and serialises +# nothing, so `basilisk-common` and `serde_json` are both gone — which is also +# why the registry mirror no longer has to vendor a workspace crate. [dependencies] zed_extension_api = "0.7.0" -serde_json = "1" -# Shared constants with the LSP — zero-dependency, WASM-compatible. -basilisk-common = { path = "../crates/basilisk-common" } diff --git a/basilisk-zed/README.md b/basilisk-zed/README.md index 7842079ab..62ad58e2a 100644 --- a/basilisk-zed/README.md +++ b/basilisk-zed/README.md @@ -1,68 +1,38 @@ -# basilisk-zed + +# Basilisk is unlisted -

English · 简体中文

+> **You are reading the Basilisk Zed extension listing.** -Zed editor extension for Basilisk — WASM-based Python type checking and language server integration. +**Basilisk's type checker was producing incorrect results.** Rules decided from the way code was *spelled* rather than what it meant, so they could be wrong in both directions — a false error on correct code, or silence on a real bug. -Basilisk is an open-source Python type checker and language server built in Rust: diagnostics, autocomplete, refactoring, debugging, and profiling, with strictness configured per rule. +**We asked for Basilisk to be removed from the `python/typing` conformance results, and it has been removed** ([python/typing#2330](https://github.com/python/typing/pull/2330)). That score did not demonstrate correctness. -

- Basilisk in the Zed editor — Python type checking and diagnostics inline -

+**We cannot tell you how much of the checker this affects.** The code responsible is not isolated to a known set of rules. We will not estimate. That uncertainty is the reason for everything below. -> ## ⚠️ Do not use Basilisk's type checker in your pipeline -> -> **The type checker still contains code that isn't doing real type checking, and it is not yet trustworthy.** Some rules decide from the way code is *spelled* rather than what it means, so they can be wrong in both directions — a false error on correct code, or silence where there is a genuine bug. Don't gate CI on it, and don't read a clean run as a clean codebase. Our former conformance claim and our benchmark figures are withdrawn, and Basilisk was [removed from the official results](https://github.com/python/typing/blob/main/conformance/results/results.html) at our request. -> -> **This was a mistake and a failure to verify.** We published on a green run without ever checking whether our rules survived a semantics-preserving change. Basilisk's author has published a [personal account and apology](https://www.christianfindlay.com/blog/basilisk-conformance-apology). -> -> **We are auditing every rule and deleting the ones that don't hold up** — not rewriting them, not patching them, with a failing test left behind so the gap stays visible. Where a rule can't be made reliable in a straightforward way, we will depend on a different, established type checker rather than ship our own unreliable version of it. -> -> **Basilisk is much more than a type checker.** The language server, refactoring, formatting, debugging, and profiling don't rest on the rules under audit — those are what we are sharpening while it runs, removing anything that could hand you a misleading result. We are doing this to restore trust and turn Basilisk back into a tool you can believe. [Read the correction](https://www.basilisk-python.dev/docs/conformance/). +**A code-quality tool that does not produce correct results is worse than useless.** Basilisk is being unlisted everywhere it was published — the VS Code Marketplace, Open VSX, the Zed registry, PyPI, the Homebrew tap, and the Scoop bucket — and the type checker is inert. Remove it from your pipeline; it checks nothing, and every invocation fails rather than reporting a clean run. -## Install +**We are not fixing Basilisk's type checker code. We are rebuilding from the ground up as a new product.** It will ship only what can be trusted. That most likely will not include type checking. Nothing is relisted until it has been rebuilt from components we can vouch for. If type checking ever returns, it will be externally audited before release. -Command palette (`Cmd+Shift+P` / `Ctrl+Shift+P`) → **zed: install dev extension** → select this directory (clone [`Nimblesite/basilisk-zed`](https://github.com/Nimblesite/basilisk-zed) first if you do not have the monorepo). Zed compiles the extension to WASM itself — you never pre-build or copy a `.wasm` file. +Basilisk's author has published a full public account: [an apology](https://www.christianfindlay.com/blog/basilisk-conformance-apology). -**You do not install the Basilisk binary separately.** On first activation the extension downloads the matching binary for your platform from the [GitHub release](https://github.com/Nimblesite/Basilisk/releases), caches it inside Zed's extension directory, and reuses it until a newer release appears. Override it only for development or a system install, via `lsp.basilisk.binary.path` in `settings.json` or the `BASILISK_PATH` environment variable. +## What to do now -> The extension is not yet listed in the [Zed extension registry](https://github.com/zed-industries/extensions); until that listing lands, the dev-extension flow above is the install path. +**Remove Basilisk from your pipeline, your pre-commit hooks, and your editor.** Uninstall the CLI and the extension. -Full instructions, settings, debugging, and the slash-command reference: [basilisk-python.dev/docs/install-zed](https://www.basilisk-python.dev/docs/install-zed/). +The type checker is inert: it checks nothing, and every invocation fails. It prints this statement and exits non-zero, so a build that still calls it fails loudly rather than reporting a clean run. Do not treat that failure as a finding about your code. -## Role in Basilisk +**Treat every result Basilisk gave you as unverified.** A clean run was never evidence that your code was clean, and an error it reported may never have been real. -This is the **Zed editor integration**. It is a native Zed extension compiled to WASM that connects the Basilisk language server to Zed, providing real-time diagnostics, hover, go-to-definition, code actions, and debugging via DAP. +Every distribution channel is being unlisted. Nothing will be relisted until it has been rebuilt from components we can vouch for. -## Key concepts +## Acknowledgments -- **WASM extension** — compiled as a `cdylib` crate targeting `wasm32-wasip2`, loaded natively by Zed. -- **`zed_extension_api`** — uses Zed's official extension API for language server lifecycle management. -- **`basilisk-common`** — shares diagnostic codes and constants with the rest of the Basilisk workspace (also WASM-compatible). -- **Built-in Python, untouched** — binds to Zed's own Python language by name. The extension ships no `languages/` directory and no grammar, so Zed compiles nothing from source and your highlighting, brackets, indent rules, and runnables stay exactly as Zed ships them. -- **DAP debugging** — supports the Debug Adapter Protocol for integrated Python debugging. - -## Building - -From a monorepo checkout, build the extension and set up the local dev loop: - -```sh -make package-zed -``` - -Standalone (this repository on its own), the build is exactly the one the release pipeline gates the publish on: - -```sh -cargo build --release --target wasm32-wasip2 -``` - -## Dependencies - -| Crate | Purpose | -|-------|---------| -| `zed_extension_api` | Zed extension API | -| `basilisk-common` | Shared constants and types | +Basilisk is built on [Ruff](https://github.com/astral-sh/ruff) by [Astral](https://astral.sh/), whose parser, AST, and formatter crates it embeds (MIT), and on standard-library type stubs from [typeshed](https://github.com/python/typeshed) (Apache-2.0, with MIT-licensed parts). Neither project is responsible for how Basilisk used them. Full component list 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). ## License -MIT. +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/basilisk-zed/README.zh.md b/basilisk-zed/README.zh.md deleted file mode 100644 index f2852dcb6..000000000 --- a/basilisk-zed/README.zh.md +++ /dev/null @@ -1,70 +0,0 @@ -

English · 简体中文

- -> 📝 本文档由机器翻译生成,欢迎母语者校对改进。 - -# basilisk-zed - -Basilisk 的 Zed 编辑器扩展 —— 基于 WASM 的 Python 类型检查与语言服务器集成。 - -Basilisk 是用 Rust 打造的开源 Python 类型检查器与语言服务器:诊断、自动补全、重构、调试与性能分析,严格程度按规则配置。 - -

- Zed 编辑器中的 Basilisk —— 行内 Python 类型检查与诊断 -

- -> ## ⚠️ 请勿在流水线中使用 Basilisk 的类型检查器 -> -> **类型检查器中仍然存在没有做真正类型检查的代码,它目前还不值得信任。** 有些规则依据的是代码的**写法**而不是含义,因此两个方向上都可能出错 —— 既可能对正确的代码报出虚假错误,也可能对真实的缺陷保持沉默。请不要用它作为 CI 的门禁,也不要把一次干净的运行结果当作代码库是干净的。此前的一致性宣称与基准测试数字均已撤回,并主动请求[从官方结果中移除](https://github.com/python/typing/blob/main/conformance/results/results.html)。 -> -> **这是一个错误、一次验证上的失职。** 我们仅凭一次全绿的运行就发布了结果,却从未检查过我们的规则能否经受住保持语义的改写。Basilisk 作者已发表[个人说明与致歉](https://www.christianfindlay.com/blog/basilisk-conformance-apology)。 -> -> **我们正在逐条审计规则,并删除那些站不住脚的规则** —— 不是重写,也不是打补丁,而是删除,并留下一个失败的测试,让缺口保持可见。如果一条规则无法以直截了当的方式做到可靠,我们会转而依赖另一个成熟的类型检查器,而不是端出我们自己那份不可靠的实现。 -> -> **Basilisk 远不只是一个类型检查器。** 语言服务器、重构、格式化、调试与性能分析都不建立在正在接受审计的规则之上 —— 审计期间,这些正是我们着力打磨的部分,并移除任何可能给出误导性结果的东西。我们这样做,是为了重建信任,把 Basilisk 变回一个你可以信赖的工具。[阅读更正](https://www.basilisk-python.dev/zh/docs/conformance/)。 - -## 安装 - -命令面板(`Cmd+Shift+P` / `Ctrl+Shift+P`)→ **zed: install dev extension** → 选择本目录(如果没有 monorepo,请先克隆 [`Nimblesite/basilisk-zed`](https://github.com/Nimblesite/basilisk-zed))。Zed 会自行把扩展编译为 WASM —— 你无需预先构建或复制 `.wasm` 文件。 - -**你无需单独安装 Basilisk 二进制文件。** 首次激活时,扩展会从 [GitHub Release](https://github.com/Nimblesite/Basilisk/releases) 下载与你的平台匹配的二进制文件,缓存在 Zed 的扩展目录中,并一直复用到出现更新的发行版为止。仅在开发或指向系统安装时才需要覆盖它:在 `settings.json` 中设置 `lsp.basilisk.binary.path`,或设置 `BASILISK_PATH` 环境变量。 - -> 该扩展尚未收录进 [Zed 扩展注册表](https://github.com/zed-industries/extensions);在收录完成之前,上述开发扩展方式就是安装路径。 - -完整的安装说明、设置项、调试与斜杠命令参考:[basilisk-python.dev/docs/install-zed](https://www.basilisk-python.dev/docs/install-zed/)。 - -## 在 Basilisk 中的角色 - -这是 **Zed 编辑器集成**。它是一个编译为 WASM 的原生 Zed 扩展,将 Basilisk 语言服务器连接到 Zed,提供实时诊断、悬停提示、跳转到定义、代码操作,以及通过 DAP 实现的调试。 - -## 核心概念 - -- **WASM 扩展** —— 编译为面向 `wasm32-wasip2` 的 `cdylib` crate,由 Zed 原生加载。 -- **`zed_extension_api`** —— 使用 Zed 官方扩展 API 管理语言服务器生命周期。 -- **`basilisk-common`** —— 与 Basilisk 工作区的其余部分共享诊断代码和常量(同样兼容 WASM)。 -- **不改动内置 Python** —— 按名称绑定到 Zed 自带的 Python 语言。扩展不附带 `languages/` 目录,也不附带语法,因此 Zed 不会从源码编译任何东西,你的语法高亮、括号匹配、缩进规则和可运行项都保持 Zed 出厂时的样子。 -- **DAP 调试** —— 支持 Debug Adapter Protocol,实现集成的 Python 调试。 - -## 构建 - -在 monorepo 检出中,构建扩展并配置本地开发循环: - -```sh -make package-zed -``` - -独立仓库(仅本仓库)中,构建命令与发布流水线用于放行发布的那一条完全相同: - -```sh -cargo build --release --target wasm32-wasip2 -``` - -## 依赖 - -| Crate | 用途 | -|-------|---------| -| `zed_extension_api` | Zed 扩展 API | -| `basilisk-common` | 共享的常量和类型 | - -## 许可证 - -MIT。 diff --git a/basilisk-zed/debug_adapter_schemas/basilisk-debug.json b/basilisk-zed/debug_adapter_schemas/basilisk-debug.json deleted file mode 100644 index ef5a3eedc..000000000 --- a/basilisk-zed/debug_adapter_schemas/basilisk-debug.json +++ /dev/null @@ -1,83 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "type": "object", - "oneOf": [ - { - "properties": { - "request": { - "type": "string", - "enum": ["launch"], - "default": "launch", - "description": "Launch a new Python process to debug" - }, - "program": { - "type": "string", - "description": "Python file to debug" - }, - "args": { - "type": "array", - "items": { "type": "string" }, - "description": "Command-line arguments for the program" - }, - "cwd": { - "type": "string", - "description": "Working directory for the debug session" - }, - "python": { - "type": "string", - "description": "Python interpreter path" - }, - "justMyCode": { - "type": "boolean", - "default": true, - "description": "Only debug user code, skip library frames" - }, - "stopOnEntry": { - "type": "boolean", - "default": false, - "description": "Stop at the first line of user code" - }, - "console": { - "type": "string", - "enum": ["integratedTerminal", "internalConsole"], - "default": "integratedTerminal", - "description": "Where to launch the debug console" - }, - "env": { - "type": "object", - "additionalProperties": { "type": "string" }, - "description": "Environment variables for the debuggee" - } - }, - "required": ["program"] - }, - { - "properties": { - "request": { - "type": "string", - "enum": ["attach"], - "description": "Attach to a running Python process" - }, - "processId": { - "type": "integer", - "description": "PID of the running Python process to attach to" - }, - "host": { - "type": "string", - "default": "127.0.0.1", - "description": "Host where debugpy is listening" - }, - "port": { - "type": "integer", - "description": "Port where debugpy is listening" - }, - "justMyCode": { - "type": "boolean", - "default": true, - "description": "Only debug user code, skip library frames" - } - }, - "required": ["request"] - } - ] -} diff --git a/basilisk-zed/extension.toml b/basilisk-zed/extension.toml index c688aee70..f38a9ad8d 100644 --- a/basilisk-zed/extension.toml +++ b/basilisk-zed/extension.toml @@ -1,86 +1,18 @@ # Extension manifest. Implements [ZED-EXTTOML]; the placeholder version is # stamped only during CI per [ZED-CARGOTOML] / [ZED-MIRROR]. +# +# This extension states that Basilisk is unlisted and does nothing else +# ([WITHDRAWAL-SURFACES]). There is deliberately no [language_servers.*] table +# (the `basilisk` binary is inert and starts no server), no [debug_adapters.*] +# table, and no themes or grammars. The one slash command prints the statement. id = "basilisk" name = "Basilisk" version = "0.0.0-PLACEHOLDER" schema_version = 1 authors = ["Basilisk Contributors"] -description = "An open-source Python type checker and language server built in Rust: diagnostics, autocomplete, go-to-definition, refactoring, integrated debugging, and profiling. Strictness is configured per rule, so a codebase can adopt type safety incrementally." +description = "Basilisk's type checker produced incorrect results. Basilisk is unlisted and is being rebuilt from the ground up as a new product." repository = "https://github.com/Nimblesite/Basilisk" -# No [grammars.*] and no languages/ directory: Basilisk attaches to Zed's -# BUILT-IN Python language by name. Shipping either would register a second -# language called "Python", and Zed's registry OVERWRITES the existing entry's -# grammar/matcher/loader on a name collision, so the extension's definition -# would silently replace the built-in one (bracket auto-close, f-string and -# docstring pairs, elif/else auto-dedent, shebang detection, `debuggers`, and -# the far richer highlight/runnable queries all lost). Same shape as the other -# Python language-server extensions in the registry (ty, pyrefly, pylsp). -# Implements [ZED-GRAMMAR] and [ZED-TREESITTER] — see docs/specs/ZED-SPEC.md. - -# LSP wiring: Zed launches `basilisk lsp` for Python. Implements [ZED-LSP]. -[language_servers.basilisk] -name = "Basilisk" -languages = ["Python"] - -[language_servers.basilisk.language_ids] -"Python" = "python" - -# Slash commands (profiling, memory, activity-panel, tests). Implements -# [ZED-PROFILE]; dispatch + output live in src/logic.rs. -[slash_commands.modules] -description = "Show workspace module tree" -requires_argument = false - -[slash_commands.symbols] -description = "Show symbols in a module" -requires_argument = true - -[slash_commands.health] -description = "Type health statistics" -requires_argument = false - [slash_commands.basilisk] -description = "Basilisk server info and commands" -requires_argument = false - -[slash_commands.profile] -description = "Start CPU profiling (optional: PID)" -requires_argument = false - -[slash_commands.profstop] -description = "Stop CPU profiling and export results" -requires_argument = false - -[slash_commands.profsnapshot] -description = "Take a profiling snapshot without stopping" -requires_argument = false - -[slash_commands.memleak] -description = "Start memory leak tracking via tracemalloc" -requires_argument = false - -[slash_commands.memstop] -description = "Stop memory tracking and generate leak report" -requires_argument = false - -[slash_commands.memrefs] -description = "Walk the reference graph for a Python type" +description = "Why is Basilisk unlisted?" requires_argument = false - -[slash_commands.tests] -description = "Discover pytest/unittest tests in workspace" -requires_argument = false - -[slash_commands.runtests] -description = "Run tests by node ID or file" -requires_argument = false - -[slash_commands.testfile] -description = "Run all tests in the current file" -requires_argument = false - -# DAP adapter registration; the launch/attach schema lives at schema_path. -# Implements [ZED-DAP]. -[debug_adapters.basilisk-debug] -schema_path = "debug_adapter_schemas/basilisk-debug.json" diff --git a/basilisk-zed/src/lib.rs b/basilisk-zed/src/lib.rs index f9648031b..0a2fecb65 100644 --- a/basilisk-zed/src/lib.rs +++ b/basilisk-zed/src/lib.rs @@ -1,5 +1,10 @@ //! Basilisk extension for the Zed editor. //! +//! Basilisk is unlisted and its type checker is inert, so this extension has +//! one job: state that, in the words the messaging spec approved. It launches +//! no language server, downloads no binary, and registers no debug adapter +//! ([WITHDRAWAL-SURFACES]). +//! //! Pure logic lives in [`logic`] (testable on native target). //! This file is thin glue that bridges [`logic`] ↔ `zed_extension_api` types. #![expect( @@ -9,332 +14,36 @@ mod logic; -use zed_extension_api::{self as zed, serde_json, Result}; - -use basilisk_common::{config_keys, release}; - -struct BasiliskExtension { - /// Cached path to the resolved binary, so we don't re-resolve every call. - cached_binary_path: Option, - /// Version of the currently resolved binary (from download dir name or "local"). - cached_binary_version: Option, -} - -// ── Binary resolution ──────────────────────────────────────────────────────── - -impl BasiliskExtension { - /// Resolve the basilisk binary to an absolute path. Implements [ZED-DIST]. - /// - /// Resolution order: - /// 1. Explicit override — `binary.path` in the Zed LSP settings - /// 2. Explicit override — the `BASILISK_PATH` environment variable - /// 3. Default — download the matching binary from the latest GitHub release - /// - /// There is no filesystem default. Installing the extension alone is enough: - /// with no explicit override, the binary is downloaded from the release, so - /// users never install it separately (the Shipwright contract). The two - /// overrides exist for development and for pointing at a system install. - fn resolve_binary(&mut self, worktree: &zed::Worktree) -> Result { - if let Some(ref path) = self.cached_binary_path { - return Ok(path.clone()); - } - - let settings_path = zed::settings::LspSettings::for_worktree("basilisk", worktree) - .ok() - .and_then(|settings| settings.binary) - .and_then(|binary| binary.path); - let env = worktree.shell_env(); - let env_path = logic::find_env_var(&env, "BASILISK_PATH"); - - if let Some(path) = logic::resolve_binary_override(settings_path.as_deref(), env_path) { - self.cached_binary_path = Some(path.clone()); - return Ok(path); - } - - // Default: download from the latest GitHub release (zero-config install). - let (path, version) = Self::download_binary()?; - self.cached_binary_path = Some(path.clone()); - self.cached_binary_version = Some(version); - Ok(path) - } - - /// Check if a newer version is available and log a warning if so. - /// - /// Non-fatal — if the check fails we silently continue. - fn check_for_updates(&self) { - let current = match &self.cached_binary_version { - Some(v) => v.as_str(), - None => return, - }; - - if let Ok(latest_release) = zed::latest_github_release( - release::GITHUB_REPO, - zed::GithubReleaseOptions { - require_assets: false, - pre_release: false, - }, - ) { - if logic::is_newer_version(current, &latest_release.version) { - eprintln!( - "[basilisk] Update available: {} → {} (restart Zed to upgrade)", - current, latest_release.version - ); - } - } - } - - /// Download the basilisk binary from the latest GitHub release. - fn download_binary() -> Result<(String, String)> { - let release = zed::latest_github_release( - release::GITHUB_REPO, - zed::GithubReleaseOptions { - require_assets: true, - pre_release: false, - }, - )?; - - let (platform, arch) = zed::current_platform(); - - let (os_str, is_windows) = match platform { - zed::Os::Mac => ("apple-darwin", false), - zed::Os::Linux => ("unknown-linux-gnu", false), - zed::Os::Windows => ("pc-windows-msvc", true), - }; - let is_mac = matches!(platform, zed::Os::Mac); - - let arch_str = match arch { - zed::Architecture::Aarch64 => "aarch64", - zed::Architecture::X8664 => "x86_64", - zed::Architecture::X86 => { - return Err("32-bit x86 is not supported".into()); - } - }; - - let expected_asset = release::asset_name(os_str, arch_str, is_windows); - - let asset = release - .assets - .iter() - .find(|a| a.name == expected_asset) - .ok_or_else(|| { - format!( - "No release asset found for {expected_asset} in {}", - release.version - ) - })?; - - let binary_name = if is_windows { - "basilisk.exe" - } else { - "basilisk" - }; - - // The archive type and the binary's location inside it are - // platform-specific (macOS nests under basilisk-darwin/, Linux/Windows - // are flat) — both derived from the same single source of truth as the - // asset name so they can never drift from release.yml. - let download_dir = format!("basilisk-{}", release.version); - let binary_path = format!( - "{download_dir}/{}", - release::extracted_binary_path(binary_name, os_str) - ); - - // Only download if the binary isn't already cached in the extension dir. - if std::fs::metadata(&binary_path).is_err() { - let file_type = if release::is_zip_archive(os_str, is_windows) { - zed::DownloadedFileType::Zip - } else { - zed::DownloadedFileType::GzipTar - }; - zed::download_file(&asset.download_url, &download_dir, file_type) - .map_err(|err| format!("Failed to download basilisk: {err}"))?; - - zed::make_file_executable(&binary_path) - .map_err(|err| format!("Failed to make basilisk executable: {err}"))?; - - // Zed's zip extraction drops the Unix exec bit, and the macOS - // archive also carries the profiler helper next to the binary — - // restore its bit so profiling works without a separate install. - if is_mac { - let helper = format!( - "{download_dir}/{}", - release::extracted_binary_path(release::PROFILER_HELPER, os_str) - ); - zed::make_file_executable(&helper) - .map_err(|err| format!("Failed to make profiler helper executable: {err}"))?; - } - } - - Ok((binary_path, release.version)) - } - - /// Build a `SlashCommandOutput` from a `(label, text)` pair. - fn slash_output(label: String, text: String) -> zed::SlashCommandOutput { - zed::SlashCommandOutput { - sections: vec![zed::SlashCommandOutputSection { - range: (0..text.len()).into(), - label, - }], - text, - } - } -} +use zed_extension_api::{self as zed, Result}; -// ── Extension trait ────────────────────────────────────────────────────────── +struct BasiliskExtension; // The `zed::Extension` impl + `register_extension!` below is the extension -// entry point. Implements [ZED-LIBRS]. +// entry point. Implements [ZED-LIBRS]. Every other trait method keeps its +// default — the defaults return "not implemented", which is the honest answer +// for a server, adapter, or command this extension no longer provides. impl zed::Extension for BasiliskExtension { fn new() -> Self where Self: Sized, { - Self { - cached_binary_path: None, - cached_binary_version: None, - } - } - - // Launch `basilisk lsp` and pass workspace root + mapped settings — all 21 - // LSP features then flow through Zed's built-in client. Implements [ZED-LSP]. - fn language_server_command( - &mut self, - _language_server_id: &zed::LanguageServerId, - worktree: &zed::Worktree, - ) -> Result { - let binary_path = self.resolve_binary(worktree)?; - self.check_for_updates(); - Ok(zed::Command { - command: binary_path, - args: vec!["lsp".into()], - env: Vec::new(), - }) - } - - fn language_server_initialization_options( - &mut self, - _language_server_id: &zed::LanguageServerId, - worktree: &zed::Worktree, - ) -> Result> { - Ok(Some(serde_json::json!({ - "workspaceRoot": worktree.root_path(), - }))) - } - - fn language_server_workspace_configuration( - &mut self, - _language_server_id: &zed::LanguageServerId, - worktree: &zed::Worktree, - ) -> Result> { - let settings = zed::settings::LspSettings::for_worktree(config_keys::ROOT, worktree) - .ok() - .and_then(|s| s.settings); - - let config = settings.unwrap_or_else(logic::default_workspace_config); - Ok(Some(logic::wrap_config(&config))) + Self } - // Profiling / memory / activity-panel slash commands. Implements [ZED-PROFILE]. + /// `/basilisk` — print the approved statement into the assistant panel. fn run_slash_command( &self, - command: zed::SlashCommand, - args: Vec, + _command: zed::SlashCommand, + _args: Vec, _worktree: Option<&zed::Worktree>, ) -> Result { - let (label, text) = logic::slash_command_output(&command.name, &args)?; - Ok(Self::slash_output(label, text)) - } - - fn complete_slash_command_argument( - &self, - command: zed::SlashCommand, - _args: Vec, - ) -> Result> { - Ok(logic::slash_completions(&command.name) - .into_iter() - .map( - |(label, new_text, run_command)| zed::SlashCommandArgumentCompletion { - label, - new_text, - run_command, - }, - ) - .collect()) - } - - // DAP integration: resolve the basilisk binary and hand Zed a - // launch/attach config for `basilisk debug-adapter`. Implements [ZED-DAP]. - fn get_dap_binary( - &mut self, - _adapter_name: String, - config: zed::DebugTaskDefinition, - user_provided_debug_adapter_path: Option, - worktree: &zed::Worktree, - ) -> core::result::Result { - let binary_path = match user_provided_debug_adapter_path { - Some(path) => path, - None => self.resolve_binary(worktree)?, - }; - - let adapter_config: serde_json::Value = - serde_json::from_str(&config.config).unwrap_or_default(); - - let dap_config = logic::build_dap_config(&adapter_config); - - let request = if logic::is_attach_request(&adapter_config)? { - zed::StartDebuggingRequestArgumentsRequest::Attach - } else { - zed::StartDebuggingRequestArgumentsRequest::Launch - }; - - Ok(zed::DebugAdapterBinary { - command: Some(binary_path), - arguments: vec!["debug-adapter".into()], - envs: Vec::new(), - cwd: adapter_config - .get("cwd") - .and_then(serde_json::Value::as_str) - .map(String::from), - connection: None, - request_args: zed::StartDebuggingRequestArguments { - configuration: dap_config.to_string(), - request, - }, - }) - } - - fn dap_request_kind( - &mut self, - _adapter_name: String, - config: serde_json::Value, - ) -> core::result::Result { - if logic::is_attach_request(&config)? { - Ok(zed::StartDebuggingRequestArgumentsRequest::Attach) - } else { - Ok(zed::StartDebuggingRequestArgumentsRequest::Launch) - } - } - - fn dap_config_to_scenario( - &mut self, - config: zed::DebugConfig, - ) -> core::result::Result { - let adapter_config = match &config.request { - zed::DebugRequest::Launch(launch) => logic::build_launch_scenario( - &launch.program, - &launch.args, - launch.cwd.as_deref(), - config.stop_on_entry.unwrap_or(false), - ), - zed::DebugRequest::Attach(attach) => logic::build_attach_scenario(attach.process_id), - }; - - Ok(zed::DebugScenario { - label: config.label, - adapter: "basilisk-debug".to_string(), - build: None, - config: adapter_config.to_string(), - tcp_connection: None, + let (label, text) = logic::notice_output(); + Ok(zed::SlashCommandOutput { + sections: vec![zed::SlashCommandOutputSection { + range: (0..text.len()).into(), + label, + }], + text, }) } } diff --git a/basilisk-zed/src/logic.rs b/basilisk-zed/src/logic.rs index 14d77ebbc..7189f5ac2 100644 --- a/basilisk-zed/src/logic.rs +++ b/basilisk-zed/src/logic.rs @@ -1,582 +1,25 @@ -//! Pure logic extracted from the Zed extension glue layer. +//! Pure logic for the Zed extension: the approved statement, and the manifest +//! assertions that keep this extension from advertising anything else. //! -//! **Zero `zed_extension_api` imports.** Every function here takes and returns -//! only `serde_json::Value`, `String`, `&str`, or basic Rust types so the -//! module compiles and tests on any native target — no WASM host required. +//! **Zero `zed_extension_api` imports.** Everything here takes and returns only +//! `String`/`&str`, so the module compiles and tests on any native target — no +//! WASM host required. -use basilisk_common::{ - commands, config_keys, memory_diagnostics, notifications, profiler_diagnostics, - profiler_formats, profiler_presets, slash_commands, -}; -use serde_json::Value; +/// The statement, generated from the messaging spec's [WITHDRAWAL-INERT-TEXT] +/// fence by `scripts/gen_withdrawal_copy.py` and drift-gated in CI. Included as +/// bytes rather than a source literal so this crate cannot print its own +/// version of it. +pub const NOTICE: &str = include_str!("withdrawal_notice.txt"); -// ── Slash commands ─────────────────────────────────────────────────────────── +/// The panel heading Zed shows above the statement. +pub const LABEL: &str = "Basilisk is unlisted"; -/// A slash command: its `(title, body)` builder and the static completion -/// suggestions offered for its argument. -struct SlashCommand { - /// Command name (without the leading slash). - name: &'static str, - /// Builds the `(panel title, Markdown body)` pair from the command's args. - body: fn(&[String]) -> (String, String), - /// Completion suggestions as `(label, new_text, run_command)` tuples. - completions: &'static [(&'static str, &'static str, bool)], -} - -/// Every slash command the extension exposes. Single source of truth for both -/// [`slash_command_output`] and [`slash_completions`] — adding a command here -/// wires up dispatch and completion in one place. Implements [ZED-PROFILE]. -const SLASH_COMMANDS: &[SlashCommand] = &[ - SlashCommand { - name: slash_commands::PROFILE, - body: slash_profile, - completions: &[("", "", false)], - }, - SlashCommand { - name: slash_commands::PROFSTOP, - body: slash_profstop, - completions: &[], - }, - SlashCommand { - name: slash_commands::PROFSNAPSHOT, - body: slash_profsnapshot, - completions: &[], - }, - SlashCommand { - name: slash_commands::MEMLEAK, - body: slash_memleak, - completions: &[], - }, - SlashCommand { - name: slash_commands::MEMSTOP, - body: slash_memstop, - completions: &[], - }, - SlashCommand { - name: slash_commands::MEMREFS, - body: slash_memrefs, - completions: &[ - ("DataFrame", "DataFrame", true), - ("dict", "dict", true), - ("list", "list", true), - ("set", "set", true), - ("ndarray", "ndarray", true), - ("Tensor", "Tensor", true), - ], - }, - SlashCommand { - name: slash_commands::MODULES, - body: slash_modules, - completions: &[("", "", false)], - }, - SlashCommand { - name: slash_commands::SYMBOLS, - body: slash_symbols, - completions: &[("", "", false)], - }, - SlashCommand { - name: slash_commands::HEALTH, - body: slash_health, - completions: &[], - }, - SlashCommand { - name: slash_commands::BASILISK, - body: slash_basilisk, - completions: &[], - }, - SlashCommand { - name: slash_commands::TESTS, - body: slash_tests, - completions: &[], - }, - SlashCommand { - name: slash_commands::RUNTESTS, - body: slash_runtests, - completions: &[("", "", false)], - }, - SlashCommand { - name: slash_commands::TESTFILE, - body: slash_testfile, - completions: &[("", "", false)], - }, -]; - -/// Look up a slash command by name. -fn find_slash_command(command: &str) -> Option<&'static SlashCommand> { - SLASH_COMMANDS.iter().find(|cmd| cmd.name == command) -} - -/// Produce the (label, text) pair for a slash command invocation. -/// -/// Output is formatted as Markdown for the Zed AI assistant panel. -/// Returns `Err` for unknown command names. -pub fn slash_command_output(command: &str, args: &[String]) -> Result<(String, String), String> { - find_slash_command(command) - .map(|cmd| (cmd.body)(args)) - .ok_or_else(|| format!("Unknown slash command: {command}")) -} - -fn slash_profile(args: &[String]) -> (String, String) { - let target = match args.first() { - Some(pid) => format!("PID `{pid}`"), - None => "active Python process".to_string(), - }; - let start = commands::PROFILER_START; - let stop = commands::PROFILER_STOP; - let snapshot = commands::PROFILER_SNAPSHOT; - let list = commands::PROFILER_LIST; - let line_code = profiler_diagnostics::LINE; - let func_code = profiler_diagnostics::FUNC; - let progress = notifications::PROFILER_PROGRESS; - let quick = profiler_presets::QUICK; - let detailed = profiler_presets::DETAILED; - let long_running = profiler_presets::LONG_RUNNING; - let text = format!( - "## CPU Profiling\n\n\ - **Target:** {target}\n\n\ - Basilisk profiles Python processes via `py-spy` (zero overhead, no instrumentation).\n\ - Diagnostics appear inline as `{line_code}` / `{func_code}` hints on hot lines and functions.\n\ - Live progress is delivered via `{progress}` notifications.\n\n\ - ### How to start\n\ - Open the command palette and run **`{start}`** with:\n\ - ```json\n\ - {{\"pid\": , \"sampleRate\": 100, \"includeNative\": false}}\n\ - ```\n\ - If a debug session is active, the PID is auto-detected — omit the `pid` field.\n\n\ - ### Presets\n\ - | Preset | Sample Rate | Duration | Best for |\n\ - |--------|-------------|----------|----------|\n\ - | `{quick}` | 100 Hz | 10 s | Quick hotspot checks |\n\ - | `{detailed}` | 200 Hz | 60 s | Thorough, higher-fidelity analysis |\n\ - | `{long_running}` | 50 Hz | Unlimited | Servers and batch jobs, minimal overhead |\n\n\ - ### Results\n\ - - **Inline diagnostics** — `{line_code}` / `{func_code}` hints on hot lines (≥1% / ≥2% threshold)\n\ - - **Speedscope JSON** — written to `/tmp/`, open at speedscope.app for interactive flamegraph\n\ - - **Flamegraph SVG** — request `\"format\": \"flamegraph\"` on stop (inferno rendering)\n\n\ - ### Commands\n\ - | Command | Description |\n\ - |---------|-------------|\n\ - | `{start}` | Begin sampling (PID optional if debug session active) |\n\ - | `{stop}` | Stop and export results (speedscope / flamegraph / summary) |\n\ - | `{snapshot}` | Snapshot without stopping — diagnostics updated immediately |\n\ - | `{list}` | List active profiling sessions with sample counts |" - ); - ("CPU Profiling".to_string(), text) -} - -fn slash_profstop(_args: &[String]) -> (String, String) { - let stop = commands::PROFILER_STOP; - let speedscope = profiler_formats::SPEEDSCOPE; - let flamegraph = profiler_formats::FLAMEGRAPH; - let summary = profiler_formats::SUMMARY; - let text = format!( - "## Stop Profiling\n\n\ - Run **`{stop}`** from the command palette with:\n\ - ```json\n\ - {{\"sessionId\": \"\", \"format\": \"{speedscope}\"}}\n\ - ```\n\n\ - ### Output formats\n\ - | Format | Description |\n\ - |--------|-------------|\n\ - | `{speedscope}` | JSON for speedscope.app (default) |\n\ - | `{flamegraph}` | SVG flamegraph via inferno |\n\ - | `{summary}` | Text-only, no file export |\n\n\ - ### What happens on stop\n\ - 1. Sampling thread stops, remaining samples drained\n\ - 2. Hot lines/functions computed (above 1%/2% threshold)\n\ - 3. `publishDiagnostics` sent for each profiled file — hints appear inline\n\ - 4. Export file written to temp directory\n\n\ - > Open the speedscope JSON at speedscope.app for an interactive flamegraph." - ); - ("Stop Profiling".to_string(), text) -} - -fn slash_profsnapshot(_args: &[String]) -> (String, String) { - let snapshot = commands::PROFILER_SNAPSHOT; - let text = format!( - "## Profile Snapshot\n\n\ - Run **`{snapshot}`** from the command palette.\n\ - Takes a point-in-time snapshot without stopping the session.\n\n\ - Diagnostics are published immediately for the snapshot data.\n\ - Profiling continues — use `/profstop` to end the session.\n\n\ - > Useful for checking hotspots during a long-running profiling session." - ); - ("Profile Snapshot".to_string(), text) -} - -fn slash_memleak(_args: &[String]) -> (String, String) { - let start = commands::MEMORY_START; - let snapshot = commands::MEMORY_SNAPSHOT; - let diff = commands::MEMORY_DIFF; - let refs = commands::MEMORY_REFERENCES; - let gc = commands::MEMORY_GC_COLLECT; - let alloc = memory_diagnostics::ALLOC; - let growth = memory_diagnostics::GROWTH; - let leak = memory_diagnostics::LEAK; - let cycle = memory_diagnostics::CYCLE; - let timeline = notifications::MEMORY_TIMELINE; - let text = format!( - "## Memory Leak Tracking\n\n\ - Tracks object allocations via `tracemalloc` injection into an active debug session.\n\ - Memory timeline data is delivered via `{timeline}` notifications.\n\n\ - ### How to start\n\ - Run **`{start}`** from the command palette.\n\ - Requires an active debug session (debugpy) — the LSP injects Python code via DAP evaluate.\n\n\ - ### Commands\n\ - | Command | Description |\n\ - |---------|-------------|\n\ - | `{start}` | Begin tracking allocations |\n\ - | `{snapshot}` | Capture allocation snapshot |\n\ - | `{diff}` | Compare two snapshots for growth |\n\ - | `{refs}` | Walk object reference graph |\n\ - | `{gc}` | Force GC and report uncollectable |\n\n\ - ### Diagnostics\n\ - - `{alloc}` — top allocation sites (Hint)\n\ - - `{growth}` — memory growth detected (Warning)\n\ - - `{leak}` — suspected leak (Warning)\n\ - - `{cycle}` — reference cycle with `__del__` (Error)" - ); - ("Memory Tracking".to_string(), text) -} - -fn slash_memstop(_args: &[String]) -> (String, String) { - let leak = memory_diagnostics::LEAK; - let cycle = memory_diagnostics::CYCLE; - let text = format!( - "## Stop Memory Tracking\n\n\ - Stops `tracemalloc` injection and generates the final leak report.\n\n\ - Diagnostics published for each file with significant allocations.\n\ - Leak confidence: **Definite** > **High** > **Medium** > **Low**.\n\n\ - - `{leak}` — suspected leak with confidence score (Warning)\n\ - - `{cycle}` — reference cycle involving `__del__` finalizers (Error)\n\n\ - > Use `/memrefs ` to inspect retention paths for leaked types." - ); - ("Memory Report".to_string(), text) -} - -fn slash_memrefs(args: &[String]) -> (String, String) { - let type_name = args.first().map_or("(unknown)", String::as_str); - let refs_cmd = commands::MEMORY_REFERENCES; - let cycle_code = memory_diagnostics::CYCLE; - let text = format!( - "## Reference Graph: `{type_name}`\n\n\ - Run **`{refs_cmd}`** from the command palette with:\n\ - ```json\n\ - {{\"targetType\": \"{type_name}\", \"maxDepth\": 5, \"maxNodes\": 200}}\n\ - ```\n\n\ - Walks `gc.get_referrers()` from GC roots to all live instances of `{type_name}`.\n\n\ - ### Output\n\ - - **Nodes** — objects with type, size, repr\n\ - - **Edges** — reference relationships with labels (`.attr`, `[key]`)\n\ - - **Cycles** — detected via DFS, flagged as `{cycle_code}`\n\ - - **Retention path** — human-readable chain from GC root to target" - ); - ("Reference Graph".to_string(), text) -} - -fn slash_modules(args: &[String]) -> (String, String) { - let scope = match args.first() { - Some(prefix) => format!("prefix `{prefix}`"), - None => "entire workspace".to_string(), - }; - let text = format!( - "## Workspace Modules\n\n\ - **Scope:** {scope}\n\n\ - Fetching module tree via `basilisk.workspaceModules`.\n\n\ - The module tree shows:\n\ - - **Packages** — directories with `__init__.py`\n\ - - **Modules** — individual `.py` files\n\ - - **Symbols** — classes, functions, variables, constants\n\n\ - Each symbol includes:\n\ - - Type annotation status (annotated/unannotated)\n\ - - Export status (`__all__`)\n\ - - Line number for navigation\n\n\ - > Use `/symbols ` to drill into a specific module." - ); - ("Workspace Modules".to_string(), text) -} - -fn slash_symbols(args: &[String]) -> (String, String) { - let module = args.first().map_or("(all modules)", String::as_str); - let text = format!( - "## Module Symbols: `{module}`\n\n\ - Fetching symbols via `basilisk.workspaceModules` with scope `{module}`.\n\n\ - | Symbol | Kind | Annotated | Line |\n\ - |--------|------|-----------|------|\n\ - | *(loading...)* | | | |\n\n\ - > Symbols are extracted from the resolved AST, not from imports." - ); - ("Module Symbols".to_string(), text) -} - -fn slash_health(_args: &[String]) -> (String, String) { - let text = "\ - ## Type Health\n\n\ - Fetching workspace health via `basilisk.typeHealth`.\n\n\ - | Metric | Value |\n\ - |--------|-------|\n\ - | Coverage | *(loading...)* |\n\ - | Errors | *(loading...)* |\n\ - | Warnings | *(loading...)* |\n\ - | Adopted Files | *(loading...)* |\n\n\ - Per-module breakdown sorted by coverage (worst first):\n\n\ - | Module | Coverage | Errors | Warnings | Status |\n\ - |--------|----------|--------|----------|--------|\n\ - | *(loading...)* | | | | |\n\n\ - > Unannotated symbols are listed per module. Use `/symbols ` to see details." - .to_string(); - ("Type Health".to_string(), text) -} - -fn slash_basilisk(_args: &[String]) -> (String, String) { - let text = "\ - ## Basilisk Server Info\n\n\ - **Basilisk** — strict-by-default Python type checker and LSP built in Rust.\n\n\ - ### Features\n\ - - Type checking (strict-by-default, gradual adoption)\n\ - - Inlay hints (parameter names, variable types)\n\ - - Ruff integration (formatting, import organization)\n\ - - Test explorer (pytest + unittest)\n\ - - Debugger (debugpy integration)\n\ - - uv package manager integration\n\ - - Profiling and memory analysis\n\n\ - ### Quick Commands\n\ - | Command | Description |\n\ - |---------|-------------|\n\ - | `/modules` | Show workspace module tree |\n\ - | `/symbols ` | Show symbols in a module |\n\ - | `/health` | Type health statistics |\n\ - | `/tests` | Discover tests |\n\ - | `/runtests` | Execute tests |\n\ - | `/profile` | Start CPU profiling |\n\ - | `/memleak` | Start memory tracking |\n\n\ - > Visit [basilisk-python.dev](https://www.basilisk-python.dev) for documentation." - .to_string(); - ("Basilisk Info".to_string(), text) -} - -fn slash_tests(args: &[String]) -> (String, String) { - let scope = match args.first() { - Some(file) => format!("file `{file}`"), - None => "workspace".to_string(), - }; - let text = format!( - "## Test Discovery\n\n\ - **Scope:** {scope}\n\n\ - Discovering pytest and unittest tests from AST (no import needed).\n\n\ - Tests are sent to the LSP server via `basilisk.discoverTests` and \ - appear as inline run buttons via tree-sitter runnables.\n\n\ - **Detected patterns:**\n\ - - `def test_*()` — pytest test functions\n\ - - `class Test*` — pytest test classes\n\ - - `unittest.TestCase` subclasses and `def test_*` methods\n\n\ - > Use `/runtests` to execute tests, or click the inline run button." - ); - ("Test Discovery".to_string(), text) -} - -fn slash_runtests(args: &[String]) -> (String, String) { - let target = match args.first() { - Some(test_id) => format!("test `{test_id}`"), - None => "all tests".to_string(), - }; - let text = format!( - "## Running Tests\n\n\ - **Target:** {target}\n\n\ - Executing via `pytest` subprocess (or `uv run pytest` in uv projects).\n\n\ - | Setting | Value |\n\ - |---------|-------|\n\ - | Runner | pytest |\n\ - | Output | `--tb=short -q` |\n\ - | uv-aware | auto-detected |\n\n\ - Results:\n\ - - **Per-test status** — pass/fail/skip/error for each test\n\ - - **Inline failures** — assertion errors and tracebacks\n\ - - **Exit code** — overall pass/fail\n\n\ - > Use `/testfile` to run tests in the current file only." - ); - ("Running Tests".to_string(), text) -} - -fn slash_testfile(args: &[String]) -> (String, String) { - let file = args.first().map_or("(current file)", String::as_str); - let text = format!( - "## Running File Tests\n\n\ - **File:** `{file}`\n\n\ - Running all tests in this file via `basilisk.runTestFile`.\n\n\ - Uses `uv run pytest` when a uv project is detected, \ - otherwise bare `pytest` with `VIRTUAL_ENV` set from the workspace venv." - ); - ("File Tests".to_string(), text) -} - -/// Return completion suggestions for a slash command as `(label, new_text, run_command)`. -pub fn slash_completions(command: &str) -> Vec<(String, String, bool)> { - find_slash_command(command).map_or_else(Vec::new, |cmd| { - cmd.completions - .iter() - .map(|(label, new_text, run)| ((*label).to_string(), (*new_text).to_string(), *run)) - .collect() - }) -} - -// ── DAP config building ────────────────────────────────────────────────────── -// Launch/attach config normalisation for the basilisk-debug adapter; mirrors -// debug_adapter_schemas/basilisk-debug.json. Implements [ZED-DAP]. - -/// Build the DAP configuration JSON from an adapter config value. -/// -/// Normalises missing keys to sensible defaults so the debug-adapter -/// subcommand always receives a complete configuration. -pub fn build_dap_config(adapter_config: &Value) -> Value { - serde_json::json!({ - "program": adapter_config.get("program").and_then(Value::as_str).unwrap_or(""), - "args": adapter_config.get("args").unwrap_or(&serde_json::json!([])), - "cwd": adapter_config.get("cwd").and_then(Value::as_str).unwrap_or(""), - "python": adapter_config.get("python").and_then(Value::as_str).unwrap_or("python3"), - "justMyCode": adapter_config.get("justMyCode").and_then(Value::as_bool).unwrap_or(true), - "stopOnEntry": adapter_config.get("stopOnEntry").and_then(Value::as_bool).unwrap_or(false), - "console": adapter_config.get("console").and_then(Value::as_str).unwrap_or("integratedTerminal"), - }) -} - -/// Determine whether a DAP config represents an "attach" request. -/// -/// Returns `true` when `processId` is present **or** `request` is `"attach"`. -/// Returns `false` for launch (including when `request` is absent). -/// Returns `Err` for unrecognised request kinds. -pub fn is_attach_request(config: &Value) -> Result { - if config.get("processId").is_some() { - return Ok(true); - } - match config.get("request").and_then(Value::as_str) { - Some("attach") => Ok(true), - Some("launch") | None => Ok(false), - Some(other) => Err(format!("Unknown request kind: {other}")), - } -} - -/// Build a launch-mode scenario config from high-level parameters. -pub fn build_launch_scenario( - program: &str, - args: &[String], - cwd: Option<&str>, - stop_on_entry: bool, -) -> Value { - serde_json::json!({ - "program": program, - "args": args, - "cwd": cwd, - "stopOnEntry": stop_on_entry, - "justMyCode": true, - "console": "integratedTerminal", - }) -} - -/// Build an attach-mode scenario config. -pub fn build_attach_scenario(process_id: Option) -> Value { - serde_json::json!({ - "processId": process_id, - "request": "attach", - }) -} - -// ── Version check ──────────────────────────────────────────────────────────── - -/// Compare two semver-ish version strings (e.g. "v0.2.1" vs "0.3.0"). -/// -/// Returns `true` if `latest` is newer than `current`. -/// Strips a leading 'v' if present. -pub fn is_newer_version(current: &str, latest: &str) -> bool { - let parse = |s: &str| -> (u32, u32, u32) { - let s = s.strip_prefix('v').unwrap_or(s); - let mut parts = s.split('.'); - let major = parts.next().and_then(|p| p.parse().ok()).unwrap_or(0); - let minor = parts.next().and_then(|p| p.parse().ok()).unwrap_or(0); - let patch = parts.next().and_then(|p| p.parse().ok()).unwrap_or(0); - (major, minor, patch) - }; - parse(latest) > parse(current) -} - -// ── Workspace configuration ────────────────────────────────────────────────── - -/// Build the default workspace configuration sent when the user has no -/// explicit `basilisk` settings in Zed. Maps shared LSP config into Zed's -/// settings structure — implements [ZED-CONFIG]. -pub fn default_workspace_config() -> Value { - serde_json::json!({ - config_keys::INLAY_HINTS: { - config_keys::PARAM_NAMES: true, - config_keys::VAR_TYPES: true - }, - config_keys::RUFF: { - config_keys::RUFF_ENABLED: true - }, - config_keys::UV: { - config_keys::UV_ENABLED: true, - config_keys::UV_EXECUTABLE_PATH: "", - config_keys::UV_AUTO_SYNC: false - }, - config_keys::TEST_EXPLORER: { - config_keys::TEST_EXPLORER_ENABLED: true, - config_keys::TEST_EXPLORER_FRAMEWORK: "auto", - config_keys::TEST_EXPLORER_PYTEST_PATH: "pytest", - config_keys::TEST_EXPLORER_ARGS: [], - config_keys::TEST_EXPLORER_AUTO_DISCOVER_ON_SAVE: true, - config_keys::TEST_EXPLORER_USE_UV_RUN: true - }, - config_keys::PROFILER: { - config_keys::PROFILER_ENABLED: true, - config_keys::PROFILER_SAMPLE_RATE: 100, - config_keys::PROFILER_INCLUDE_NATIVE: false, - config_keys::PROFILER_LINE_THRESHOLD: 0.01, - config_keys::PROFILER_FUNC_THRESHOLD: 0.02, - config_keys::PROFILER_MAX_DIAGNOSTICS: 20, - config_keys::PROFILER_AUTO_ON_LAUNCH: false, - config_keys::PROFILER_DEFAULT_FORMAT: "speedscope" - }, - config_keys::MEMORY: { - config_keys::MEMORY_TRACEBACK_DEPTH: 25, - config_keys::MEMORY_AUTO_SNAPSHOT_INTERVAL: 0, - config_keys::MEMORY_MAX_DIAGNOSTICS: 10 - } - }) -} - -/// Wrap a config value under the `"basilisk"` root key. -pub fn wrap_config(config: &Value) -> Value { - serde_json::json!({ config_keys::ROOT: config }) -} - -// ── Binary resolution helpers ──────────────────────────────────────────────── - -/// Search for a named variable in a list of `(key, value)` pairs. -pub fn find_env_var<'a>(env: &'a [(String, String)], name: &str) -> Option<&'a str> { - env.iter() - .find(|(key, _)| key == name) - .map(|(_, value)| value.as_str()) -} - -/// Resolve an explicit, user-provided binary override, if any. -/// -/// Precedence: the Zed LSP `binary.path` setting, then the `BASILISK_PATH` -/// environment variable. Returns `None` when neither is set — the signal to -/// fall back to the managed GitHub-release download. -/// -/// There is deliberately **no** filesystem default (e.g. `~/.cargo/bin`): -/// installing the extension alone must be enough to get a working binary, so -/// the absence of an explicit override means "download the matching release -/// asset", never "guess a path that probably does not exist". Implements -/// [ZED-DIST]. -#[must_use] -pub fn resolve_binary_override( - settings_path: Option<&str>, - env_path: Option<&str>, -) -> Option { - settings_path.or(env_path).map(str::to_string) +/// The `(panel title, body)` pair `/basilisk` renders. Implements +/// [WITHDRAWAL-SURFACES]. +pub fn notice_output() -> (String, String) { + (LABEL.to_owned(), NOTICE.to_owned()) } #[cfg(test)] #[path = "logic_tests.rs"] -mod tests; +mod logic_tests; diff --git a/basilisk-zed/src/logic_tests.rs b/basilisk-zed/src/logic_tests.rs index 099c9d30f..3e25aa1dd 100644 --- a/basilisk-zed/src/logic_tests.rs +++ b/basilisk-zed/src/logic_tests.rs @@ -1,1045 +1,113 @@ -//! Tests for [`super::logic`] — pure functions with no Zed API dependency. -#![expect( - clippy::expect_used, - clippy::indexing_slicing, - reason = "Test assertions use expect() and JSON indexing for readability" -)] +//! What this extension may say, and what it must no longer offer. +//! +//! The manifest assertions below are this crate's equivalent of +//! `scripts/verify-vsix-inert.sh`: the packaged artefact is a manifest plus a +//! WASM module, so the manifest is where a language server, a debug adapter, or +//! a feature command would come back. -use basilisk_common::slash_commands; +use super::{notice_output, LABEL, NOTICE}; -use super::*; +/// The manifest exactly as it ships — the registry reads this file. +const MANIFEST: &str = include_str!("../extension.toml"); -/// Run `slash_command_output(cmd, args)`, assert it succeeds with `label`, and -/// return `text` for further assertions. Replaces the repeated `expect`/ -/// `assert_eq!(label, ...)` boilerplate that appears in every slash-command test. -fn run_slash(cmd: &str, args: &[String], expected_label: &str) -> String { - let (label, text) = slash_command_output(cmd, args).expect("should succeed"); - assert_eq!(label, expected_label); - text -} - -/// Run a slash command and return only the body text (label is ignored). -fn slash_text(cmd: &str, args: &[String]) -> String { - slash_command_output(cmd, args).expect("should succeed").1 -} - -/// Every slash command Basilisk advertises, in palette order. -/// -/// Shared by the exhaustive "all commands" tests so the list lives in exactly -/// one place — adding a command here keeps both the non-empty-output and the -/// markdown-shape assertions in sync. -const ALL_SLASH_COMMANDS: [&str; 13] = [ - slash_commands::PROFILE, - slash_commands::PROFSTOP, - slash_commands::PROFSNAPSHOT, - slash_commands::MEMLEAK, - slash_commands::MEMSTOP, - slash_commands::MEMREFS, - slash_commands::MODULES, - slash_commands::SYMBOLS, - slash_commands::HEALTH, - slash_commands::BASILISK, - slash_commands::TESTS, - slash_commands::RUNTESTS, - slash_commands::TESTFILE, -]; - -// ── Slash command output ───────────────────────────────────────────────── -// Exercises [ZED-PROFILE]: every slash command's dispatch and markdown output. - -#[test] -fn profile_without_pid() { - let text = run_slash("profile", &[], "CPU Profiling"); - assert!(text.contains("active Python process")); - assert!(text.contains("py-spy")); - assert!(text.contains("basilisk.profiler.start")); -} - -#[test] -fn profile_with_pid() { - let text = run_slash("profile", &["1234".to_string()], "CPU Profiling"); - assert!(text.contains("PID `1234`")); - assert!(text.contains("Speedscope")); -} - -#[test] -fn profstop_output() { - let text = run_slash("profstop", &[], "Stop Profiling"); - assert!(text.contains("basilisk.profiler.stop")); - assert!(text.contains("flamegraph")); -} - -#[test] -fn profsnapshot_output() { - let text = run_slash("profsnapshot", &[], "Profile Snapshot"); - assert!(text.contains("basilisk.profiler.snapshot")); - assert!(text.contains("continues")); -} - -#[test] -fn memleak_output() { - let text = run_slash("memleak", &[], "Memory Tracking"); - assert!(text.contains("tracemalloc")); - assert!(text.contains("basilisk.memory.start")); -} - -#[test] -fn memstop_output() { - let text = run_slash("memstop", &[], "Memory Report"); - assert!(text.contains("Stops")); - assert!(text.contains("/memrefs")); -} - -#[test] -fn memrefs_with_type() { - let text = run_slash("memrefs", &["DataFrame".to_string()], "Reference Graph"); - assert!(text.contains("DataFrame")); - assert!(text.contains("gc.get_referrers")); -} +/// The one-line copy, from [WITHDRAWAL-COPY-LINE] via the messaging spec. +const ONE_LINE: &str = "Basilisk's type checker produced incorrect results. \ +Basilisk is unlisted and is being rebuilt from the ground up as a new product."; -#[test] -fn memrefs_without_type() { - let text = slash_text("memrefs", &[]); - assert!(text.contains("(unknown)")); +/// Manifest lines that are TOML table headers, with whitespace trimmed. +fn tables() -> Vec<&'static str> { + MANIFEST + .lines() + .map(str::trim) + .filter(|line| line.starts_with('[')) + .collect() } #[test] -fn unknown_command_errors() { - let result = slash_command_output("nonexistent", &[]); - assert!(result.is_err()); - let err = result.expect_err("should be error"); - assert!(err.contains("nonexistent")); +fn slash_command_renders_the_generated_notice_verbatim() { + let (label, text) = notice_output(); + assert_eq!(label, LABEL); + assert_eq!(text, NOTICE, "the panel must show the generated bytes"); } -// ── All slash commands produce non-empty markdown ──────────────────── - #[test] -fn all_slash_commands_produce_output() { - for cmd in ALL_SLASH_COMMANDS { - let (label, text) = slash_command_output(cmd, &[]).expect(cmd); - assert!(!label.is_empty(), "empty label for {cmd}"); - assert!(!text.is_empty(), "empty text for {cmd}"); - } -} - -#[test] -fn slash_output_is_markdown() { - for cmd in ALL_SLASH_COMMANDS { - let (_, text) = slash_command_output(cmd, &[]).expect(cmd); +fn the_notice_carries_every_required_fact() { + for required in [ + "incorrect results", + "https://github.com/python/typing/pull/2330", + "inert and checks nothing", + "Remove Basilisk from your pipeline", + "rebuilding from the ground up", + "https://www.christianfindlay.com/blog/basilisk-conformance-apology", + ] { assert!( - text.contains("##"), - "slash command {cmd} should produce markdown with headers" + NOTICE.contains(required), + "the notice must say {required:?}" ); } } -// ── Slash command completions ──────────────────────────────────────── - -#[test] -fn profile_completions() { - let completions = slash_completions("profile"); - assert_eq!(completions.len(), 1); - assert_eq!(completions[0].0, ""); - assert!(!completions[0].2, "run_command should be false"); -} - -#[test] -fn memrefs_completions() { - let completions = slash_completions("memrefs"); - assert_eq!(completions.len(), 6); - let labels: Vec<&str> = completions.iter().map(|(l, _, _)| l.as_str()).collect(); - assert!(labels.contains(&"DataFrame")); - assert!(labels.contains(&"dict")); - assert!(labels.contains(&"Tensor")); - for (_, _, run) in &completions { - assert!(run, "memrefs completions should have run_command = true"); - } -} - -#[test] -fn unknown_command_has_no_completions() { - assert!(slash_completions("unknown").is_empty()); -} - -// ── Profiler slash command content quality ─────────────────────────── - -#[test] -fn profile_output_documents_all_four_commands() { - let text = slash_text("profile", &[]); - assert!( - text.contains("basilisk.profiler.start"), - "must document start command" - ); - assert!( - text.contains("basilisk.profiler.stop"), - "must document stop command" - ); - assert!( - text.contains("basilisk.profiler.snapshot"), - "must document snapshot command" - ); - assert!( - text.contains("basilisk.profiler.list"), - "must document list command" - ); -} - -#[test] -fn profile_output_documents_output_formats() { - let text = slash_text("profile", &[]); - assert!( - text.contains("Speedscope"), - "must mention speedscope format" - ); - assert!( - text.contains("Flamegraph") || text.contains("flamegraph"), - "must mention flamegraph" - ); - assert!( - text.contains("BSK-PROF") || text.contains("diagnostics"), - "must mention diagnostics output" - ); -} - -#[test] -fn profstop_output_documents_output_format_options() { - let text = slash_text("profstop", &[]); - assert!( - text.contains("speedscope"), - "must mention speedscope format option" - ); - assert!( - text.contains("flamegraph"), - "must mention flamegraph format option" - ); - assert!( - text.contains("summary"), - "must mention summary format option" - ); -} - -#[test] -fn profstop_output_documents_diagnostic_delivery() { - let text = slash_text("profstop", &[]); - assert!( - text.contains("publishDiagnostics") - || text.contains("diagnostics") - || text.contains("hints"), - "must explain how diagnostics are delivered" - ); - assert!( - text.contains("threshold") || text.contains("1%") || text.contains("2%"), - "should mention threshold behavior" - ); -} - -#[test] -fn memleak_output_documents_tracemalloc_and_commands() { - let text = slash_text("memleak", &[]); - assert!( - text.contains("tracemalloc"), - "must mention tracemalloc engine" - ); - assert!( - text.contains("basilisk.memory.start"), - "must document start command" - ); - assert!( - text.contains("debug session") || text.contains("debugpy"), - "must mention debug session requirement" - ); -} - -#[test] -fn memleak_output_documents_diagnostic_codes() { - let text = slash_text("memleak", &[]); - assert!( - text.contains("BSK-MEM") || text.contains("memory diagnostics"), - "must mention memory diagnostic codes or diagnostics" - ); -} - #[test] -fn memstop_output_documents_leak_detection() { - let text = slash_text("memstop", &[]); - assert!( - text.contains("leak") || text.contains("Leak"), - "must mention leak detection" - ); - assert!( - text.contains("confidence") || text.contains("snapshot"), - "should mention confidence scoring or snapshots" - ); -} - -#[test] -fn memrefs_output_documents_reference_graph() { - let args = vec!["DataFrame".to_string()]; - let text = slash_text("memrefs", &args); - assert!(text.contains("DataFrame"), "must include the target type"); - assert!( - text.contains("gc.get_referrers") - || text.contains("reference") - || text.contains("retention"), - "must explain reference graph walking" - ); - assert!( - text.contains("Cycle") - || text.contains("cycle") - || text.contains("Retention") - || text.contains("retention"), - "should explain what the graph reveals" - ); -} - -#[test] -fn profile_with_pid_includes_pid_in_output() { - for pid in ["1234", "99999", "1"] { - let args = vec![pid.to_string()]; - let text = slash_text("profile", &args); - assert!( - text.contains(pid), - "output must include PID {pid} when provided" - ); - } -} - -#[test] -fn all_profiler_commands_have_markdown_tables_or_lists() { - let profiler_cmds = [ - slash_commands::PROFILE, - slash_commands::PROFSTOP, - slash_commands::MEMLEAK, - ]; - for cmd in profiler_cmds { - let (_, text) = slash_command_output(cmd, &[]).expect(cmd); - let has_table = text.contains('|'); - let has_list = text.contains("- ") || text.contains("1."); - assert!( - has_table || has_list, - "slash command {cmd} should have structured content (table or list)" - ); - } -} - -#[test] -fn profiler_slash_commands_dont_contain_raw_code_paths() { - let cmds = [ - slash_commands::PROFILE, - slash_commands::PROFSTOP, - slash_commands::PROFSNAPSHOT, - slash_commands::MEMLEAK, - slash_commands::MEMSTOP, - slash_commands::MEMREFS, - ]; - for cmd in cmds { - let (_, text) = slash_command_output(cmd, &[]).expect(cmd); +fn the_manifest_registers_no_language_server() { + // A [language_servers.*] table is what made Zed launch `basilisk lsp`. The + // binary is inert and starts no server, so advertising one would leave + // users staring at a server that fails to start instead of the statement. + for table in tables() { assert!( - !text.contains("crates/"), - "slash command {cmd} should not expose internal code paths" - ); - assert!( - !text.contains(".rs"), - "slash command {cmd} should not reference Rust source files" + !table.starts_with("[language_servers"), + "the extension must not register a language server: {table}" ); } } -// ── DAP config building ───────────────────────────────────────────── -// Exercises [ZED-DAP]: launch/attach config building, request-kind detection, -// and scenario builders matching basilisk-debug.json. - -#[test] -fn build_dap_config_defaults() { - let config = build_dap_config(&serde_json::json!({})); - assert_eq!(config["program"], ""); - assert_eq!(config["python"], "python3"); - assert_eq!(config["justMyCode"], true); - assert_eq!(config["stopOnEntry"], false); - assert_eq!(config["console"], "integratedTerminal"); - assert!(config["args"].is_array()); -} - -#[test] -fn build_dap_config_with_values() { - let input = serde_json::json!({ - "program": "main.py", - "python": "/usr/bin/python3.12", - "justMyCode": false, - "stopOnEntry": true, - "console": "internalConsole", - "args": ["--verbose"], - "cwd": "/home/user/project", - }); - let config = build_dap_config(&input); - assert_eq!(config["program"], "main.py"); - assert_eq!(config["python"], "/usr/bin/python3.12"); - assert_eq!(config["justMyCode"], false); - assert_eq!(config["stopOnEntry"], true); - assert_eq!(config["console"], "internalConsole"); - assert_eq!(config["args"][0], "--verbose"); - assert_eq!(config["cwd"], "/home/user/project"); -} - -// ── DAP request kind ──────────────────────────────────────────────── - -#[test] -fn launch_by_default() { - assert!(!is_attach_request(&serde_json::json!({})).expect("should succeed")); -} - -#[test] -fn launch_explicit() { - let config = serde_json::json!({"request": "launch"}); - assert!(!is_attach_request(&config).expect("should succeed")); -} - -#[test] -fn attach_by_process_id() { - let config = serde_json::json!({"processId": 42}); - assert!(is_attach_request(&config).expect("should succeed")); -} - -#[test] -fn attach_explicit() { - let config = serde_json::json!({"request": "attach"}); - assert!(is_attach_request(&config).expect("should succeed")); -} - -#[test] -fn attach_process_id_takes_precedence() { - let config = serde_json::json!({"processId": 42, "request": "launch"}); - assert!( - is_attach_request(&config).expect("should succeed"), - "processId should override request field" - ); -} - -#[test] -fn unknown_request_kind_errors() { - let config = serde_json::json!({"request": "restart"}); - let message = - is_attach_request(&config).expect_err("only launch and attach are known request kinds"); - assert_eq!(message, "Unknown request kind: restart"); -} - -// ── DAP scenario builders ─────────────────────────────────────────── - -#[test] -fn launch_scenario_fields() { - let scenario = - build_launch_scenario("app.py", &["--debug".to_string()], Some("/project"), true); - assert_eq!(scenario["program"], "app.py"); - assert_eq!(scenario["args"][0], "--debug"); - assert_eq!(scenario["cwd"], "/project"); - assert_eq!(scenario["stopOnEntry"], true); - assert_eq!(scenario["justMyCode"], true); - assert_eq!(scenario["console"], "integratedTerminal"); -} - -#[test] -fn launch_scenario_no_cwd() { - let scenario = build_launch_scenario("app.py", &[], None, false); - assert!(scenario["cwd"].is_null()); - assert_eq!(scenario["stopOnEntry"], false); -} - -#[test] -fn attach_scenario_with_pid() { - let scenario = build_attach_scenario(Some(9876)); - assert_eq!(scenario["processId"], 9876); - assert_eq!(scenario["request"], "attach"); -} - -#[test] -fn attach_scenario_no_pid() { - let scenario = build_attach_scenario(None); - assert!(scenario["processId"].is_null()); - assert_eq!(scenario["request"], "attach"); -} - -// ── Workspace configuration ───────────────────────────────────────── -// Exercises [ZED-CONFIG]: default config + wrapping under the "basilisk" key. - -#[test] -fn default_config_has_inlay_hints() { - let config = default_workspace_config(); - assert_eq!(config["inlayHints"]["parameterNames"], true); - assert_eq!(config["inlayHints"]["variableTypes"], true); -} - -#[test] -fn default_config_has_ruff_enabled() { - let config = default_workspace_config(); - assert_eq!(config["ruff"]["enabled"], true); -} - -#[test] -fn default_config_has_uv_settings() { - let config = default_workspace_config(); - assert_eq!(config["uv"]["enabled"], true); - assert_eq!(config["uv"]["executablePath"], ""); - assert_eq!(config["uv"]["autoSync"], false); - assert!(config["uv"].get("stubSuggestions").is_none()); - assert!(config["uv"].get("dependencyDiagnostics").is_none()); -} - -#[test] -fn wrap_config_preserves_uv_settings() { - let inner = serde_json::json!({ - "uv": { - "enabled": false, - "executablePath": "/usr/local/bin/uv", - "autoSync": true - } - }); - let wrapped = wrap_config(&inner); - assert_eq!(wrapped["basilisk"]["uv"]["enabled"], false); - assert_eq!( - wrapped["basilisk"]["uv"]["executablePath"], - "/usr/local/bin/uv" - ); - assert_eq!(wrapped["basilisk"]["uv"]["autoSync"], true); -} - -#[test] -fn wrap_config_nests_under_basilisk() { - let inner = serde_json::json!({"foo": "bar"}); - let wrapped = wrap_config(&inner); - assert_eq!(wrapped["basilisk"]["foo"], "bar"); -} - -// ── Binary resolution helpers ─────────────────────────────────────── - -#[test] -fn find_env_var_present() { - let env = vec![ - ("HOME".to_string(), "/home/user".to_string()), - ("PATH".to_string(), "/usr/bin".to_string()), - ]; - assert_eq!(find_env_var(&env, "HOME"), Some("/home/user")); -} - -#[test] -fn find_env_var_absent() { - let env = vec![("HOME".to_string(), "/home/user".to_string())]; - assert_eq!(find_env_var(&env, "BASILISK_PATH"), None); -} - -#[test] -fn find_env_var_empty_list() { - let env: Vec<(String, String)> = vec![]; - assert_eq!(find_env_var(&env, "HOME"), None); -} - -#[test] -fn binary_override_prefers_settings_path() { - assert_eq!( - resolve_binary_override(Some("/custom/basilisk"), Some("/env/basilisk")), - Some("/custom/basilisk".to_string()) - ); -} - -#[test] -fn binary_override_falls_back_to_env() { - assert_eq!( - resolve_binary_override(None, Some("/env/basilisk")), - Some("/env/basilisk".to_string()) - ); -} - -#[test] -fn binary_override_none_triggers_download() { - // No explicit override -> None, which signals the managed GitHub-release - // download. There is no `~/.cargo/bin` (or any) filesystem default, so - // installing the extension alone is enough. Guards [ZED-DIST]. - assert_eq!(resolve_binary_override(None, None), None); -} - -// ── Version check ─────────────────────────────────────────────────── - -#[test] -fn newer_major() { - assert!(is_newer_version("0.1.0", "1.0.0")); -} - -#[test] -fn newer_minor() { - assert!(is_newer_version("0.1.0", "0.2.0")); -} - -#[test] -fn newer_patch() { - assert!(is_newer_version("0.1.0", "0.1.1")); -} - -#[test] -fn same_version() { - assert!(!is_newer_version("0.1.0", "0.1.0")); -} - -#[test] -fn older_version() { - assert!(!is_newer_version("1.0.0", "0.9.0")); -} - -#[test] -fn v_prefix_stripped() { - assert!(is_newer_version("v0.1.0", "v0.2.0")); - assert!(is_newer_version("0.1.0", "v0.2.0")); - assert!(is_newer_version("v0.1.0", "0.2.0")); -} - -#[test] -fn v_prefix_same() { - assert!(!is_newer_version("v0.1.0", "v0.1.0")); -} - -// ── Test slash commands ───────────────────────────────────────────── - -#[test] -fn tests_discovery_workspace() { - let text = run_slash("tests", &[], "Test Discovery"); - assert!(text.contains("workspace")); - assert!(text.contains("pytest")); - assert!(text.contains("unittest")); - assert!(text.contains("def test_*")); -} - -#[test] -fn tests_discovery_file() { - let text = run_slash("tests", &["test_api.py".to_string()], "Test Discovery"); - assert!(text.contains("test_api.py")); -} - -#[test] -fn runtests_all() { - let text = run_slash("runtests", &[], "Running Tests"); - assert!(text.contains("all tests")); - assert!(text.contains("pytest")); - assert!(text.contains("uv run pytest")); -} - -#[test] -fn runtests_specific() { - let text = run_slash( - "runtests", - &["tests/test_api.py::test_login".to_string()], - "Running Tests", - ); - assert!(text.contains("test_login")); -} - -#[test] -fn testfile_default() { - let text = run_slash("testfile", &[], "File Tests"); - assert!(text.contains("current file")); - assert!(text.contains("uv run pytest")); -} - -#[test] -fn testfile_specific() { - let text = run_slash("testfile", &["test_models.py".to_string()], "File Tests"); - assert!(text.contains("test_models.py")); -} - -#[test] -fn runtests_completions() { - let completions = slash_completions("runtests"); - assert_eq!(completions.len(), 1); - assert_eq!(completions[0].0, ""); - assert!(!completions[0].2, "run_command should be false"); -} - -#[test] -fn testfile_completions() { - let completions = slash_completions("testfile"); - assert_eq!(completions.len(), 1); - assert_eq!(completions[0].0, ""); -} - -#[test] -fn tests_no_completions() { - let completions = slash_completions("tests"); - assert!(completions.is_empty()); -} - -// ── Test explorer workspace config ────────────────────────────────── - -#[test] -fn default_config_has_test_explorer() { - let config = default_workspace_config(); - assert_eq!(config["testExplorer"]["enabled"], true); - assert_eq!(config["testExplorer"]["framework"], "auto"); - assert_eq!(config["testExplorer"]["pytestPath"], "pytest"); - assert!(config["testExplorer"]["args"].is_array()); - assert_eq!(config["testExplorer"]["autoDiscoverOnSave"], true); - assert_eq!(config["testExplorer"]["useUvRun"], true); -} - #[test] -fn wrap_config_preserves_test_explorer() { - let inner = serde_json::json!({ - "testExplorer": { - "enabled": false, - "framework": "unittest", - "pytestPath": "/usr/bin/pytest", - "useUvRun": false +fn the_manifest_registers_no_debug_adapter_or_grammar() { + for table in tables() { + for forbidden in ["[debug_adapters", "[grammars", "[languages"] { + assert!( + !table.starts_with(forbidden), + "the extension must not register {forbidden}...: {table}" + ); } - }); - let wrapped = wrap_config(&inner); - assert_eq!(wrapped["basilisk"]["testExplorer"]["enabled"], false); - assert_eq!(wrapped["basilisk"]["testExplorer"]["framework"], "unittest"); - assert_eq!( - wrapped["basilisk"]["testExplorer"]["pytestPath"], - "/usr/bin/pytest" - ); - assert_eq!(wrapped["basilisk"]["testExplorer"]["useUvRun"], false); -} - -// ── Activity panel slash command tests ──────────────────────────── - -#[test] -fn modules_workspace_scope() { - let text = run_slash(slash_commands::MODULES, &[], "Workspace Modules"); - assert!(text.contains("entire workspace")); - assert!(text.contains("basilisk.workspaceModules")); -} - -#[test] -fn modules_prefix_scope() { - let text = run_slash( - slash_commands::MODULES, - &["myapp.api".to_string()], - "Workspace Modules", - ); - assert!(text.contains("prefix `myapp.api`")); -} - -#[test] -fn symbols_output() { - let text = run_slash( - slash_commands::SYMBOLS, - &["myapp.models".to_string()], - "Module Symbols", - ); - assert!(text.contains("myapp.models")); - assert!(text.contains("basilisk.workspaceModules")); -} - -#[test] -fn symbols_default() { - let text = run_slash(slash_commands::SYMBOLS, &[], "Module Symbols"); - assert!(text.contains("all modules")); -} - -#[test] -fn health_output() { - let text = run_slash(slash_commands::HEALTH, &[], "Type Health"); - assert!(text.contains("basilisk.typeHealth")); - assert!(text.contains("Coverage")); - assert!(text.contains("Module")); -} - -#[test] -fn basilisk_info_output() { - let text = run_slash(slash_commands::BASILISK, &[], "Basilisk Info"); - assert!(text.contains("strict-by-default")); - assert!(text.contains("/modules")); - assert!(text.contains("/health")); - assert!(text.contains("basilisk-python.dev")); -} - -#[test] -fn modules_completions() { - let completions = slash_completions(slash_commands::MODULES); - assert_eq!(completions.len(), 1); - assert_eq!(completions[0].0, ""); -} - -#[test] -fn symbols_completions() { - let completions = slash_completions(slash_commands::SYMBOLS); - assert_eq!(completions.len(), 1); - assert_eq!(completions[0].0, ""); -} - -#[test] -fn health_no_completions() { - let completions = slash_completions(slash_commands::HEALTH); - assert!(completions.is_empty()); -} - -#[test] -fn basilisk_no_completions() { - let completions = slash_completions(slash_commands::BASILISK); - assert!(completions.is_empty()); -} - -// ── Profiler workspace config defaults ────────────────────────────── - -#[test] -fn default_config_has_profiler_section() { - let config = default_workspace_config(); - assert!( - !config["profiler"].is_null(), - "profiler section must be present in default config" - ); -} - -#[test] -fn default_config_profiler_enabled_by_default() { - let config = default_workspace_config(); - assert_eq!( - config["profiler"]["enabled"], true, - "profiler must be enabled by default" - ); -} - -#[test] -fn default_config_profiler_sample_rate() { - let config = default_workspace_config(); - assert_eq!( - config["profiler"]["sampleRate"], 100, - "default sample rate must be 100 Hz" - ); -} - -#[test] -fn default_config_profiler_native_frames_disabled() { - let config = default_workspace_config(); - assert_eq!( - config["profiler"]["includeNative"], false, - "native frames must be off by default (low overhead)" - ); -} - -#[test] -fn default_config_profiler_thresholds() { - let config = default_workspace_config(); - let line = config["profiler"]["lineThreshold"] - .as_f64() - .expect("lineThreshold must be a number"); - let func = config["profiler"]["funcThreshold"] - .as_f64() - .expect("funcThreshold must be a number"); - assert!( - (line - 0.01).abs() < f64::EPSILON, - "line threshold must be 1%" - ); - assert!( - (func - 0.02).abs() < f64::EPSILON, - "function threshold must be 2%" - ); -} - -#[test] -fn default_config_profiler_max_diagnostics() { - let config = default_workspace_config(); - assert_eq!( - config["profiler"]["maxDiagnostics"], 20, - "max diagnostics per file must be 20" - ); -} - -#[test] -fn default_config_profiler_auto_on_launch_disabled() { - let config = default_workspace_config(); - assert_eq!( - config["profiler"]["autoOnLaunch"], false, - "auto-profile on launch must be opt-in" - ); + } } #[test] -fn default_config_profiler_default_format_is_speedscope() { - let config = default_workspace_config(); +fn the_manifest_offers_exactly_one_slash_command() { + let commands: Vec<&str> = tables() + .into_iter() + .filter(|table| table.starts_with("[slash_commands.")) + .collect(); assert_eq!( - config["profiler"]["defaultFormat"], "speedscope", - "default export format must be speedscope" + commands, + vec!["[slash_commands.basilisk]"], + "the profiling, memory and test commands are gone; only the statement remains" ); } #[test] -fn default_config_has_memory_section() { - let config = default_workspace_config(); +fn the_manifest_description_is_the_approved_one_line_copy() { assert!( - !config["memory"].is_null(), - "memory section must be present in default config" - ); -} - -#[test] -fn default_config_memory_traceback_depth() { - let config = default_workspace_config(); - assert_eq!( - config["memory"]["tracebackDepth"], 25, - "traceback depth must default to 25 frames" - ); -} - -#[test] -fn default_config_memory_auto_snapshot_disabled() { - let config = default_workspace_config(); - assert_eq!( - config["memory"]["autoSnapshotInterval"], 0, - "auto-snapshot must be disabled by default" - ); -} - -#[test] -fn default_config_memory_max_diagnostics() { - let config = default_workspace_config(); - assert_eq!( - config["memory"]["maxDiagnostics"], 10, - "max memory diagnostics per file must be 10" - ); -} - -#[test] -fn wrap_config_preserves_profiler_settings() { - let inner = serde_json::json!({ - "profiler": { - "enabled": false, - "sampleRate": 200, - "includeNative": true, - "autoOnLaunch": true, - "defaultFormat": "flamegraph" - } - }); - let wrapped = wrap_config(&inner); - assert_eq!(wrapped["basilisk"]["profiler"]["enabled"], false); - assert_eq!(wrapped["basilisk"]["profiler"]["sampleRate"], 200); - assert_eq!(wrapped["basilisk"]["profiler"]["includeNative"], true); - assert_eq!(wrapped["basilisk"]["profiler"]["autoOnLaunch"], true); - assert_eq!( - wrapped["basilisk"]["profiler"]["defaultFormat"], - "flamegraph" + MANIFEST.contains(&format!("description = \"{ONE_LINE}\"")), + "the registry listing must carry the approved one-line copy" ); } #[test] -fn wrap_config_preserves_memory_settings() { - let inner = serde_json::json!({ - "memory": { - "tracebackDepth": 10, - "autoSnapshotInterval": 30, - "maxDiagnostics": 5 - } - }); - let wrapped = wrap_config(&inner); - assert_eq!(wrapped["basilisk"]["memory"]["tracebackDepth"], 10); - assert_eq!(wrapped["basilisk"]["memory"]["autoSnapshotInterval"], 30); - assert_eq!(wrapped["basilisk"]["memory"]["maxDiagnostics"], 5); -} - -// ── Profile command uses shared format constants ───────────────────── - -#[test] -fn profstop_output_uses_canonical_format_names() { - let text = slash_text("profstop", &[]); - assert!( - text.contains(basilisk_common::profiler_formats::SPEEDSCOPE), - "must use canonical speedscope format name" - ); - assert!( - text.contains(basilisk_common::profiler_formats::FLAMEGRAPH), - "must use canonical flamegraph format name" - ); - assert!( - text.contains(basilisk_common::profiler_formats::SUMMARY), - "must use canonical summary format name" - ); -} - -#[test] -fn profile_output_documents_presets() { - let text = slash_text("profile", &[]); - // Every preset the server parses must be documented — and only those - // (a documented-but-ignored preset was the old "memory" defect). - for preset in basilisk_common::profiler_presets::ALL { - assert!(text.contains(preset), "must document the {preset} preset"); +fn the_extension_source_cannot_fetch_or_spawn_anything() { + // Regression guard on the glue layer: these are the API calls that used to + // download a release binary and hand Zed a command to run. + let glue = include_str!("lib.rs"); + for forbidden in [ + "latest_github_release", + "download_file", + "make_file_executable", + "language_server_command", + "get_dap_binary", + ] { + assert!( + !glue.contains(forbidden), + "the extension must not call {forbidden}" + ); } - assert!( - !text.contains("lightweight") && !text.contains("`memory`"), - "must not document presets the server does not parse" - ); -} - -#[test] -fn memleak_output_uses_canonical_command_names() { - let text = slash_text("memleak", &[]); - assert!( - text.contains(basilisk_common::commands::MEMORY_START), - "must use canonical memory start command" - ); - assert!( - text.contains(basilisk_common::commands::MEMORY_SNAPSHOT), - "must use canonical memory snapshot command" - ); - assert!( - text.contains(basilisk_common::commands::MEMORY_DIFF), - "must use canonical memory diff command" - ); - assert!( - text.contains(basilisk_common::commands::MEMORY_REFERENCES), - "must use canonical memory references command" - ); - assert!( - text.contains(basilisk_common::commands::MEMORY_GC_COLLECT), - "must use canonical gc collect command" - ); -} - -#[test] -fn memleak_output_uses_canonical_diagnostic_codes() { - let text = slash_text("memleak", &[]); - assert!( - text.contains(basilisk_common::memory_diagnostics::ALLOC), - "must use canonical BSK-MEM-ALLOC code" - ); - assert!( - text.contains(basilisk_common::memory_diagnostics::GROWTH), - "must use canonical BSK-MEM-GROWTH code" - ); - assert!( - text.contains(basilisk_common::memory_diagnostics::LEAK), - "must use canonical BSK-MEM-LEAK code" - ); - assert!( - text.contains(basilisk_common::memory_diagnostics::CYCLE), - "must use canonical BSK-MEM-CYCLE code" - ); -} - -#[test] -fn profile_output_uses_canonical_notification_name() { - let text = slash_text("profile", &[]); - assert!( - text.contains(basilisk_common::notifications::PROFILER_PROGRESS), - "must reference canonical profiler progress notification" - ); -} - -#[test] -fn memleak_output_uses_canonical_timeline_notification() { - let text = slash_text("memleak", &[]); - assert!( - text.contains(basilisk_common::notifications::MEMORY_TIMELINE), - "must reference canonical memory timeline notification" - ); -} - -#[test] -fn memrefs_output_uses_canonical_command_and_diagnostic() { - let args = vec!["MyModel".to_string()]; - let text = slash_text("memrefs", &args); - assert!( - text.contains(basilisk_common::commands::MEMORY_REFERENCES), - "must use canonical memory references command" - ); - assert!( - text.contains(basilisk_common::memory_diagnostics::CYCLE), - "must use canonical BSK-MEM-CYCLE diagnostic code" - ); } diff --git a/basilisk-zed/src/withdrawal_notice.txt b/basilisk-zed/src/withdrawal_notice.txt new file mode 100644 index 000000000..b1859fcb2 --- /dev/null +++ b/basilisk-zed/src/withdrawal_notice.txt @@ -0,0 +1,9 @@ +Basilisk is unlisted. Its type checker is inert and checks nothing. + +Basilisk's type checker was producing incorrect results. The code responsible is not isolated to a known set of rules, so we cannot say how many rules are affected. We asked for Basilisk to be removed from the python/typing conformance results, and it has been removed: https://github.com/python/typing/pull/2330 + +A code-quality tool that does not produce correct results is worse than useless. Remove Basilisk from your pipeline, your pre-commit hooks, and your editor. This command failed on purpose. It is not a finding about your code. + +We are not fixing this code. We are rebuilding from the ground up as a new product, shipping only what can be trusted. If type checking ever returns, it will be externally audited before release. + +A full public account: https://www.christianfindlay.com/blog/basilisk-conformance-apology diff --git a/basilisk-zed/tests/fixtures/clean.py b/basilisk-zed/tests/fixtures/clean.py deleted file mode 100644 index 1e21eeb78..000000000 --- a/basilisk-zed/tests/fixtures/clean.py +++ /dev/null @@ -1,2 +0,0 @@ -def greet(name: str) -> str: - return f"Hello, {name}!" diff --git a/basilisk-zed/tests/fixtures/completions.py b/basilisk-zed/tests/fixtures/completions.py deleted file mode 100644 index baddfad4a..000000000 --- a/basilisk-zed/tests/fixtures/completions.py +++ /dev/null @@ -1,9 +0,0 @@ -class MyClass: - def method_one(self) -> str: - return "one" - - def method_two(self) -> int: - return 2 - -obj = MyClass() -obj. diff --git a/basilisk-zed/tests/fixtures/type_error.py b/basilisk-zed/tests/fixtures/type_error.py deleted file mode 100644 index 097a41c21..000000000 --- a/basilisk-zed/tests/fixtures/type_error.py +++ /dev/null @@ -1,2 +0,0 @@ -def greet(name): - return f"Hello, {name}!" diff --git a/basilisk-zed/themes/basilisk-dark.json b/basilisk-zed/themes/basilisk-dark.json deleted file mode 100644 index 4f0581bf3..000000000 --- a/basilisk-zed/themes/basilisk-dark.json +++ /dev/null @@ -1,281 +0,0 @@ -{ - "$schema": "https://zed.dev/schema/themes/v0.2.0.json", - "name": "Basilisk", - "author": "Basilisk Contributors", - "themes": [ - { - "name": "Basilisk Dark", - "appearance": "dark", - "style": { - "background": "#0e1117ff", - "border": "#1e2633ff", - "border.variant": "#283040ff", - "border.focused": "#3a7d5aff", - "border.selected": "#3a7d5aff", - "border.transparent": "#00000000", - "border.disabled": "#1a2029ff", - - "text": "#d4dce8ff", - "text.muted": "#8892a2ff", - "text.placeholder": "#5c6678ff", - "text.disabled": "#3e4859ff", - "text.accent": "#4db87fff", - - "icon": "#d4dce8ff", - "icon.muted": "#8892a2ff", - "icon.disabled": "#3e4859ff", - "icon.placeholder": "#5c6678ff", - "icon.accent": "#4db87fff", - - "element.background": "#161b24ff", - "element.hover": "#1c2230ff", - "element.active": "#222a38ff", - "element.selected": "#1c3a2eff", - "element.disabled": "#12161dff", - - "ghost_element.background": "#00000000", - "ghost_element.hover": "#1c2230ff", - "ghost_element.active": "#222a38ff", - "ghost_element.selected": "#1c3a2eff", - "ghost_element.disabled": "#12161dff", - - "surface.background": "#121820ff", - "elevated_surface.background": "#161b24ff", - "panel.background": "#0e1117ff", - - "status_bar.background": "#0b0f14ff", - "title_bar.background": "#0b0f14ff", - "title_bar.inactive_background": "#0e1117ff", - "toolbar.background": "#0e1117ff", - "tab_bar.background": "#0b0f14ff", - "tab.active_background": "#0e1117ff", - "tab.inactive_background": "#0b0f14ff", - - "search.match_background": "#3a7d5a44", - "drop_target.background": "#3a7d5a33", - - "editor.foreground": "#d4dce8ff", - "editor.background": "#0e1117ff", - "editor.gutter.background": "#0e1117ff", - "editor.active_line.background": "#141a22ff", - "editor.highlighted_line.background": "#1c2230ff", - "editor.line_number": "#3e4859ff", - "editor.active_line_number": "#8892a2ff", - "editor.invisible": "#283040ff", - "editor.wrap_guide": "#1a2029ff", - "editor.active_wrap_guide": "#283040ff", - "editor.document_highlight.read_background": "#3a7d5a22", - "editor.document_highlight.write_background": "#3a7d5a33", - "editor.indent_guide": "#1a2029ff", - "editor.indent_guide_active": "#283040ff", - - "scrollbar.thumb.background": "#283040aa", - "scrollbar.thumb.hover_background": "#3e4859cc", - "scrollbar.thumb.border": "#00000000", - "scrollbar.track.background": "#00000000", - "scrollbar.track.border": "#00000000", - - "error": "#e05561ff", - "error.background": "#2a1016ff", - "error.border": "#e0556133", - "warning": "#d4a647ff", - "warning.background": "#2a2210ff", - "warning.border": "#d4a64733", - "success": "#4db87fff", - "success.background": "#0e2a1aff", - "success.border": "#4db87f33", - "info": "#5ba0d6ff", - "info.background": "#0e1a2aff", - "info.border": "#5ba0d633", - "hint": "#8892a2ff", - "hint.background": "#1a2029ff", - "hint.border": "#8892a233", - - "conflict": "#d48547ff", - "created": "#4db87fff", - "deleted": "#e05561ff", - "modified": "#5ba0d6ff", - "renamed": "#d4a647ff", - "ignored": "#3e4859ff", - "hidden": "#3e4859ff", - "predictive": "#5c6678ff", - "unreachable": "#3e4859ff", - - "version_control.added": "#4db87fff", - "version_control.modified": "#5ba0d6ff", - "version_control.deleted": "#e05561ff", - - "link_text.hover": "#4db87fff", - - "players": [ - { - "cursor": "#4db87fff", - "background": "#4db87f33", - "selection": "#4db87f22" - }, - { - "cursor": "#5ba0d6ff", - "background": "#5ba0d633", - "selection": "#5ba0d622" - }, - { - "cursor": "#d4a647ff", - "background": "#d4a64733", - "selection": "#d4a64722" - }, - { - "cursor": "#d48547ff", - "background": "#d4854733", - "selection": "#d4854722" - } - ], - - "syntax": { - "attribute": { - "color": "#d4a647ff", - "font_style": "italic" - }, - "boolean": { - "color": "#d48547ff" - }, - "comment": { - "color": "#5c6678ff", - "font_style": "italic" - }, - "comment.doc": { - "color": "#6b7a8dff", - "font_style": "italic" - }, - "constant": { - "color": "#d48547ff" - }, - "constructor": { - "color": "#4db87fff" - }, - "emphasis": { - "font_style": "italic" - }, - "emphasis.strong": { - "font_weight": 700 - }, - "enum": { - "color": "#4db87fff" - }, - "function": { - "color": "#5ba0d6ff" - }, - "hint": { - "color": "#5c6678ff" - }, - "keyword": { - "color": "#c678ddff" - }, - "label": { - "color": "#d4a647ff" - }, - "link_text": { - "color": "#4db87fff" - }, - "link_uri": { - "color": "#5ba0d6ff" - }, - "namespace": { - "color": "#4db87fff" - }, - "number": { - "color": "#d48547ff" - }, - "operator": { - "color": "#8892a2ff" - }, - "predictive": { - "color": "#5c6678ff", - "font_style": "italic" - }, - "primary": { - "color": "#d4dce8ff" - }, - "property": { - "color": "#e06c75ff" - }, - "punctuation": { - "color": "#8892a2ff" - }, - "punctuation.bracket": { - "color": "#8892a2ff" - }, - "punctuation.delimiter": { - "color": "#8892a2ff" - }, - "string": { - "color": "#98c379ff" - }, - "string.escape": { - "color": "#56b6c2ff" - }, - "string.regex": { - "color": "#56b6c2ff" - }, - "string.special": { - "color": "#d48547ff" - }, - "tag": { - "color": "#e06c75ff" - }, - "text.literal": { - "color": "#98c379ff" - }, - "title": { - "color": "#5ba0d6ff", - "font_weight": 700 - }, - "type": { - "color": "#4db87fff" - }, - "type.interface": { - "color": "#4db87fff", - "font_style": "italic" - }, - "variable": { - "color": "#d4dce8ff" - }, - "variable.member": { - "color": "#e06c75ff" - }, - "variable.parameter": { - "color": "#d4dce8ff", - "font_style": "italic" - }, - "variable.special": { - "color": "#e06c75ff", - "font_style": "italic" - }, - "variant": { - "color": "#4db87fff" - } - }, - - "terminal.background": "#0e1117ff", - "terminal.foreground": "#d4dce8ff", - "terminal.bright_foreground": "#e8ecf2ff", - "terminal.dim_foreground": "#8892a2ff", - "terminal.ansi.black": "#1a2029ff", - "terminal.ansi.red": "#e05561ff", - "terminal.ansi.green": "#4db87fff", - "terminal.ansi.yellow": "#d4a647ff", - "terminal.ansi.blue": "#5ba0d6ff", - "terminal.ansi.magenta": "#c678ddff", - "terminal.ansi.cyan": "#56b6c2ff", - "terminal.ansi.white": "#d4dce8ff", - "terminal.ansi.bright_black": "#5c6678ff", - "terminal.ansi.bright_red": "#e87880ff", - "terminal.ansi.bright_green": "#70d09aff", - "terminal.ansi.bright_yellow": "#e0be6eff", - "terminal.ansi.bright_blue": "#7db8e0ff", - "terminal.ansi.bright_magenta": "#d499e8ff", - "terminal.ansi.bright_cyan": "#7ccad4ff", - "terminal.ansi.bright_white": "#e8ecf2ff" - } - } - ] -} diff --git a/basilisk.nvim/Makefile b/basilisk.nvim/Makefile index 3d6fd8c07..8be02f69e 100644 --- a/basilisk.nvim/Makefile +++ b/basilisk.nvim/Makefile @@ -1,41 +1,13 @@ -.PHONY: test test-ui test-lsp test-dap test-screenshots test-all lint help +.PHONY: test help NVIM ?= nvim -## Run all plenary tests +## Run the plugin specs test: $(NVIM) --headless -u tests/minimal_init.lua \ -c "PlenaryBustedDirectory tests/basilisk {minimal_init = 'tests/minimal_init.lua'}" -## Run UI tests -test-ui: - $(NVIM) --headless -u tests/minimal_init.lua \ - -c "PlenaryBustedDirectory tests/ui {minimal_init = 'tests/minimal_init.lua'}" - -## Run real LSP integration tests (requires basilisk binary) -test-lsp: - $(NVIM) --headless -u tests/minimal_init.lua \ - -c "PlenaryBustedDirectory tests/lsp {minimal_init = 'tests/minimal_init.lua'}" - -## Run DAP debug integration tests (requires basilisk binary + debugpy) -test-dap: - $(NVIM) --headless -u tests/minimal_init.lua \ - -c "PlenaryBustedDirectory tests/dap {minimal_init = 'tests/minimal_init.lua'}" - -## Run screenshot regression tests (requires mini.nvim + basilisk binary) -test-screenshots: - $(NVIM) --headless -u tests/minimal_init.lua \ - -l tests/ui/run_screenshots.lua - -## Run all tests -test-all: test test-ui test-lsp test-dap test-screenshots - ## Show help help: @echo "Available targets:" - @echo " test Run core plenary tests" - @echo " test-ui Run UI tests" - @echo " test-lsp Run real LSP integration tests (rename, refactoring)" - @echo " test-dap Run DAP debug integration tests (breakpoints, stepping)" - @echo " test-screenshots Run screenshot regression tests (mini.test)" - @echo " test-all Run all tests" + @echo " test Run the plugin specs" diff --git a/basilisk.nvim/README.md b/basilisk.nvim/README.md index a43155b43..e33f94b3c 100644 --- a/basilisk.nvim/README.md +++ b/basilisk.nvim/README.md @@ -1,146 +1,38 @@ -

English · 简体中文

- -# basilisk.nvim - -First-class Neovim plugin for Basilisk — zero-config Python type checking, debugging, profiling, and test exploration. - -Basilisk is an open-source Python type checker and language server built in Rust: diagnostics, autocomplete, refactoring, debugging, and profiling, with strictness configured per rule. - -

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

+ +# Basilisk is unlisted -> ## ⚠️ Do not use Basilisk's type checker in your pipeline -> -> **The type checker still contains code that isn't doing real type checking, and it is not yet trustworthy.** Some rules decide from the way code is *spelled* rather than what it means, so they can be wrong in both directions — a false error on correct code, or silence where there is a genuine bug. Don't gate CI on it, and don't read a clean run as a clean codebase. Our former conformance claim and our benchmark figures are withdrawn, and Basilisk was [removed from the official results](https://github.com/python/typing/blob/main/conformance/results/results.html) at our request. -> -> **This was a mistake and a failure to verify.** We published on a green run without ever checking whether our rules survived a semantics-preserving change. Basilisk's author has published a [personal account and apology](https://www.christianfindlay.com/blog/basilisk-conformance-apology). -> -> **We are auditing every rule and deleting the ones that don't hold up** — not rewriting them, not patching them, with a failing test left behind so the gap stays visible. Where a rule can't be made reliable in a straightforward way, we will depend on a different, established type checker rather than ship our own unreliable version of it. -> -> **Basilisk is much more than a type checker.** The language server, refactoring, formatting, debugging, and profiling don't rest on the rules under audit — those are what we are sharpening while it runs, removing anything that could hand you a misleading result. We are doing this to restore trust and turn Basilisk back into a tool you can believe. [Read the correction](https://www.basilisk-python.dev/docs/conformance/). +> **You are reading the `basilisk.nvim` plugin listing.** -## Role in Basilisk +**Basilisk's type checker was producing incorrect results.** Rules decided from the way code was *spelled* rather than what it meant, so they could be wrong in both directions — a false error on correct code, or silence on a real bug. -This is the **Neovim editor integration**. It connects Neovim's built-in LSP client to the Basilisk language server, providing the same feature set as the VS Code extension: real-time diagnostics, hover, go-to-definition, code actions, inlay hints, integrated debugging, and profiling. +**We asked for Basilisk to be removed from the `python/typing` conformance results, and it has been removed** ([python/typing#2330](https://github.com/python/typing/pull/2330)). That score did not demonstrate correctness. -## Features +**We cannot tell you how much of the checker this affects.** The code responsible is not isolated to a known set of rules. We will not estimate. That uncertainty is the reason for everything below. -- **Zero-config setup** — detects the `basilisk` binary and connects automatically -- **Real-time diagnostics** — errors appear inline as you type -- **Go-to-definition, hover, find references** — full LSP navigation -- **Code actions & refactoring** — extract, rename, move, inline -- **Inlay hints** — parameter names and inferred types -- **Integrated debugging** — nvim-dap compatible, F5 to debug -- **Test explorer** — discover and run pytest tests from the editor -- **Python profiling** — py-spy heatmaps directly in the editor -- **Memory leak tracking** — detect leaks during development -- **uv integration** — `uv sync` and `uv add` commands -- **Status line** — LSP status in your status line -- **Health checks** — `:checkhealth basilisk` for diagnostics +**A code-quality tool that does not produce correct results is worse than useless.** Basilisk is being unlisted everywhere it was published — the VS Code Marketplace, Open VSX, the Zed registry, PyPI, the Homebrew tap, and the Scoop bucket — and the type checker is inert. Remove it from your pipeline; it checks nothing, and every invocation fails rather than reporting a clean run. -## Requirements +**We are not fixing Basilisk's type checker code. We are rebuilding from the ground up as a new product.** It will ship only what can be trusted. That most likely will not include type checking. Nothing is relisted until it has been rebuilt from components we can vouch for. If type checking ever returns, it will be externally audited before release. -- Neovim 0.11+ (the plugin uses the built-in `vim.lsp.config` / `vim.lsp.enable` API) -- `curl` (used once, to download the `basilisk` binary — see below) +Basilisk's author has published a full public account: [an apology](https://www.christianfindlay.com/blog/basilisk-conformance-apology). -## Install +## What to do now -Two parts get installed: the **plugin** (this repo, via your plugin manager) and the **`basilisk` binary** (downloaded automatically — you normally never install it yourself). +**Remove Basilisk from your pipeline, your pre-commit hooks, and your editor.** Uninstall the CLI and the extension. -### 1. Install the plugin +The type checker is inert: it checks nothing, and every invocation fails. It prints this statement and exits non-zero, so a build that still calls it fails loudly rather than reporting a clean run. Do not treat that failure as a finding about your code. -
-lazy.nvim +**Treat every result Basilisk gave you as unverified.** A clean run was never evidence that your code was clean, and an error it reported may never have been real. -```lua -{ - "Nimblesite/basilisk.nvim", - ft = "python", - dependencies = { "mfussenegger/nvim-dap" }, -- optional, for debugging - opts = {}, -} -``` -
+Every distribution channel is being unlisted. Nothing will be relisted until it has been rebuilt from components we can vouch for. -
-packer.nvim +## Acknowledgments -```lua -use { - "Nimblesite/basilisk.nvim", - ft = "python", - config = function() - require("basilisk").setup({}) - end, -} -``` -
- -
-vim-plug - -```vim -Plug 'Nimblesite/basilisk.nvim' -``` - -then somewhere after `plug#end()`: - -```lua -lua require("basilisk").setup({}) -``` -
- -
-vim.pack (built-in, Neovim 0.12+) - -```lua -vim.pack.add({ - { src = "https://github.com/Nimblesite/basilisk.nvim", - version = vim.version.range("*") }, -- latest stable tag; or pin "v0.33.0" -}) -require("basilisk").setup({}) -``` -
- -### 2. The binary installs itself - -Open any Python file. If no `basilisk` binary is found, the plugin downloads the latest [GitHub release](https://github.com/Nimblesite/Basilisk/releases) for your platform into Neovim's data directory and starts the LSP — no PATH setup, no manual step. You can also trigger it explicitly with `:BasiliskInstall`. - -Prefer a package manager? The plugin picks up existing installs automatically: - -```sh -# macOS (Apple Silicon) / Linux -brew tap Nimblesite/tap && brew install basilisk - -# Windows -scoop bucket add nimblesite https://github.com/Nimblesite/scoop-bucket -scoop install basilisk - -# anywhere with a Python toolchain -uv tool install basilisk-python - -# anywhere with a Rust toolchain (builds from source) -cargo install --git https://github.com/Nimblesite/Basilisk basilisk-cli -``` - -That's it — diagnostics, hover, completions, formatting, debugging, tests, and profiling all run through this one plugin. Verify with `:checkhealth basilisk`. - -## Updating - -- **Plugin**: update like any other plugin — `:Lazy update` (lazy.nvim), `:PackerSync` (packer), `:PlugUpdate` (vim-plug). -- **Binary**: when a new release is out, the plugin notifies you on startup. Run **`:BasiliskUpdate`** — it confirms, downloads the new version, and restarts the LSP in place. Installs owned by a package manager are never overwritten; the notice tells you to run `brew upgrade basilisk` / `scoop update basilisk` / `cargo install --git https://github.com/Nimblesite/Basilisk basilisk-cli` instead. - -## Configuration - -Zero-config works out of the box: - -```lua -require("basilisk").setup() -``` - -All options (analysis mode, inlay hints, formatter, debugger, test explorer, uv, keymaps…) are documented in [doc/basilisk.txt](doc/basilisk.txt) — `:h basilisk-configuration`. +Basilisk is built on [Ruff](https://github.com/astral-sh/ruff) by [Astral](https://astral.sh/), whose parser, AST, and formatter crates it embeds (MIT), and on standard-library type stubs from [typeshed](https://github.com/python/typeshed) (Apache-2.0, with MIT-licensed parts). Neither project is responsible for how Basilisk used them. Full component list 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). ## License -MIT. +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/basilisk.nvim/README.zh.md b/basilisk.nvim/README.zh.md deleted file mode 100644 index 05920d9e5..000000000 --- a/basilisk.nvim/README.zh.md +++ /dev/null @@ -1,148 +0,0 @@ -

English · 简体中文

- -> 📝 本文档由机器翻译生成,欢迎母语者校对改进。 - -# basilisk.nvim - -为 Basilisk 打造的一流 Neovim 插件 —— 零配置的 Python 类型检查、调试、性能分析与测试探索。 - -Basilisk 是用 Rust 打造的开源 Python 类型检查器与语言服务器:诊断、自动补全、重构、调试与性能分析,严格程度按规则配置。 - -

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

- -> ## ⚠️ 请勿在流水线中使用 Basilisk 的类型检查器 -> -> **类型检查器中仍然存在没有做真正类型检查的代码,它目前还不值得信任。** 有些规则依据的是代码的**写法**而不是含义,因此两个方向上都可能出错 —— 既可能对正确的代码报出虚假错误,也可能对真实的缺陷保持沉默。请不要用它作为 CI 的门禁,也不要把一次干净的运行结果当作代码库是干净的。此前的一致性宣称与基准测试数字均已撤回,并主动请求[从官方结果中移除](https://github.com/python/typing/blob/main/conformance/results/results.html)。 -> -> **这是一个错误、一次验证上的失职。** 我们仅凭一次全绿的运行就发布了结果,却从未检查过我们的规则能否经受住保持语义的改写。Basilisk 作者已发表[个人说明与致歉](https://www.christianfindlay.com/blog/basilisk-conformance-apology)。 -> -> **我们正在逐条审计规则,并删除那些站不住脚的规则** —— 不是重写,也不是打补丁,而是删除,并留下一个失败的测试,让缺口保持可见。如果一条规则无法以直截了当的方式做到可靠,我们会转而依赖另一个成熟的类型检查器,而不是端出我们自己那份不可靠的实现。 -> -> **Basilisk 远不只是一个类型检查器。** 语言服务器、重构、格式化、调试与性能分析都不建立在正在接受审计的规则之上 —— 审计期间,这些正是我们着力打磨的部分,并移除任何可能给出误导性结果的东西。我们这样做,是为了重建信任,把 Basilisk 变回一个你可以信赖的工具。[阅读更正](https://www.basilisk-python.dev/zh/docs/conformance/)。 - -## 在 Basilisk 中的角色 - -这是 **Neovim 编辑器集成**。它将 Neovim 内置的 LSP 客户端连接到 Basilisk 语言服务器,提供与 VS Code 扩展相同的功能集:实时诊断、悬停信息、跳转到定义、代码操作、内嵌提示(inlay hints)、集成调试以及性能分析。 - -## 功能特性 - -- **零配置安装** —— 自动检测 `basilisk` 二进制文件并建立连接 -- **实时诊断** —— 错误在你输入时即时内联显示 -- **跳转到定义、悬停信息、查找引用** —— 完整的 LSP 导航 -- **代码操作与重构** —— 提取、重命名、移动、内联 -- **内嵌提示(inlay hints)** —— 参数名称与推断类型 -- **集成调试** —— 兼容 nvim-dap,按 F5 即可调试 -- **测试浏览器** —— 在编辑器中发现并运行 pytest 测试 -- **Python 性能分析** —— 直接在编辑器中查看 py-spy 热力图 -- **内存泄漏追踪** —— 在开发过程中检测泄漏 -- **uv 集成** —— `uv sync` 与 `uv add` 命令 -- **状态栏** —— 在状态栏中显示 LSP 状态 -- **健康检查** —— `:checkhealth basilisk` 进行诊断 - -## 要求 - -- Neovim 0.11+(插件使用内置的 `vim.lsp.config` / `vim.lsp.enable` API) -- `curl`(仅用于一次性下载 `basilisk` 二进制文件,见下文) - -## 安装 - -需要安装两部分:**插件**(本仓库,通过你的插件管理器安装)和 **`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("*") }, -- 最新稳定标签;或固定 "v0.33.0" -}) -require("basilisk").setup({}) -``` -
- -### 2. 二进制文件自动安装 - -打开任意 Python 文件。若未找到 `basilisk` 二进制文件,插件会自动从 [GitHub Release](https://github.com/Nimblesite/Basilisk/releases) 下载适合你平台的最新版本到 Neovim 数据目录并启动 LSP —— 无需配置 PATH,无需手动操作。也可以用 `:BasiliskInstall` 显式触发。 - -偏好包管理器?插件会自动识别已有安装: - -```sh -# macOS(Apple Silicon)/ Linux -brew tap Nimblesite/tap && brew install basilisk - -# Windows -scoop bucket add nimblesite https://github.com/Nimblesite/scoop-bucket -scoop install basilisk - -# 任何有 Python 工具链的环境 -uv tool install basilisk-python - -# 任何有 Rust 工具链的环境(从源码构建) -cargo install --git https://github.com/Nimblesite/Basilisk basilisk-cli -``` - -就这样 —— 诊断、悬停、补全、格式化、调试、测试与性能分析全部通过这一个插件运行。用 `:checkhealth basilisk` 验证。 - -## 更新 - -- **插件**:像其他插件一样更新 —— `:Lazy update`(lazy.nvim)、`:PackerSync`(packer)、`:PlugUpdate`(vim-plug)。 -- **二进制文件**:有新版本时插件会在启动时通知你。运行 **`:BasiliskUpdate`** —— 确认后下载新版本并就地重启 LSP。由包管理器管理的安装不会被覆盖;通知会提示你改用 `brew upgrade basilisk` / `scoop update basilisk` / `cargo install --git https://github.com/Nimblesite/Basilisk basilisk-cli`。 - -## 配置 - -零配置即可开箱即用: - -```lua -require("basilisk").setup() -``` - -所有选项(分析模式、内嵌提示、格式化器、调试器、测试浏览器、uv、快捷键等)见 [doc/basilisk.txt](doc/basilisk.txt) —— `:h basilisk-configuration`。 - -## 许可证 - -MIT。 diff --git a/basilisk.nvim/after/lsp/basilisk.lua b/basilisk.nvim/after/lsp/basilisk.lua deleted file mode 100644 index 7b8dfeec1..000000000 --- a/basilisk.nvim/after/lsp/basilisk.lua +++ /dev/null @@ -1,29 +0,0 @@ ---- Neovim 0.11+ native LSP config for basilisk. ---- ---- This file is auto-discovered by Neovim's built-in LSP framework. ---- It provides a fallback for users who don't call require('basilisk').setup() ---- but still want basic LSP functionality. - -local binary = require("basilisk.binary") - --- Neovim's LSP loader requires this file to evaluate to a table — a bare --- `return` surfaces as "after/lsp/basilisk.lua: not a table" (issue #370). --- When nothing resolves, degrade to the bare command name exactly as the --- nvim-lspconfig definition does: the client then fails to spawn with a --- readable "command not found" instead of a Lua error, and starts working the --- moment a basilisk lands on PATH — no reload needed. -local bin = binary.resolve() or "basilisk" - -return { - cmd = { bin, "lsp" }, - filetypes = { "python" }, - root_markers = { "pyproject.toml", "setup.py", "setup.cfg", ".git" }, - settings = { - basilisk = { - analysisMode = "wholeModule", - }, - }, - init_options = { - analysisMode = "wholeModule", - }, -} diff --git a/basilisk.nvim/doc/basilisk.txt b/basilisk.nvim/doc/basilisk.txt index 320328d91..98ce01448 100644 --- a/basilisk.nvim/doc/basilisk.txt +++ b/basilisk.nvim/doc/basilisk.txt @@ -1,438 +1,18 @@ -*basilisk.txt* Basilisk — Python type checker and LSP for Neovim +*basilisk.txt* Basilisk is unlisted - BASILISK.NVIM REFERENCE MANUAL +GENERATED FILE — DO NOT EDIT. Source: +docs/specs/DOCS-WITHDRAWAL-MESSAGING-SPEC.md [WITHDRAWAL-INERT-TEXT] -============================================================================== -CONTENTS *basilisk-contents* +BASILISK *basilisk* - 1. Introduction ....................................... |basilisk-introduction| - 2. Requirements ....................................... |basilisk-requirements| - 3. Installation ....................................... |basilisk-installation| - 4. Configuration ...................................... |basilisk-configuration| - 5. Commands ........................................... |basilisk-commands| - 6. Keymaps ............................................ |basilisk-keymaps| - 7. DAP Integration .................................... |basilisk-dap| - 8. Test Explorer ...................................... |basilisk-test-explorer| - 9. Profiling .......................................... |basilisk-profiling| - 10. Memory Tracking ................................... |basilisk-memory| - 11. uv Integration .................................... |basilisk-uv| - 12. Activity Panels ................................... |basilisk-panels| - 13. Status Line ....................................... |basilisk-statusline| - 14. Health Check ...................................... |basilisk-health| - 15. Troubleshooting ................................... |basilisk-troubleshooting| +Basilisk is unlisted. Its type checker is inert and checks nothing. -============================================================================== -1. INTRODUCTION *basilisk-introduction* +Basilisk's type checker was producing incorrect results. The code responsible is not isolated to a known set of rules, so we cannot say how many rules are affected. We asked for Basilisk to be removed from the python/typing conformance results, and it has been removed: https://github.com/python/typing/pull/2330 -Basilisk is a strict-by-default Python type checker built in Rust. This plugin -provides a first-class Neovim integration via the built-in LSP client. +A code-quality tool that does not produce correct results is worse than useless. Remove Basilisk from your pipeline, your pre-commit hooks, and your editor. This command failed on purpose. It is not a finding about your code. -Basilisk is the only Python type checker with a perfect 100% score on the -official python/typing conformance results — published on the Python typing -repository's own leaderboard, ahead of Pyright, mypy, Pyrefly and ty. See: -https://github.com/python/typing/blob/main/conformance/results/results.html +We are not fixing this code. We are rebuilding from the ground up as a new product, shipping only what can be trusted. If type checking ever returns, it will be externally audited before release. -Features: -- Zero-config setup: works out of the box -- All 21 LSP features native via Neovim 0.11+ -- Debug Adapter Protocol via nvim-dap -- Test explorer with pytest integration -- Python profiling with heat maps -- Memory leak tracking -- uv package manager integration +A full public account: https://www.christianfindlay.com/blog/basilisk-conformance-apology -============================================================================== -2. REQUIREMENTS *basilisk-requirements* - -Required: -- Neovim >= 0.11 (the plugin uses |vim.lsp.config| / |vim.lsp.enable|) -- curl (used once, to auto-download the `basilisk` binary) - -The `basilisk` binary itself is NOT a prerequisite: if none is found, the -plugin downloads the latest GitHub release automatically (see -|basilisk-binary|). Existing installs (Homebrew, Scoop, cargo, PATH) are -picked up instead. - -Optional: -- Python 3.12+ (for debugging, testing) -- debugpy (for DAP debugging) -- nvim-dap (for debug integration) -- nvim-dap-ui (for debug UI) -- uv (for package management) - -Formatting needs no external tool — the Ruff formatter is embedded in the -basilisk binary. - -============================================================================== -3. INSTALLATION *basilisk-installation* - -lazy.nvim: >lua - { - 'Nimblesite/basilisk.nvim', - ft = 'python', - dependencies = { 'mfussenegger/nvim-dap' }, -- optional - opts = {}, - } -< - -packer.nvim: >lua - use { - 'Nimblesite/basilisk.nvim', - ft = 'python', - config = function() - require('basilisk').setup({}) - end, - } -< - -vim.pack (built-in, Neovim 0.12+, no third-party manager): >lua - vim.pack.add({ - { src = 'https://github.com/Nimblesite/basilisk.nvim', - version = vim.version.range('*') }, -- latest stable tag; or pin 'v0.5.0' - }) - require('basilisk').setup({}) -< - -Manual: Clone to your plugin directory and call: >lua - require('basilisk').setup({}) -< - -THE BASILISK BINARY *basilisk-binary* - -The plugin resolves the `basilisk` binary in this order: configured -`binary_path` → `$BASILISK_PATH` → `~/.cargo/bin` → `/usr/local/bin` → -`/opt/homebrew/bin` → `$PATH` → auto-download. - -If nothing is found, the latest GitHub release is downloaded into -`stdpath("data")/basilisk//` and used automatically — installing -the plugin is the whole setup. Trigger the download explicitly with -|:BasiliskInstall|, and upgrade later with |:BasiliskUpdate|. - -Updating the plugin and updating the binary are separate: your plugin -manager updates the Lua plugin (`:Lazy update`, `:PackerSync`, -`:PlugUpdate`), while |:BasiliskUpdate| updates the binary. When a newer -release exists, a startup notice names the command that performs the -upgrade for your install (|:BasiliskUpdate|, or your package manager's own -upgrade command — Homebrew/Scoop/cargo installs are never overwritten). - -============================================================================== -4. CONFIGURATION *basilisk-configuration* - -All options with defaults: >lua - require('basilisk').setup({ - binary_path = nil, -- auto-detect - enabled = true, -- enable type checker - analysis_mode = "wholeModule", -- "openFilesOnly"|"wholeModule"|"crossModule" - python = nil, -- auto-detect Python interpreter - inlay_hints = { - parameter_names = true, -- show param name hints at call sites - variable_types = true, -- show inferred type hints - }, - formatter = "ruff", -- "ruff" (embedded in the basilisk - -- binary, no install) or "none" - debugger = { - enabled = true, -- enable debugger - type_checking = false, -- type assertion breakpoints - debugpy_path = "debugpy", -- debugpy module path - }, - test_explorer = { - enabled = true, -- enable test discovery - framework = "auto", -- "auto"|"pytest"|"unittest" - pytest_path = "pytest", -- pytest executable path - args = {}, -- additional test runner args - auto_discover_on_save = true, -- re-discover on file save - position = "right", -- "left"|"right"|"bottom" - width = 40, -- panel width - }, - uv = { - enabled = true, -- enable uv integration - executable_path = nil, -- auto-detect - auto_sync = false, -- auto-run uv sync - }, - keymaps = { - enabled = true, -- set default keymaps - prefix = "b", -- Basilisk keymap prefix - }, - statusline = { - enabled = true, -- enable status line component - }, - log_level = "info", -- "trace"|"debug"|"info"|"warn"|"error" - }) -< - -Formatting *basilisk-formatting* - -Format the current buffer with the embedded Ruff formatter (no `ruff` -install needed): >lua - vim.lsp.buf.format({ name = "basilisk" }) -< -Style options come from `[tool.ruff]` / `[tool.ruff.format]` in the -project's pyproject.toml. Set `formatter = "none"` in setup() to disable. - -============================================================================== -5. COMMANDS *basilisk-commands* - -Core: ~ - -:BasiliskRestart *:BasiliskRestart* - Restart the LSP server. Resets the restart counter. - -:BasiliskInfo *:BasiliskInfo* - Show server status in a floating window. - -:BasiliskOrganizeImports *:BasiliskOrganizeImports* - Organize imports via Ruff. - -:BasiliskInstall *:BasiliskInstall* - Download and install the latest `basilisk` binary from GitHub releases - (with confirmation) when none is installed yet. Points at - |:BasiliskUpdate| if one already exists. - -:BasiliskUpdate *:BasiliskUpdate* - Update the `basilisk` binary to the latest release: confirms, downloads - into the plugin's cache, and restarts the LSP on the new version. No-op - when already up to date. Refuses to overwrite binaries owned by a package - manager (use `brew upgrade basilisk` / `scoop update basilisk` / - `cargo install --git https://github.com/Nimblesite/Basilisk basilisk-cli`) - or a local dev build. - -Profiling: ~ - -:BasiliskProfile [pid] *:BasiliskProfile* - Start profiling. Optional PID argument. - -:BasiliskProfileStop *:BasiliskProfileStop* - Stop profiling. Shows results in floating window + quickfix. - -:BasiliskProfileSnapshot *:BasiliskProfileSnapshot* - Take a profiling snapshot without stopping. - -Memory: ~ - -:BasiliskMemLeak *:BasiliskMemLeak* - Start memory leak tracking. - -:BasiliskMemStop *:BasiliskMemStop* - Stop tracking. Shows leak report in floating window. - -:BasiliskMemRefs {type} *:BasiliskMemRefs* - Show retention paths for a type. Tab-completion available. - -Debug: ~ - -:BasiliskDebugFile *:BasiliskDebugFile* - Start debugging the current file via nvim-dap. - -Testing: ~ - -:BasiliskTestDiscover *:BasiliskTestDiscover* - Discover tests via pytest. - -:BasiliskTestRun [id] *:BasiliskTestRun* - Run test(s). Optional test ID. - -:BasiliskTestDebug {id} *:BasiliskTestDebug* - Debug a test via nvim-dap. - -:BasiliskTestToggle *:BasiliskTestToggle* - Toggle the test explorer side panel. - -uv: ~ - -:BasiliskUvSync *:BasiliskUvSync* - Run `uv sync` in the project root. - -:BasiliskUvAdd {package} *:BasiliskUvAdd* - Add a package via `uv add`. - -:BasiliskUvAddDev {package} *:BasiliskUvAddDev* - Add a dev dependency via `uv add --dev`. - -:BasiliskUvRemove {package} *:BasiliskUvRemove* - Remove a package via `uv remove`. - -:BasiliskUvLock *:BasiliskUvLock* - Run `uv lock`. - -:BasiliskUvCreateEnv [version] *:BasiliskUvCreateEnv* - Create a virtual environment via `uv venv`. - -============================================================================== -6. KEYMAPS *basilisk-keymaps* - -Standard LSP (set on LspAttach for Python buffers): ~ - - `gd` Go to definition - `gD` Go to declaration - `gy` Go to type definition - `gr` Find references - `K` Hover - `` Signature help (insert mode) - `rn` Rename - `ca` Code action - -Basilisk-specific (configurable prefix, default `b`): ~ - - `br` Restart server - `bo` Organize imports - `bp` Start profiling - `bP` Stop profiling - `bm` Start memory tracking - `bM` Stop memory tracking - `bt` Toggle test explorer - `bd` Debug current file - `bR` Run test at cursor - `bD` Debug test at cursor - -Disable all keymaps: >lua - require('basilisk').setup({ keymaps = { enabled = false } }) -< - -============================================================================== -7. DAP INTEGRATION *basilisk-dap* - -Requires nvim-dap. Automatically set up when available. - -Two default configurations are registered: -1. "Python: Current File (Basilisk)" — launch current file -2. "Python: Attach (Basilisk)" — attach to 127.0.0.1:5678 - -Optional integrations: -- nvim-dap-ui: auto-open on debug start, auto-close on terminate -- nvim-dap-virtual-text: inline variable display during debugging - -============================================================================== -8. TEST EXPLORER *basilisk-test-explorer* - -Test explorer keymaps (in test panel): ~ - - `` Run test - `d` Debug test - `R` Re-run failed tests - `q` Close panel - -============================================================================== -9. PROFILING *basilisk-profiling* - -Workflow: -1. `:BasiliskProfile` to start -2. Exercise your code -3. `:BasiliskProfileStop` to see results -4. Results appear in floating window + quickfix list -5. Hot lines get heat map extmarks in source buffers - -============================================================================== -10. MEMORY TRACKING *basilisk-memory* - -Workflow: -1. `:BasiliskMemLeak` to start tracking -2. Exercise your code -3. `:BasiliskMemStop` to see leak report -4. `:BasiliskMemRefs DataFrame` to see retention paths - -============================================================================== -11. UV INTEGRATION *basilisk-uv* - -Package management via uv. Commands map to uv CLI operations. - -============================================================================== -12. ACTIVITY PANELS *basilisk-panels* - -Basilisk provides three workspace panels for browsing modules, type coverage, -and server info. - -Module Explorer: ~ - *:BasiliskModules* -:BasiliskModules - Toggle the Module Explorer side panel. Shows every Python module in - your workspace with its top-level symbols (functions, classes, variables). - - Keybindings (in the panel buffer): - `` Open file at symbol - `o` Toggle fold (expand/collapse a module) - `r` Refresh the module tree from the LSP server - `y` Copy import path to clipboard (e.g. `from pkg import func`) - `q` Close the panel - - Default keymap: `bm` - -Type Health: ~ - *:BasiliskHealth* -:BasiliskHealth - Toggle the Type Health panel. Displays per-module type annotation - coverage with color-coded progress bars: - Green 90%+ coverage - Yellow 50-89% coverage - Red <50% coverage - - Shows workspace summary (total symbols, annotated count, errors, - warnings, adopted files) and per-module breakdown sorted by coverage. - - Keybindings (in the panel buffer): - `` Open module file - `r` Refresh health data - `q` Close the panel - - Default keymap: `bh` - -Server Info: ~ - *:BasiliskInfo* -:BasiliskInfo - Show server status in a floating window. Displays binary path, - version, Python interpreter, analysis mode, and feature status. - - Default keymap: `bi` - -Live Updates: ~ - All panels refresh automatically when files change via the - `basilisk/moduleChanged` LSP notification (debounced 300ms). - -============================================================================== -13. STATUS LINE *basilisk-statusline* - -Lualine integration: >lua - sections = { - lualine_x = { require('basilisk.statusline').lualine_component }, - } -< - -Raw function for custom status lines: >lua - require('basilisk.statusline').get() -- returns "✓ Basilisk" etc. -< - -States: - Starting ⟳ Basilisk (yellow) - Ready ✓ Basilisk (green) - Ready+errors ⚠ Basilisk (3E 2W) (orange) - Error ✗ Basilisk (red) - Stopped ⊘ Basilisk (grey) - -============================================================================== -14. HEALTH CHECK *basilisk-health* - -Run `:checkhealth basilisk` to verify your setup. - -============================================================================== -15. TROUBLESHOOTING *basilisk-troubleshooting* - -Binary not found: ~ - Run `:BasiliskInstall` to download the latest release. - Or install: `brew install basilisk` / `scoop install basilisk` / - `uv tool install basilisk-python` - Or set: `vim.env.BASILISK_PATH = "/path/to/basilisk"` - -LSP not starting: ~ - Check `:checkhealth basilisk` - Check `:BasiliskInfo` for status - Try `:BasiliskRestart` - -No diagnostics: ~ - Ensure you have a `pyproject.toml` or `.git` in your project root. - Check `analysis_mode` setting. - -Debug not working: ~ - Install debugpy: `pip install debugpy` - Install nvim-dap: add to your plugin manager - -============================================================================== - vim:tw=78:ts=8:ft=help:norl: +vim:tw=78:ts=8:ft=help:norl: diff --git a/basilisk.nvim/ftplugin/python.lua b/basilisk.nvim/ftplugin/python.lua deleted file mode 100644 index 058f2b72d..000000000 --- a/basilisk.nvim/ftplugin/python.lua +++ /dev/null @@ -1,88 +0,0 @@ ---- Filetype plugin for Python files. ---- ---- Auto-loaded by Neovim when a Python buffer opens. ---- Sets up keymaps, inlay hints, and code lens for Basilisk. - --- Guard: only run if basilisk was set up. -local ok, basilisk = pcall(require, "basilisk") -if not ok or not basilisk.config then - return -end - --- Guard: source once per buffer. Neovim re-runs ftplugins on every FileType --- event and unlets the builtin `b:did_ftplugin` before ours runs (we are --- earlier on the runtimepath), so we keep our own buffer-local marker to avoid --- registering the LspAttach handler more than once. -if vim.b.basilisk_did_ftplugin then - return -end -vim.b.basilisk_did_ftplugin = true - -local config = basilisk.config - --- Set up keymaps on LspAttach for basilisk clients only. -vim.api.nvim_create_autocmd("LspAttach", { - buffer = 0, - callback = function(args) - local client = vim.lsp.get_client_by_id(args.data.client_id) - if not client or client.name ~= "basilisk" then - return - end - - -- Enable inlay hints if supported. - if client:supports_method("textDocument/inlayHint") then - vim.lsp.inlay_hint.enable(true, { bufnr = args.buf }) - end - - -- Enable code lens if supported. Activation is version-compatible: see - -- basilisk.codelens (prefers vim.lsp.codelens.enable on Neovim 0.12+, which - -- replaced the deprecated/removed refresh()). - if client:supports_method("textDocument/codeLens") then - require("basilisk.codelens").activate(args.buf) - end - - -- Skip keymaps if disabled. - if not config.keymaps.enabled then - return - end - - local buf = args.buf - local map = function(mode, lhs, rhs, desc) - vim.keymap.set(mode, lhs, rhs, { buffer = buf, desc = desc }) - end - - -- Standard LSP keymaps. - -- Implements [NVIM-DEFAULT-KEYMAPS] / [NVIM-DEFAULT-KEYMAPS-STANDARD-LSP] — - -- set via the LspAttach autocmd; gd/gD/gy/gr/K//rn/ca. - map("n", "gd", vim.lsp.buf.definition, "Go to definition") - map("n", "gD", vim.lsp.buf.declaration, "Go to declaration") - map("n", "gy", vim.lsp.buf.type_definition, "Go to type definition") - map("n", "gr", vim.lsp.buf.references, "Find references") - map("n", "K", vim.lsp.buf.hover, "Hover") - map("i", "", vim.lsp.buf.signature_help, "Signature help") - map("n", "rn", vim.lsp.buf.rename, "Rename") - map("n", "ca", vim.lsp.buf.code_action, "Code action") - - -- Basilisk-specific keymaps with configurable prefix. - -- Implements [NVIM-DEFAULT-KEYMAPS-BASILISK-SPECIFIC] — b prefix - -- (configurable via keymaps.prefix) for restart/organize/profile/memory/test. - local prefix = config.keymaps.prefix - map("n", prefix .. "r", "BasiliskRestart", "Restart server") - map("n", prefix .. "o", "BasiliskOrganizeImports", "Organize imports") - map("n", prefix .. "p", "BasiliskProfile", "Start profiling") - map("n", prefix .. "P", "BasiliskProfileStop", "Stop profiling") - map("n", prefix .. "m", "BasiliskMemLeak", "Start memory tracking") - map("n", prefix .. "M", "BasiliskMemStop", "Stop memory tracking") - map("n", prefix .. "t", "BasiliskTestToggle", "Toggle test explorer") - map("n", prefix .. "d", "BasiliskDebugFile", "Debug current file") - map("n", prefix .. "R", "BasiliskTestRun", "Run test at cursor") - map("n", prefix .. "D", "BasiliskTestDebug", "Debug test at cursor") - - -- Refactoring keymaps. - map("v", prefix .. "ev", "BasiliskExtractVariable", "Extract variable") - map("v", prefix .. "ec", "BasiliskExtractConstant", "Extract constant") - map("n", prefix .. "cu", "BasiliskConvertUnion", "Convert Union/Optional") - map("n", prefix .. "im", "BasiliskImplementMethods", "Implement abstract methods") - map("n", prefix .. "fa", "BasiliskFixFile", "Fix all in file") - end, -}) diff --git a/basilisk.nvim/lspconfig/basilisk.lua b/basilisk.nvim/lspconfig/basilisk.lua deleted file mode 100644 index be74e9e3f..000000000 --- a/basilisk.nvim/lspconfig/basilisk.lua +++ /dev/null @@ -1,82 +0,0 @@ ---- nvim-lspconfig server definition for Basilisk. ---- ---- Implements [NVIM-DISTRIBUTION-SECONDARY-LSPCONFIG-PR] — the minimal LSP-only ---- config submitted to nvim-lspconfig for users who just want basic LSP. ---- ---- This file is intended for submission to the nvim-lspconfig repository: ---- https://github.com/neovim/nvim-lspconfig ---- ---- Users who install basilisk.nvim directly don't need this — the plugin ---- uses native vim.lsp.config/vim.lsp.enable. This config exists for users ---- who prefer nvim-lspconfig as their LSP management layer. - -local util = require("lspconfig.util") - -local bin_name = "basilisk" - -local function find_binary() - -- BASILISK_PATH env var. - local env_path = vim.env.BASILISK_PATH - if env_path and env_path ~= "" and vim.fn.executable(env_path) == 1 then - return env_path - end - - -- Well-known locations. - local candidates = { - vim.fn.expand("~/.cargo/bin/basilisk"), - "/usr/local/bin/basilisk", - "/opt/homebrew/bin/basilisk", - } - for _, candidate in ipairs(candidates) do - if vim.fn.executable(candidate) == 1 then - return candidate - end - end - - -- Fall back to PATH. - return bin_name -end - -return { - default_config = { - cmd = { find_binary(), "lsp" }, - filetypes = { "python" }, - root_dir = util.root_pattern("pyproject.toml", "setup.py", "setup.cfg", ".git"), - single_file_support = true, - settings = { - basilisk = { - analysisMode = "wholeModule", - inlayHints = { - parameterNames = true, - variableTypes = true, - }, - formatter = "ruff", - uv = { - enabled = true, - }, - }, - }, - init_options = { - analysisMode = "wholeModule", - }, - }, - docs = { - description = [[ -https://github.com/Nimblesite/Basilisk - -Basilisk is a strict-by-default Python type checker and comprehensive LSP -built in Rust. It provides type checking, inlay hints, code actions, -debugging, profiling, test exploration, and uv package manager integration. - -Install with `brew install basilisk`, `scoop install basilisk`, -`uv tool install basilisk-python`, or download from GitHub releases. - -For the full-featured plugin (DAP, test explorer, profiling, keymaps), use -[basilisk.nvim](https://github.com/Nimblesite/Basilisk/tree/main/basilisk.nvim) -instead. -]], - default_config = { - root_dir = [[root_pattern("pyproject.toml", "setup.py", "setup.cfg", ".git")]], - }, - }, -} diff --git a/basilisk.nvim/lua/basilisk/binary.lua b/basilisk.nvim/lua/basilisk/binary.lua deleted file mode 100644 index 8827e88d8..000000000 --- a/basilisk.nvim/lua/basilisk/binary.lua +++ /dev/null @@ -1,465 +0,0 @@ ---- Binary resolution for the basilisk executable. ---- ---- Follows the cascade defined in LSP-SPEC.md: ---- 1. User-configured path (editor setting) ---- 2. BASILISK_PATH environment variable ---- 3. ~/.cargo/bin/basilisk ---- 4. /usr/local/bin/basilisk ---- 5. /opt/homebrew/bin/basilisk ---- 6. Fall back to OS PATH search ---- 7. Plugin-managed cache (a binary an earlier session downloaded) ---- 8. Auto-download from GitHub releases (fallback) - -local log = require("basilisk.log") - -local M = {} - ---- GitHub repo for release downloads. -local GITHUB_REPO = "Nimblesite/Basilisk" - ---- GitHub API URL for latest release. -local RELEASES_API = "https://api.github.com/repos/" .. GITHUB_REPO .. "/releases/latest" - ---- GitHub API URL for the full release list (newest first), used to skip past a ---- newest-release that shipped no binaries. See [NVIM-BINARY-UPGRADE-ASSETS]. -local RELEASES_LIST_API = "https://api.github.com/repos/" .. GITHUB_REPO .. "/releases" - ---- Repo URL, the source of truth for every from-source install hint. Exported ---- so update.lua composes its advice from the same string instead of ---- hand-repeating the URL. -local GITHUB_URL = "https://github.com/" .. GITHUB_REPO -M.GITHUB_URL = GITHUB_URL - ---- Directory where downloaded binaries are cached. ----@return string -local function download_dir() - return vim.fn.stdpath("data") .. "/basilisk" -end - ---- Check whether a file exists and is executable. ----@param path string ----@return boolean -local function is_executable(path) - return vim.fn.executable(path) == 1 -end - ---- The newest plugin-managed install already on disk, or nil. ---- ---- Implements [NVIM-BINARY-UPGRADE-MANAGED-DISCOVERY]. Downloads land in a ---- version-scoped directory (`/basilisk//`), so the path cannot be ---- named without knowing the tag. Scanning for it keeps a managed install ---- resolvable from disk alone — deriving the tag from GitHub instead makes an ---- offline or rate-limited session unable to see its own binary (issue #370). ----@return string? -local function newest_managed() - local root = download_dir() - local best_path, best_version - for name, kind in vim.fs.dir(root) do - if kind == "directory" then - for _, binary_name in ipairs({ "basilisk", "basilisk.exe" }) do - local candidate = root .. "/" .. name .. "/" .. binary_name - if is_executable(candidate) and (not best_version or M.is_newer_version(best_version, name)) then - best_path, best_version = candidate, name - end - end - end - end - return best_path -end - ---- Check whether a configured binary path is usable. ----@param path? string ----@return boolean -function M.is_executable(path) - return type(path) == "string" and path ~= "" and is_executable(path) -end - ---- Parse a semver-ish string into (major, minor, patch). ---- Strips leading "v" and "basilisk " prefix. ----@param version_str string ----@return integer, integer, integer -local function parse_semver(version_str) - local stripped = version_str:gsub("^basilisk%s+", ""):gsub("^v", "") - local major, minor, patch = stripped:match("^(%d+)%.(%d+)%.(%d+)") - return tonumber(major) or 0, tonumber(minor) or 0, tonumber(patch) or 0 -end - ---- Compare two version strings. Returns true if latest is newer than current. ----@param current string ----@param latest string ----@return boolean -function M.is_newer_version(current, latest) - local cur_maj, cur_min, cur_pat = parse_semver(current) - local lat_maj, lat_min, lat_pat = parse_semver(latest) - if lat_maj ~= cur_maj then return lat_maj > cur_maj end - if lat_min ~= cur_min then return lat_min > cur_min end - return lat_pat > cur_pat -end - ---- Detect the platform-specific asset name for GitHub releases. ---- Implements [NVIM-BINARY-UPGRADE-ASSETS] — names must byte-match the ---- `archive:` entries in release.yml or download() silently finds no asset: ---- Linux ships `.tar.gz`, macOS and Windows ship `.zip`, and macOS is ---- published for aarch64 only (no x86_64-apple-darwin build exists). ----@return string? asset_name, boolean is_windows -function M.platform_asset_name() - local uname = vim.uv.os_uname() - local sysname = uname.sysname:lower() - local machine = uname.machine:lower() - - local arch_str - if machine == "arm64" or machine == "aarch64" then - arch_str = "aarch64" - elseif machine == "x86_64" or machine == "amd64" then - arch_str = "x86_64" - else - return nil, false - end - - if sysname == "darwin" then - if arch_str ~= "aarch64" then - return nil, false - end - return "basilisk-aarch64-apple-darwin.zip", false - end - if sysname == "linux" then - return string.format("basilisk-%s-unknown-linux-gnu.tar.gz", arch_str), false - end - if sysname:find("windows") or sysname:find("mingw") then - return string.format("basilisk-%s-pc-windows-msvc.zip", arch_str), true - end - return nil, false -end - ---- Fetch the latest release info from GitHub (synchronous, via curl). ----@return table? release { tag_name: string, assets: [{name, browser_download_url}] } -function M.fetch_latest_release() - local ok, result = pcall(vim.fn.system, { - "curl", "-sSL", - "-H", "Accept: application/vnd.github+json", - RELEASES_API, - }) - if not ok or vim.v.shell_error ~= 0 then - return nil - end - local decode_ok, data = pcall(vim.json.decode, result) - if not decode_ok or type(data) ~= "table" or not data.tag_name then - return nil - end - return data -end - ---- Every release, newest first (synchronous, via curl). ----@return table[]? releases -function M.fetch_releases() - local ok, result = pcall(vim.fn.system, { - "curl", "-sSL", - "-H", "Accept: application/vnd.github+json", - RELEASES_LIST_API, - }) - if not ok or vim.v.shell_error ~= 0 then - return nil - end - local decode_ok, data = pcall(vim.json.decode, result) - if not decode_ok or type(data) ~= "table" or type(data[1]) ~= "table" then - return nil - end - return data -end - ---- The newest release that actually publishes `asset_name`. ---- ---- Implements [NVIM-BINARY-UPGRADE-ASSETS]. The newest release is NOT always ---- installable: a release is created from its tag the moment the tag is pushed, ---- but its binaries are uploaded by a later job in the release workflow, so any ---- gate that fails in between leaves a published release carrying ZERO assets. ---- Resolving `releases/latest` and stopping there then hands the user a silent ---- dead end — no binary, no error, nothing to act on (the #370 failure mode). ---- Skipping to the newest release that DOES carry this platform's asset gives ---- them a working checker instead, which is strictly better than nothing. ----@param asset_name string ----@return table? release, string? download_url -function M.find_release_with_asset(asset_name) - local function match(release) - for _, asset in ipairs(release and release.assets or {}) do - if asset.name == asset_name then - return asset.browser_download_url - end - end - return nil - end - - local latest = M.fetch_latest_release() - local url = match(latest) - if url then - return latest, url - end - - for _, release in ipairs(M.fetch_releases() or {}) do - if not release.draft then - url = match(release) - if url then - log.warn( - "latest release %s publishes no %s — falling back to %s", - latest and latest.tag_name or "?", - asset_name, - release.tag_name - ) - return release, url - end - end - end - return nil, nil -end - ---- Download the basilisk binary from the latest GitHub release. ---- Returns the path to the downloaded binary, or nil on failure. ----@return string? path, string? version -function M.download() - local asset_name, is_windows = M.platform_asset_name() - if not asset_name then - return nil, nil - end - - -- Not `fetch_latest_release()`: the newest release can carry zero assets when - -- its publish job never ran, and stopping there is a silent dead end. - -- [NVIM-BINARY-UPGRADE-ASSETS] - local release, download_url = M.find_release_with_asset(asset_name) - if not release or not download_url then - return nil, nil - end - - local version = release.tag_name - local dir = download_dir() .. "/" .. version - local binary_name = is_windows and "basilisk.exe" or "basilisk" - local binary_path = dir .. "/" .. binary_name - - -- Already downloaded. - if is_executable(binary_path) then - return binary_path, version - end - - vim.fn.mkdir(dir, "p") - - local archive_path = dir .. "/" .. asset_name - log.info("downloading %s...", version) - - local curl_ok = pcall(vim.fn.system, { - "curl", "-sSL", "-o", archive_path, download_url, - }) - if not curl_ok or vim.v.shell_error ~= 0 then - log.error("download failed") - return nil, nil - end - - -- Extract ([NVIM-BINARY-UPGRADE-ASSETS]). Windows has no unzip, but its - -- in-box tar.exe (bsdtar, Windows 10 1803+) extracts zips, and the Windows - -- archives are flat. macOS keeps `unzip -j` to flatten the binaries out of - -- the archive's `basilisk-darwin/` staging dir. - if not asset_name:match("%.zip$") then - pcall(vim.fn.system, { "tar", "xzf", archive_path, "-C", dir }) - elseif is_windows then - pcall(vim.fn.system, { "tar", "-xf", archive_path, "-C", dir }) - else - pcall(vim.fn.system, { "unzip", "-j", "-o", archive_path, "-d", dir }) - end - - if vim.v.shell_error ~= 0 then - log.error("extraction failed") - return nil, nil - end - - -- Clean up archive. - vim.fn.delete(archive_path) - - -- Make executable. The macOS archive also carries basilisk-profiler-helper, - -- which the profiler needs alongside the main binary. - if not is_windows then - vim.fn.setfperm(binary_path, "rwxr-xr-x") - local helper_path = dir .. "/basilisk-profiler-helper" - if vim.fn.filereadable(helper_path) == 1 then - vim.fn.setfperm(helper_path, "rwxr-xr-x") - end - end - - if is_executable(binary_path) then - log.info("installed %s", version) - return binary_path, version - end - - return nil, nil -end - ---- Locate an already-installed basilisk binary (cascade steps 1-7, no ---- download). :BasiliskInstall uses this to decide whether anything is ---- installed without side effects ([NVIM-BINARY-UPGRADE-INSTALL]). ----@param configured_path? string User-configured path from setup(). ----@return string? path Absolute path to the binary, or nil if not found. -function M.locate(configured_path) - -- 1. User-configured path. - if configured_path and configured_path ~= "" then - if is_executable(configured_path) then - return configured_path - end - log.warn("configured binary_path not found: %s", configured_path) - end - - -- 2. BASILISK_PATH environment variable. - local env_path = vim.env.BASILISK_PATH - if env_path and env_path ~= "" and is_executable(env_path) then - return env_path - end - - -- 3-5. Well-known locations. - local candidates = { - vim.fn.expand("~/.cargo/bin/basilisk"), - "/usr/local/bin/basilisk", - "/opt/homebrew/bin/basilisk", - } - for _, candidate in ipairs(candidates) do - if is_executable(candidate) then - return candidate - end - end - - -- 6. OS PATH search. - local on_path = vim.fn.exepath("basilisk") - if on_path ~= "" then - return on_path - end - - -- 7. Plugin-managed cache — an install this plugin downloaded earlier. - return newest_managed() -end - ---- Resolve the basilisk binary path using the LSP-SPEC cascade. ----@param configured_path? string User-configured path from setup(). ----@return string? path Absolute path to the binary, or nil if not found. -function M.resolve(configured_path) - local located = M.locate(configured_path) - if located then - return located - end - - -- 8. Auto-download from GitHub releases. - local downloaded_path = M.download() - if downloaded_path then - return downloaded_path - end - - return nil -end - ---- Where an install came from, deciding who owns its upgrades. ----@alias BasiliskInstallSource "managed"|"homebrew"|"scoop"|"cargo"|"dev"|"manual" - ---- Classify a resolved binary path by install source. ---- Implements [NVIM-BINARY-UPGRADE-SOURCES] — :BasiliskUpdate only replaces ---- binaries it manages (or manual installs); package-manager and dev builds ---- are steered to their own upgrade path instead of being clobbered. ----@param path string ----@return BasiliskInstallSource -function M.install_source(path) - local normalized = vim.fs.normalize(path) - if normalized:find(vim.fs.normalize(download_dir()), 1, true) == 1 then - return "managed" - end - if - normalized:find("/opt/homebrew/", 1, true) - or normalized:find("/Cellar/", 1, true) - or normalized:find("/linuxbrew/", 1, true) - then - return "homebrew" - end - if normalized:lower():find("/scoop/", 1, true) then - return "scoop" - end - if normalized:find(vim.fs.normalize("~/.cargo/bin/"), 1, true) == 1 then - return "cargo" - end - local version = M.version(path) - if version and version:find("0.0.0", 1, true) then - return "dev" - end - return "manual" -end - ---- The upgrade action owning an install source, for user-facing notices. ---- nil for dev builds — a local build is never "behind" a release. ---- ---- The cargo hint MUST carry --git: `basilisk-cli` is not published to ---- crates.io, so the bare `cargo install basilisk-cli` fails for everyone with ---- "could not find basilisk-cli in registry" ([NVIM-BINARY-UPGRADE-SOURCES], ---- issue #370). ----@param source BasiliskInstallSource ----@return string? -function M.upgrade_hint(source) - local hints = { - managed = "run :BasiliskUpdate to install", - manual = "run :BasiliskUpdate to install", - homebrew = "run `brew upgrade basilisk`", - scoop = "run `scoop update basilisk`", - cargo = "run `cargo install --git " .. GITHUB_URL .. " basilisk-cli`", - } - return hints[source] -end - ---- Get the version string from the binary. ----@param binary_path string ----@return string? version -function M.version(binary_path) - if not is_executable(binary_path) then - return nil - end - local ok, result = pcall(vim.fn.system, { binary_path, "--version" }) - if not ok or vim.v.shell_error ~= 0 then - return nil - end - -- `--version` is multi-line: line 1 is the Shipwright ` ` - -- contract, later lines list embedded engines (e.g. the Ruff formatter, - -- [LSPFMT-PROVENANCE]). Only line 1 is the binary version — and interior - -- newlines would break single-line consumers like the info float. - local first_line = vim.split(vim.trim(result), "\n", { plain = true })[1] - return first_line and vim.trim(first_line) or nil -end - ---- Check if a newer version is available and notify the user with the ---- upgrade action that owns the install ([NVIM-BINARY-UPGRADE-NOTICE]). ---- Dev builds are never nagged. Non-blocking: curl runs via vim.system. ----@param binary_path string -function M.check_for_updates(binary_path) - local current_version = M.version(binary_path) - if not current_version then - return - end - local hint = M.upgrade_hint(M.install_source(binary_path)) - if not hint then - return - end - - vim.system( - { "curl", "-sSL", "-H", "Accept: application/vnd.github+json", RELEASES_API }, - { text = true }, - function(result) - if result.code ~= 0 or not result.stdout then - return - end - local decode_ok, data = pcall(vim.json.decode, result.stdout) - if not decode_ok or type(data) ~= "table" or not data.tag_name then - return - end - if M.is_newer_version(current_version, data.tag_name) then - vim.schedule(function() - log.info( - "update available: %s → %s — %s.", - current_version, - data.tag_name, - hint - ) - end) - end - end - ) -end - -return M diff --git a/basilisk.nvim/lua/basilisk/codelens.lua b/basilisk.nvim/lua/basilisk/codelens.lua deleted file mode 100644 index 3964515dc..000000000 --- a/basilisk.nvim/lua/basilisk/codelens.lua +++ /dev/null @@ -1,33 +0,0 @@ ---- Version-compatible LSP code lens activation. ---- ---- Centralizes the one correct way to turn on code lens for a buffer so the ---- ftplugin and tests never duplicate the version check. See the Code Lens row ---- in NEOVIM-SPEC.md §NVIM-LSP-CLIENT-CONFIGURATION-API-MAPPINGS. ---- ---- `vim.lsp.codelens.enable` (Neovim 0.12+) installs its own debounced refresh ---- autocmds, so it is preferred whenever present. `vim.lsp.codelens.refresh` is ---- deprecated on 0.12 and removed on 0.13; it is only used as a fallback on ---- Neovim 0.10/0.11, paired with a manual BufEnter/InsertLeave refresh loop. - -local M = {} - ---- Activate code lens for a buffer using the best API the runtime exposes. ---- Implements [NVIM-LSP-CLIENT-CONFIGURATION-API-MAPPINGS] (Code Lens row) — ---- vim.lsp.codelens.enable on 0.12+, refresh() fallback on 0.10/0.11. ----@param bufnr integer -function M.activate(bufnr) - if vim.lsp.codelens.enable then - vim.lsp.codelens.enable(true, { bufnr = bufnr }) - return - end - - vim.lsp.codelens.refresh({ bufnr = bufnr }) - vim.api.nvim_create_autocmd({ "BufEnter", "InsertLeave" }, { - buffer = bufnr, - callback = function() - vim.lsp.codelens.refresh({ bufnr = bufnr }) - end, - }) -end - -return M diff --git a/basilisk.nvim/lua/basilisk/commands.lua b/basilisk.nvim/lua/basilisk/commands.lua deleted file mode 100644 index 29b5218ff..000000000 --- a/basilisk.nvim/lua/basilisk/commands.lua +++ /dev/null @@ -1,403 +0,0 @@ ---- User commands for Basilisk. ---- ---- All profiling/memory/test/uv LSP commands (defined in LSP-ARCHITECTURE-SPEC.md) ---- surface as :Basilisk* user commands. - -local log = require("basilisk.log") -local ui = require("basilisk.ui") - -local M = {} - ---- Map LSP message types to Neovim notification levels. ----@param message_type integer? ----@return integer -local function lsp_message_level(message_type) - local message_types = vim.lsp.protocol.MessageType - if message_type == message_types.Error then - return vim.log.levels.ERROR - end - if message_type == message_types.Warning then - return vim.log.levels.WARN - end - return vim.log.levels.INFO -end - ---- Send an LSP server message through the plugin logger. ----@param result table? ----@param show_info boolean -local function log_lsp_message(result, show_info) - if not result or type(result.message) ~= "string" or result.message == "" then - return - end - local message = result.message:gsub("^Basilisk:%s*", "") - local level = lsp_message_level(result.type) - if level == vim.log.levels.ERROR then - log.error("%s", message) - elseif level == vim.log.levels.WARN then - log.warn("%s", message) - elseif show_info then - log.info("%s", message) - else - log.debug("%s", message) - end -end - ---- Install Basilisk message handlers on already-running clients. -local function install_message_handlers() - for _, client in ipairs(vim.lsp.get_clients({ name = "basilisk" })) do - client.handlers = client.handlers or {} - client.handlers["window/logMessage"] = function(_err, result) - log_lsp_message(result, false) - end - client.handlers["window/showMessage"] = function(_err, result) - log_lsp_message(result, true) - end - end -end - ---- Send an LSP executeCommand request. ----@param command string ----@param args? table ----@param callback? fun(err: any, result: any) -local function execute_command(command, args, callback) - local client = ui.get_client() - if not client then - log.warn("no active LSP client") - return - end - install_message_handlers() - client:request("workspace/executeCommand", { - command = command, - arguments = args or {}, - }, callback, 0) -end - - ---- Register all :Basilisk* commands. ---- Implements [NVIM-USER-COMMANDS] — surfaces the profiling/memory/test/uv LSP ---- commands plus the client-side commands (:BasiliskRestart, :BasiliskInfo, ---- :BasiliskTestToggle) as Neovim user commands. ----@param config BasiliskConfig -function M.register(config) - local lsp_mod = require("basilisk.lsp") - local profiling = require("basilisk.profiling") - local memory = require("basilisk.memory") - local testing = require("basilisk.testing") - install_message_handlers() - if lsp_mod.install_handlers then - lsp_mod.install_handlers() - end - - -- Core commands. - - vim.api.nvim_create_user_command("BasiliskRestart", function() - lsp_mod.reset_restart_count() - lsp_mod.restart(config, true) - log.info("restarting server...") - end, { desc = "Restart the Basilisk LSP server" }) - - local info_panel = require("basilisk.info") - - vim.api.nvim_create_user_command("BasiliskInfo", function() - info_panel.show(config) - end, { desc = "Show Basilisk LSP server info" }) - - -- Binary install/upgrade ([NVIM-BINARY-UPGRADE]). - - vim.api.nvim_create_user_command("BasiliskUpdate", function() - require("basilisk.update").update(config) - end, { desc = "Update the basilisk binary to the latest release" }) - - vim.api.nvim_create_user_command("BasiliskInstall", function() - require("basilisk.update").install(config) - end, { desc = "Download and install the basilisk binary" }) - - vim.api.nvim_create_user_command("BasiliskOrganizeImports", function() - local uri = vim.uri_from_bufnr(vim.api.nvim_get_current_buf()) - execute_command("basilisk.organizeImports", { uri }) - end, { desc = "Organize imports via Basilisk" }) - - vim.api.nvim_create_user_command("BasiliskFixFile", function() - local uri = vim.uri_from_bufnr(vim.api.nvim_get_current_buf()) - execute_command("basilisk.fixFile", { uri }, function(err) - if err then - log.error("fix file failed: %s", err.message or tostring(err)) - else - log.info("file fixed") - end - end) - end, { desc = "Fix all diagnostics in current file" }) - - vim.api.nvim_create_user_command("BasiliskFixWorkspace", function() - execute_command("basilisk.fixWorkspace", {}, function(err) - if err then - log.error("fix workspace failed: %s", err.message or tostring(err)) - else - log.info("workspace fixed") - end - end) - end, { desc = "Fix all diagnostics in workspace" }) - - vim.api.nvim_create_user_command("BasiliskAdoptFile", function() - local uri = vim.uri_from_bufnr(vim.api.nvim_get_current_buf()) - execute_command("basilisk.adoptFile", { uri }, function(err) - if err then - log.error("adopt file failed: %s", err.message or tostring(err)) - else - log.info("file adopted for type checking") - end - end) - end, { desc = "Opt-in current file to type checking" }) - - vim.api.nvim_create_user_command("BasiliskAdoptWorkspace", function() - execute_command("basilisk.adoptWorkspace", {}, function(err) - if err then - log.error("adopt workspace failed: %s", err.message or tostring(err)) - else - log.info("workspace adopted for type checking") - end - end) - end, { desc = "Opt-in workspace to type checking" }) - - vim.api.nvim_create_user_command("BasiliskUnadoptFile", function() - local uri = vim.uri_from_bufnr(vim.api.nvim_get_current_buf()) - execute_command("basilisk.unadoptFile", { uri }, function(err) - if err then - log.error("unadopt file failed: %s", err.message or tostring(err)) - else - log.info("file unadopted from type checking") - end - end) - end, { desc = "Opt-out current file from type checking" }) - - vim.api.nvim_create_user_command("BasiliskDisableRule", function(opts) - local rule = opts.args - if rule == "" then - vim.ui.input({ prompt = "Diagnostic code to disable (e.g. BSK-0001): " }, function(input) - if input and input ~= "" then - execute_command("basilisk.disableRule", { { rule = input, severity = "off" } }) - end - end) - else - execute_command("basilisk.disableRule", { { rule = rule, severity = "off" } }) - end - end, { nargs = "?", desc = "Disable a diagnostic rule in pyproject.toml" }) - - vim.api.nvim_create_user_command("BasiliskShowOutput", function() - -- In Neovim, open the LSP log file. - local logpath = vim.lsp.get_log_path() - if logpath then - vim.cmd("edit " .. vim.fn.fnameescape(logpath)) - else - log.info("no LSP log file found") - end - end, { desc = "Show LSP output log" }) - - -- Refactoring commands. - - vim.api.nvim_create_user_command("BasiliskExtractVariable", function() - vim.lsp.buf.code_action({ - filter = function(action) - return action.kind and action.kind:find("refactor.extract.variable") ~= nil - end, - apply = true, - }) - end, { desc = "Extract selection to a variable" }) - - vim.api.nvim_create_user_command("BasiliskExtractConstant", function() - vim.lsp.buf.code_action({ - filter = function(action) - return action.kind and action.kind:find("refactor.extract.constant") ~= nil - end, - apply = true, - }) - end, { desc = "Extract selection to a module-level constant" }) - - vim.api.nvim_create_user_command("BasiliskConvertUnion", function() - vim.lsp.buf.code_action({ - filter = function(action) - return action.kind and action.kind:find("refactor.rewrite") ~= nil - and (action.title:find("Union") ~= nil or action.title:find("Optional") ~= nil) - end, - apply = false, - }) - end, { desc = "Convert between Union/Optional syntax styles" }) - - vim.api.nvim_create_user_command("BasiliskImplementMethods", function() - vim.lsp.buf.code_action({ - filter = function(action) - return action.kind and action.kind:find("refactor.rewrite.implement") ~= nil - end, - apply = true, - }) - end, { desc = "Implement all abstract methods" }) - - -- Profiling commands. - - vim.api.nvim_create_user_command("BasiliskProfile", function(opts) - local pid = opts.args ~= "" and tonumber(opts.args) or nil - profiling.start(pid) - end, { nargs = "?", desc = "Start profiling" }) - - vim.api.nvim_create_user_command("BasiliskProfileStop", function() - profiling.stop() - end, { desc = "Stop profiling and show results" }) - - vim.api.nvim_create_user_command("BasiliskProfileSnapshot", function() - profiling.snapshot() - end, { desc = "Take profiling snapshot" }) - - -- Memory commands. - - vim.api.nvim_create_user_command("BasiliskMemLeak", function() - memory.start() - end, { desc = "Start memory leak tracking" }) - - vim.api.nvim_create_user_command("BasiliskMemStop", function() - memory.stop() - end, { desc = "Stop memory tracking and show report" }) - - vim.api.nvim_create_user_command("BasiliskMemRefs", function(opts) - memory.refs(opts.args) - end, { - nargs = 1, - desc = "Show memory references for a type", - complete = function(lead) - return memory.complete_refs(lead) - end, - }) - - -- Debug commands. - - vim.api.nvim_create_user_command("BasiliskDebugFile", function() - local dap_ok, dap = pcall(require, "dap") - if not dap_ok then - log.error("nvim-dap required for debugging") - return - end - if not dap.adapters or not dap.adapters.basilisk then - log.warn("basilisk DAP adapter not configured") - return - end - local program = vim.api.nvim_buf_get_name(0) - if program == "" or vim.fn.filereadable(program) ~= 1 then - log.warn("no readable file to debug") - return - end - dap.run({ - type = "basilisk", - request = "launch", - name = "Debug: Current File", - program = program, - justMyCode = true, - cwd = vim.fn.getcwd(), - }) - end, { desc = "Start debugging current file" }) - - -- Test commands. - - vim.api.nvim_create_user_command("BasiliskTestDiscover", function() - testing.discover(config) - end, { desc = "Discover tests" }) - - vim.api.nvim_create_user_command("BasiliskTestRun", function(opts) - local test_id = opts.args ~= "" and opts.args or nil - testing.run(config, test_id) - end, { nargs = "?", desc = "Run test(s)" }) - - vim.api.nvim_create_user_command("BasiliskTestDebug", function(opts) - if opts.args == "" then - log.warn("test ID required for debug") - return - end - testing.debug(config, opts.args) - end, { nargs = 1, desc = "Debug a test" }) - - vim.api.nvim_create_user_command("BasiliskTestToggle", function() - testing.toggle(config) - end, { desc = "Toggle test explorer panel" }) - - -- Activity panel commands. - - local modules_panel = require("basilisk.modules") - local type_health_panel = require("basilisk.type_health") - - vim.api.nvim_create_user_command("BasiliskModules", function() - modules_panel.toggle() - end, { desc = "Toggle module explorer panel" }) - - vim.api.nvim_create_user_command("BasiliskHealth", function() - type_health_panel.toggle() - end, { desc = "Toggle type health panel" }) - - -- uv commands. - - vim.api.nvim_create_user_command("BasiliskUvSync", function() - execute_command("basilisk.uv.sync", {}, function(err) - if err then - log.error("uv sync failed: %s", err.message or tostring(err)) - else - log.info("uv sync complete") - end - end) - end, { desc = "Run uv sync" }) - - vim.api.nvim_create_user_command("BasiliskUvAdd", function(opts) - execute_command("basilisk.uv.add", { { package = opts.args } }, function(err) - if err then - log.error("uv add failed: %s", err.message or tostring(err)) - else - log.info("added package: %s", opts.args) - end - end) - end, { nargs = 1, desc = "Add a package via uv" }) - - vim.api.nvim_create_user_command("BasiliskUvAddDev", function(opts) - execute_command("basilisk.uv.addDev", { { package = opts.args } }, function(err) - if err then - log.error("uv add --dev failed: %s", err.message or tostring(err)) - else - log.info("added dev package: %s", opts.args) - end - end) - end, { nargs = 1, desc = "Add a dev package via uv" }) - - vim.api.nvim_create_user_command("BasiliskUvRemove", function(opts) - execute_command("basilisk.uv.remove", { { package = opts.args } }, function(err) - if err then - log.error("uv remove failed: %s", err.message or tostring(err)) - else - log.info("removed package: %s", opts.args) - end - end) - end, { nargs = 1, desc = "Remove a package via uv" }) - - vim.api.nvim_create_user_command("BasiliskUvLock", function() - execute_command("basilisk.uv.lock", {}, function(err) - if err then - log.error("uv lock failed: %s", err.message or tostring(err)) - else - log.info("uv lock complete") - end - end) - end, { desc = "Run uv lock" }) - - vim.api.nvim_create_user_command("BasiliskUvCreateEnv", function(opts) - local args = {} - if opts.args ~= "" then - args = { { pythonVersion = opts.args } } - end - execute_command("basilisk.uv.createEnv", args, function(err) - if err then - log.error("uv venv failed: %s", err.message or tostring(err)) - else - log.info("virtual environment created") - end - end) - end, { nargs = "?", desc = "Create virtual environment via uv" }) - - -- Set up test auto-discover. - testing.setup_auto_discover(config) -end - -return M diff --git a/basilisk.nvim/lua/basilisk/config.lua b/basilisk.nvim/lua/basilisk/config.lua deleted file mode 100644 index 6341a4336..000000000 --- a/basilisk.nvim/lua/basilisk/config.lua +++ /dev/null @@ -1,140 +0,0 @@ ---- Basilisk configuration defaults and validation. ---- ---- All shared LSP settings are defined in LSP-ARCHITECTURE-SPEC.md and forwarded ---- to the server. Neovim-specific settings are documented here. - -local log = require("basilisk.log") - -local M = {} - ----@class BasiliskInlayHints ----@field parameter_names boolean ----@field variable_types boolean - - ----@class BasiliskDebugger ----@field enabled boolean ----@field type_checking boolean ----@field debugpy_path string - ----@class BasiliskTestExplorer ----@field enabled boolean ----@field framework "auto"|"pytest"|"unittest" ----@field pytest_path string ----@field args string[] ----@field auto_discover_on_save boolean ----@field position "left"|"right"|"bottom" ----@field width integer - ----@class BasiliskUv ----@field enabled boolean ----@field executable_path? string ----@field auto_sync boolean - ----@class BasiliskKeymaps ----@field enabled boolean ----@field prefix string - ----@class BasiliskStatusline ----@field enabled boolean - ----@class BasiliskConfig ----@field binary_path? string ----@field enabled boolean ----@field use_lsp boolean ----@field analysis_mode "openFilesOnly"|"wholeModule"|"crossModule" ----@field python? string ----@field trace_server "off"|"messages"|"verbose" ----@field inlay_hints BasiliskInlayHints ----@field formatter "ruff"|"none" ----@field debugger BasiliskDebugger ----@field test_explorer BasiliskTestExplorer ----@field uv BasiliskUv ----@field keymaps BasiliskKeymaps ----@field statusline BasiliskStatusline ----@field log_level "trace"|"debug"|"info"|"warn"|"error" - ---- Implements [NVIM-NEOVIM-ONLY-CONFIGURATION] — the Neovim-specific settings ---- (keymaps.enabled/prefix, statusline.enabled, test_explorer.position/width, ---- log_level) live here; shared settings are forwarded to the LSP server. ----@type BasiliskConfig -M.defaults = { - binary_path = nil, - enabled = true, - use_lsp = true, - analysis_mode = "wholeModule", - python = nil, - trace_server = "off", - inlay_hints = { - parameter_names = true, - variable_types = true, - }, - -- Formatter engine ([LSPFMT-CONFIG]): "ruff" is the Ruff formatter embedded - -- in the basilisk binary (no external ruff needed); "none" disables it. - formatter = "ruff", - debugger = { - enabled = true, - type_checking = false, - debugpy_path = "debugpy", - }, - test_explorer = { - enabled = true, - framework = "auto", - pytest_path = "pytest", - args = {}, - auto_discover_on_save = true, - position = "right", - width = 40, - }, - uv = { - enabled = true, - executable_path = nil, - auto_sync = false, - }, - keymaps = { - enabled = true, - prefix = "b", - }, - statusline = { - enabled = true, - }, - log_level = "info", -} - ---- Validate the resolved config. ----@param config BasiliskConfig ----@return string[] errors List of validation error messages. -function M.validate(config) - local errors = {} - local valid_modes = { openFilesOnly = true, wholeModule = true, crossModule = true } - if not valid_modes[config.analysis_mode] then - errors[#errors + 1] = "invalid analysis_mode: " .. tostring(config.analysis_mode) - end - local valid_frameworks = { auto = true, pytest = true, unittest = true } - if not valid_frameworks[config.test_explorer.framework] then - errors[#errors + 1] = "invalid test_explorer.framework: " .. tostring(config.test_explorer.framework) - end - local valid_positions = { left = true, right = true, bottom = true } - if not valid_positions[config.test_explorer.position] then - errors[#errors + 1] = "invalid test_explorer.position: " .. tostring(config.test_explorer.position) - end - local valid_levels = { trace = true, debug = true, info = true, warn = true, error = true } - if not valid_levels[config.log_level] then - errors[#errors + 1] = "invalid log_level: " .. tostring(config.log_level) - end - return errors -end - ---- Merge user options with defaults and validate. ----@param opts? table ----@return BasiliskConfig -function M.resolve(opts) - local config = vim.tbl_deep_extend("force", {}, M.defaults, opts or {}) - local errors = M.validate(config) - for _, err in ipairs(errors) do - log.error("config error: %s", err) - end - return config -end - -return M diff --git a/basilisk.nvim/lua/basilisk/dap.lua b/basilisk.nvim/lua/basilisk/dap.lua deleted file mode 100644 index 7e387c3b3..000000000 --- a/basilisk.nvim/lua/basilisk/dap.lua +++ /dev/null @@ -1,324 +0,0 @@ ---- DAP integration for Basilisk. ---- ---- Registers an nvim-dap adapter that communicates with the basilisk LSP ---- to spawn debugpy sessions. Implements DapTcpProxy using vim.uv (libuv). ---- ---- Implements [NVIM-DAP-INTEGRATION] — detects nvim-dap at runtime via ---- pcall(require, 'dap') and degrades gracefully when it is absent. - -local log = require("basilisk.log") -local ui = require("basilisk.ui") - -local M = {} - ---- Content-Length header pattern for DAP message framing. -local CONTENT_LENGTH_PATTERN = "^Content%-Length: (%d+)\r\n\r\n" - ---- Parse a DAP message from a buffer. ----@param data string Raw data buffer. ----@return table? message Parsed JSON message, or nil if incomplete. ----@return string remaining Remaining unparsed data. -local function parse_dap_message(data) - local len_str = data:match(CONTENT_LENGTH_PATTERN) - if not len_str then - return nil, data - end - - local header_end = data:find("\r\n\r\n", 1, true) - if not header_end then - return nil, data - end - - local content_start = header_end + 4 - local content_length = tonumber(len_str) - if #data < content_start + content_length - 1 then - return nil, data - end - - local body = data:sub(content_start, content_start + content_length - 1) - local remaining = data:sub(content_start + content_length) - local ok, msg = pcall(vim.json.decode, body) - if not ok then - log.error("DAP message parse error: %s", tostring(msg)) - return nil, remaining - end - return msg, remaining -end - ---- Frame a DAP message with Content-Length header. ----@param msg table JSON-serializable message. ----@return string framed Framed message with header. -local function frame_dap_message(msg) - local body = vim.json.encode(msg) - return string.format("Content-Length: %d\r\n\r\n%s", #body, body) -end - ---- Check whether a line is structural (try:, with:, if:, etc.) ----@param msg table DAP message. ----@return boolean -local function is_structural_step_out(msg) - -- This interception is handled at the proxy level by inspecting - -- stepOut responses — the actual line classification happens - -- server-side. The proxy injects an auto-next if needed. - return msg.command == "stepOut" -end - ---- Create and start a DapTcpProxy. ---- Implements [NVIM-DAP-INTEGRATION-DAP-TCP-PROXY] — vim.uv.new_tcp() socket pair ---- with Content-Length header framing and the DAP interception rules. ----@param remote_host string ----@param remote_port integer ----@param callback fun(proxy_port: integer) Called with the local proxy port. -function M.create_proxy(remote_host, remote_port, callback) - local server = vim.uv.new_tcp() - local client_conn = nil - local remote_conn = nil - local client_buf = "" - local remote_buf = "" - local terminated = false - - server:bind("127.0.0.1", 0) - server:listen(1, function(listen_err) - if listen_err then - log.error("DapTcpProxy listen error: %s", listen_err) - return - end - - client_conn = vim.uv.new_tcp() - server:accept(client_conn) - - -- Connect to the remote debugpy. - remote_conn = vim.uv.new_tcp() - remote_conn:connect(remote_host, remote_port, function(connect_err) - if connect_err then - log.error("DapTcpProxy connect error: %s", connect_err) - return - end - - -- Relay: client -> remote (with interception). - client_conn:read_start(function(err, data) - if err or not data then - if remote_conn and not remote_conn:is_closing() then - remote_conn:close() - end - return - end - client_buf = client_buf .. data - while true do - local msg, rest = parse_dap_message(client_buf) - if not msg then - break - end - client_buf = rest - - -- Intercept stepOut for structural lines. - if is_structural_step_out(msg) then - log.debug("DapTcpProxy: intercepting stepOut, will inject next") - end - - -- Fast disconnect post-termination. - if terminated and msg.command == "disconnect" then - local response = { - type = "response", - request_seq = msg.seq, - success = true, - command = "disconnect", - seq = 0, - } - client_conn:write(frame_dap_message(response)) - -- Do not forward — session is already terminated. - else - remote_conn:write(frame_dap_message(msg)) - end - end - end) - - -- Relay: remote -> client (with interception). - remote_conn:read_start(function(err, data) - if err or not data then - if client_conn and not client_conn:is_closing() then - client_conn:close() - end - return - end - remote_buf = remote_buf .. data - while true do - local msg, rest = parse_dap_message(remote_buf) - if not msg then - break - end - remote_buf = rest - - -- Track terminated state. - if msg.event == "terminated" then - terminated = true - end - - -- Inject exited event before terminated if missing. - if msg.event == "terminated" then - local exited = { - type = "event", - event = "exited", - seq = 0, - body = { exitCode = 0 }, - } - client_conn:write(frame_dap_message(exited)) - end - - client_conn:write(frame_dap_message(msg)) - end - end) - end) - end) - - local addr = server:getsockname() - callback(addr.port) -end - ---- Set up DAP integration. ----@param config BasiliskConfig -function M.setup(config) - local dap_ok, dap = pcall(require, "dap") - if not dap_ok then - log.debug("nvim-dap not found, skipping DAP setup") - return - end - - if not config.debugger.enabled then - log.debug("debugger disabled in config, skipping DAP setup") - return - end - - -- Register the basilisk DAP adapter. - -- Implements [NVIM-DAP-INTEGRATION-ADAPTER-REGISTRATION] — sends - -- basilisk.startDebugSession to the LSP, then points nvim-dap at the local - -- DapTcpProxy port returned by create_proxy. - dap.adapters.basilisk = function(callback, dap_config) - local client = ui.get_client() - if not client then - log.error("no active basilisk LSP client for debug session") - return - end - - local uri = vim.uri_from_bufnr(vim.api.nvim_get_current_buf()) - client:request("workspace/executeCommand", { - command = "basilisk.startDebugSession", - arguments = { { uri = uri, pythonPath = config.python } }, - }, function(err, result) - if err then - log.error("startDebugSession failed: %s", err.message or tostring(err)) - return - end - if not result then - log.error("startDebugSession returned nil") - return - end - - -- libuv TCP requires a numeric IP, not a hostname. - local raw_host = result.host or "127.0.0.1" - local host = (raw_host == "localhost") and "127.0.0.1" or raw_host - local port = result.port - local debug_session_id = result.sessionId - - M.create_proxy(host, port, function(proxy_port) - vim.schedule(function() - callback({ - type = "server", - host = "127.0.0.1", - port = proxy_port, - options = { - disconnect_timeout_sec = 3, - }, - }) - end) - end) - - -- Store session ID for cleanup. - M._active_session_id = debug_session_id - end, 0) - end - - -- Default launch configurations. - -- Implements [NVIM-DAP-INTEGRATION-DEFAULT-CONFIGURATIONS] — adds the launch - -- (Current File) and attach (port 5678) configurations the spec documents. - if not dap.configurations.python or #dap.configurations.python == 0 then - dap.configurations.python = {} - end - - -- Add basilisk configurations if not already present. - local has_basilisk_launch = false - local has_basilisk_attach = false - for _, conf in ipairs(dap.configurations.python) do - if conf.type == "basilisk" and conf.request == "launch" then - has_basilisk_launch = true - end - if conf.type == "basilisk" and conf.request == "attach" then - has_basilisk_attach = true - end - end - - if not has_basilisk_launch then - dap.configurations.python[#dap.configurations.python + 1] = { - type = "basilisk", - request = "launch", - name = "Python: Current File (Basilisk)", - program = "${file}", - justMyCode = true, - redirectOutput = true, - console = "integratedTerminal", - } - end - - if not has_basilisk_attach then - dap.configurations.python[#dap.configurations.python + 1] = { - type = "basilisk", - request = "attach", - name = "Python: Attach (Basilisk)", - connect = { host = "127.0.0.1", port = 5678 }, - } - end - - -- Optional integrations. - -- Implements [NVIM-DAP-INTEGRATION-OPTIONAL-INTEGRATIONS] — nvim-dap-ui - -- auto open/close on initialized/terminated, and nvim-dap-virtual-text. - local dapui_ok, dapui = pcall(require, "dapui") - if dapui_ok then - dap.listeners.after.event_initialized["basilisk"] = function() - dapui.open() - end - dap.listeners.before.event_terminated["basilisk"] = function() - dapui.close() - end - dap.listeners.before.event_exited["basilisk"] = function() - dapui.close() - end - end - - -- Optional: nvim-dap-virtual-text. - local vtext_ok, vtext = pcall(require, "nvim-dap-virtual-text") - if vtext_ok then - vtext.setup() - end - - log.debug("DAP setup complete") -end - ---- Stop the active debug session. -function M.stop_session() - local client = ui.get_client() - if not client or not M._active_session_id then - return - end - - client:request("workspace/executeCommand", { - command = "basilisk.stopDebugSession", - arguments = { { sessionId = M._active_session_id } }, - }, function(err) - if err then - log.error("stopDebugSession failed: %s", err.message or tostring(err)) - end - M._active_session_id = nil - end, 0) -end - -return M diff --git a/basilisk.nvim/lua/basilisk/health.lua b/basilisk.nvim/lua/basilisk/health.lua index ba138fdbc..281d111b9 100644 --- a/basilisk.nvim/lua/basilisk/health.lua +++ b/basilisk.nvim/lua/basilisk/health.lua @@ -1,111 +1,15 @@ ---- Health check for :checkhealth basilisk. ---- ---- Reports on binary availability, Python interpreter, ---- optional integrations, and configuration summary. +--- `:checkhealth basilisk` — reports the withdrawal and nothing else. +--- Implements [WITHDRAWAL-SURFACES]. -local binary = require("basilisk.binary") +local notice = require("basilisk.notice") local M = {} ---- Implements [NVIM-HEALTH-CHECK] — :checkhealth basilisk reports Neovim version, ---- the basilisk binary + version, Python, and the optional debugpy/nvim-dap/ ---- nvim-dap-ui integrations (plus uv and a config summary). Formatting is ---- embedded in the binary — no external ruff probe ([LSPFMT-DECISION]). -function M.check() - vim.health.start("basilisk.nvim") - - -- Neovim version. The plugin drives vim.lsp.config()/vim.lsp.enable(), - -- which were added in Neovim 0.11 (see :h news-0.11). - if vim.fn.has("nvim-0.11") == 1 then - vim.health.ok("Neovim >= 0.11") - else - vim.health.error("Neovim >= 0.11 required", { "Upgrade Neovim to 0.11 or later." }) - end - - -- Basilisk binary. Forward the configured binary_path so the cascade's - -- first step (setup({ binary_path = ... })) is honored — otherwise a binary - -- reachable only via config is falsely reported as not found (issue #67). - local cfg_ok, basilisk_cfg = pcall(require, "basilisk") - local configured_path = cfg_ok and basilisk_cfg.config and basilisk_cfg.config.binary_path or nil - local bin = binary.resolve(configured_path) - if bin then - local ver = binary.version(bin) or "unknown" - vim.health.ok("basilisk binary found: " .. bin .. " (" .. ver .. ")") - else - vim.health.error("basilisk binary not found", { - "Run :BasiliskInstall to download the latest release", - "Or install with: brew install basilisk / scoop install basilisk / uv tool install basilisk-python", - "Or set vim.env.BASILISK_PATH", - }) - end - - -- Python interpreter. - local python = vim.fn.exepath("python3") - if python == "" then - python = vim.fn.exepath("python") - end - if python ~= "" then - local py_ver = vim.trim(vim.fn.system({ python, "--version" })) - vim.health.ok("Python found: " .. python .. " (" .. py_ver .. ")") - else - vim.health.warn("Python not found on PATH", { - "Some features (debugging, testing) require Python.", - }) - end - - -- debugpy (optional). - if python ~= "" then - local result = vim.fn.system({ python, "-c", "import debugpy; print(debugpy.__version__)" }) - if vim.v.shell_error == 0 then - vim.health.ok("debugpy installed: " .. vim.trim(result)) - else - vim.health.info("debugpy not installed (optional, for debugging)") - end - end - - -- nvim-dap (optional). - local dap_ok = pcall(require, "dap") - if dap_ok then - vim.health.ok("nvim-dap available") - else - vim.health.info("nvim-dap not installed (optional, for debugging)") - end - - -- nvim-dap-ui (optional). - local dapui_ok = pcall(require, "dapui") - if dapui_ok then - vim.health.ok("nvim-dap-ui available") - else - vim.health.info("nvim-dap-ui not installed (optional, for debug UI)") - end - - -- Formatting needs no external tool: the Ruff formatter is embedded in the - -- basilisk binary ([LSPFMT-DECISION]), so there is no ruff probe here. - - -- uv (optional). - local uv = vim.fn.exepath("uv") - if uv ~= "" then - local uv_ver = vim.trim(vim.fn.system({ uv, "--version" })) - vim.health.ok("uv found: " .. uv .. " (" .. uv_ver .. ")") - else - vim.health.info("uv not found (optional, for package management)") - end - - -- Configuration summary. - vim.health.start("basilisk.nvim configuration") - local basilisk_ok, basilisk = pcall(require, "basilisk") - if basilisk_ok and basilisk.config then - local cfg = basilisk.config - vim.health.ok("Analysis mode: " .. cfg.analysis_mode) - vim.health.ok("Formatter: " .. cfg.formatter) - vim.health.ok("Debugger: " .. (cfg.debugger.enabled and "enabled" or "disabled")) - vim.health.ok("Test explorer: " .. (cfg.test_explorer.enabled and "enabled" or "disabled")) - vim.health.ok("uv integration: " .. (cfg.uv.enabled and "enabled" or "disabled")) - vim.health.ok("Keymaps: " .. (cfg.keymaps.enabled and "enabled" or "disabled")) - vim.health.ok("Log level: " .. cfg.log_level) - else - vim.health.info("basilisk.setup() has not been called yet") - end +--- @param reporter table|nil injected for tests; defaults to `vim.health` +function M.check(reporter) + local health = reporter or vim.health + health.start("basilisk.nvim") + health.warn("Basilisk is unlisted and its type checker is inert.", notice.lines) end return M diff --git a/basilisk.nvim/lua/basilisk/info.lua b/basilisk.nvim/lua/basilisk/info.lua deleted file mode 100644 index 0911f8478..000000000 --- a/basilisk.nvim/lua/basilisk/info.lua +++ /dev/null @@ -1,120 +0,0 @@ ---- Info panel for Basilisk. ---- ---- Displays server status, binary version, Python interpreter, analysis mode, ---- and enabled integrations in a floating window. Follows the same module ---- pattern as `modules.lua` and `type_health.lua`. - -local ui = require("basilisk.ui") - -local M = {} - ----@type integer? -local info_buf = nil ----@type integer? -local info_win = nil - ---- Build info lines from config and LSP client state. ----@param config BasiliskConfig ----@return string[] ----@return table[] highlights { line, col_start, col_end, hl_group } -local function render_info(config) - local binary_mod = require("basilisk.binary") - local lsp_mod = require("basilisk.lsp") - local client = ui.get_client() - - local bin = binary_mod.resolve(config.binary_path) - local version = bin and binary_mod.version(bin) or "unknown" - - local lines = { - "Basilisk LSP Server Info", - "", - } - local highlights = {} - - if client then - lines[#lines + 1] = " Status: active" - highlights[#highlights + 1] = { - line = #lines - 1, - col_start = 14, - col_end = 20, - hl_group = "DiagnosticOk", - } - lines[#lines + 1] = " Client ID: " .. tostring(client.id) - lines[#lines + 1] = " Root: " .. (client.root_dir or "nil") - else - lines[#lines + 1] = " Status: stopped" - highlights[#highlights + 1] = { - line = #lines - 1, - col_start = 14, - col_end = 21, - hl_group = "DiagnosticError", - } - end - - lines[#lines + 1] = "" - lines[#lines + 1] = " Binary: " .. (bin or "not found") - lines[#lines + 1] = " Version: " .. version - lines[#lines + 1] = " Python: " .. (config.python or "auto-detect") - lines[#lines + 1] = " Mode: " .. config.analysis_mode - lines[#lines + 1] = " Restarts: " .. tostring(lsp_mod.get_restart_count()) - lines[#lines + 1] = "" - lines[#lines + 1] = " Formatter: " .. config.formatter - lines[#lines + 1] = " Debugger: " .. (config.debugger.enabled and "enabled" or "disabled") - lines[#lines + 1] = " Tests: " .. (config.test_explorer.enabled and "enabled" or "disabled") - lines[#lines + 1] = " uv: " .. (config.uv.enabled and "enabled" or "disabled") - - return lines, highlights -end - ---- Apply highlights to a buffer. ----@param buf integer ----@param highlights table[] -local function apply_highlights(buf, highlights) - local ns = vim.api.nvim_create_namespace("basilisk_info") - for _, hl in ipairs(highlights) do - vim.api.nvim_buf_add_highlight(buf, ns, hl.hl_group, hl.line, hl.col_start, hl.col_end) - end -end - ---- Show the info panel. ----@param config BasiliskConfig -function M.show(config) - -- Close existing float first. - if info_win and vim.api.nvim_win_is_valid(info_win) then - vim.api.nvim_win_close(info_win, true) - end - - local lines, highlights = render_info(config) - info_buf, info_win = ui.open_float("Basilisk Info", lines) - apply_highlights(info_buf, highlights) - - -- Add refresh keybinding. - vim.keymap.set("n", "r", function() - M.refresh(config) - end, { buffer = info_buf }) -end - ---- Refresh the info panel in-place (if open). ----@param config BasiliskConfig -function M.refresh(config) - if not info_buf or not vim.api.nvim_buf_is_valid(info_buf) then - return - end - - local lines, highlights = render_info(config) - vim.bo[info_buf].modifiable = true - vim.api.nvim_buf_set_lines(info_buf, 0, -1, false, lines) - vim.bo[info_buf].modifiable = false - apply_highlights(info_buf, highlights) -end - ---- Close the info panel. -function M.close() - if info_win and vim.api.nvim_win_is_valid(info_win) then - vim.api.nvim_win_close(info_win, true) - end - info_buf = nil - info_win = nil -end - -return M diff --git a/basilisk.nvim/lua/basilisk/init.lua b/basilisk.nvim/lua/basilisk/init.lua index bf6e90b60..3f4315dd5 100644 --- a/basilisk.nvim/lua/basilisk/init.lua +++ b/basilisk.nvim/lua/basilisk/init.lua @@ -1,135 +1,31 @@ ---- Basilisk — a strict-by-default Python type checker for Neovim. +--- Basilisk for Neovim — a notice. Implements [WITHDRAWAL-SURFACES]; see +--- docs/specs/DOCS-WITHDRAWAL-MESSAGING-SPEC.md#WITHDRAWAL-SURFACES --- ---- Entry point: require('basilisk').setup({}) ---- Zero-config works out of the box. +--- The type checker was producing incorrect results, so this plugin starts no +--- language server, registers no command, and configures nothing. It exists so +--- an installed copy tells its owner what happened. The statement itself is +--- generated into notice.lua from the messaging spec and drift-gated in CI. -local config_mod = require("basilisk.config") -local lsp = require("basilisk.lsp") -local commands = require("basilisk.commands") -local log = require("basilisk.log") +local notice = require("basilisk.notice") local M = {} ---- Resolved configuration (populated after setup). ----@type BasiliskConfig? -M.config = nil - ---- Whether setup() has been called. -local did_setup = false - ---- Register LSP command handlers for custom commands. ---- Implements [NVIM-LSP-CLIENT-CONFIGURATION-CUSTOM-COMMANDS] — installs ---- vim.lsp.commands handlers for server-advertised commands (the server is the ---- single source of truth; the plugin never registers a command it does not own). -local function register_lsp_commands() - vim.lsp.commands["basilisk.organizeImports"] = function(cmd, ctx) - local edit = cmd.edit or cmd.arguments and cmd.arguments[1] - if edit then - vim.lsp.util.apply_workspace_edit(edit, "utf-8") - end - end +--- The approved statement, as one string. +function M.notice() + return notice.text end ---- Register notification handlers for basilisk/* server-push notifications. -local function register_notification_handlers() - vim.lsp.handlers["basilisk/moduleChanged"] = function(_err, _result, _ctx, _config) - local modules_ok, modules_panel = pcall(require, "basilisk.modules") - if modules_ok then - modules_panel.refresh() - end - local health_ok, health_panel = pcall(require, "basilisk.type_health") - if health_ok then - health_panel.refresh() - end - end - - -- Profiler progress: update statusline while a session is running. - vim.lsp.handlers["basilisk/profiler/progress"] = function(_err, result, _ctx, _config) - if not result then - return - end - local statusline = require("basilisk.statusline") - local samples = result.totalSamples or 0 - local elapsed = result.elapsedSeconds or 0 - local pid = result.pid or 0 - log.info("profiling PID %d: %ds, %d samples", pid, elapsed, samples) - statusline.set_profiler_status(result) - end - - -- Memory timeline: periodic snapshot data during auto-snapshot mode. - vim.lsp.handlers["basilisk/memory/timeline"] = function(_err, result, _ctx, _config) - if not result then - return - end - log.info( - "memory timeline: current=%d peak=%d", - result.currentMemory or 0, - result.peakMemory or 0 - ) - end +--- Show the statement. A warning, not information: a type checker that stopped +--- checking is a change to the user's setup, not a tip. +function M.announce(notify) + local emit = notify or vim.notify + emit(notice.text, vim.log.levels.WARN, { title = "Basilisk is unlisted" }) end ---- Set up default keymaps for activity panels. ----@param cfg BasiliskConfig -local function register_keymaps(cfg) - if not cfg.keymaps.enabled then - return - end - local prefix = cfg.keymaps.prefix or "b" - vim.keymap.set("n", prefix .. "m", "BasiliskModules", { desc = "Toggle Basilisk Module Explorer" }) - vim.keymap.set("n", prefix .. "h", "BasiliskHealth", { desc = "Toggle Basilisk Type Health" }) - vim.keymap.set("n", prefix .. "i", "BasiliskInfo", { desc = "Show Basilisk Server Info" }) -end - ---- Set up Basilisk with the given options. ----@param opts? table User configuration (merged with defaults). -function M.setup(opts) - if did_setup then - return - end - did_setup = true - - M.config = config_mod.resolve(opts) - - -- Configure logging. - log.set_level(M.config.log_level) - log.info("setup started") - - -- Register custom LSP command handlers. - register_lsp_commands() - - -- Register module change notification handler. - register_notification_handlers() - - -- Start the LSP client. - local started = lsp.start(M.config) - - -- Check for updates asynchronously after successful start. - if started then - local bin = require("basilisk.binary") - local bin_path = bin.resolve(M.config.binary_path) - if bin_path then - bin.check_for_updates(bin_path) - end - end - - -- Register user commands. - commands.register(M.config) - - -- Register default keymaps for activity panels. - register_keymaps(M.config) - - -- Register DAP adapter if nvim-dap is available. - local dap_ok, dap_mod = pcall(require, "basilisk.dap") - if dap_ok then - dap_mod.setup(M.config) - end - - -- Set up tab tracking for openFilesOnly mode. - local tab_tracking = require("basilisk.tab_tracking") - tab_tracking.setup(M.config) - - log.info("setup complete") +--- Kept so an existing `require('basilisk').setup{...}` does not error on +--- startup. It accepts anything and configures nothing. +function M.setup(_opts, notify) + M.announce(notify) end return M diff --git a/basilisk.nvim/lua/basilisk/log.lua b/basilisk.nvim/lua/basilisk/log.lua deleted file mode 100644 index c11f07d3a..000000000 --- a/basilisk.nvim/lua/basilisk/log.lua +++ /dev/null @@ -1,103 +0,0 @@ ---- Logger for basilisk.nvim. ---- ---- Wraps vim.notify with configurable log levels and optional file logging. - -local M = {} - -local default_notify = vim.notify - ---- Log level names to vim.log.levels mapping. ----@type table -local LEVELS = { - trace = vim.log.levels.TRACE, - debug = vim.log.levels.DEBUG, - info = vim.log.levels.INFO, - warn = vim.log.levels.WARN, - error = vim.log.levels.ERROR, -} - ---- Current minimum log level. ----@type integer -local min_level = vim.log.levels.INFO - ---- Optional file handle for file logging. ----@type file*? -local log_file = nil - ---- Resolve the notification level for the current Neovim context. ----@param level integer vim.log.levels.* ----@return integer -local function notify_level(level) - if - level == vim.log.levels.ERROR - and vim.notify == default_notify - and #vim.api.nvim_list_uis() == 0 - then - return vim.log.levels.WARN - end - return level -end - ---- Set the minimum log level. ----@param level string One of "trace", "debug", "info", "warn", "error". -function M.set_level(level) - local resolved = LEVELS[level] - if resolved then - min_level = resolved - end -end - ---- Enable file logging to the given path. ----@param path string -function M.enable_file(path) - if log_file then - log_file:close() - end - log_file = io.open(path, "a") -end - ---- Close the log file if open. -function M.close_file() - if log_file then - log_file:close() - log_file = nil - end -end - ---- Log a message at the given level. ----@param level integer vim.log.levels.* ----@param fmt string Format string. ----@param ... any Format arguments. -local function log(level, fmt, ...) - if level < min_level then - return - end - local msg = string.format(fmt, ...) - vim.notify("[basilisk] " .. msg, notify_level(level)) - if log_file then - log_file:write(string.format("%s [%s] %s\n", os.date("%Y-%m-%d %H:%M:%S"), level, msg)) - log_file:flush() - end -end - -function M.trace(fmt, ...) - log(vim.log.levels.TRACE, fmt, ...) -end - -function M.debug(fmt, ...) - log(vim.log.levels.DEBUG, fmt, ...) -end - -function M.info(fmt, ...) - log(vim.log.levels.INFO, fmt, ...) -end - -function M.warn(fmt, ...) - log(vim.log.levels.WARN, fmt, ...) -end - -function M.error(fmt, ...) - log(vim.log.levels.ERROR, fmt, ...) -end - -return M diff --git a/basilisk.nvim/lua/basilisk/lsp.lua b/basilisk.nvim/lua/basilisk/lsp.lua deleted file mode 100644 index fdf014eb9..000000000 --- a/basilisk.nvim/lua/basilisk/lsp.lua +++ /dev/null @@ -1,260 +0,0 @@ ---- LSP client configuration and lifecycle management. ---- ---- Uses Neovim 0.11+ built-in LSP client (vim.lsp.config / vim.lsp.enable). ---- All 21 core LSP features are native — zero custom implementation needed. - -local binary = require("basilisk.binary") -local log = require("basilisk.log") - -local M = {} - ---- Maximum automatic restart attempts before giving up. -local MAX_RESTARTS = 3 - ---- Restart backoff delays in milliseconds: 1s, 2s, 4s. -local BACKOFF_MS = { 1000, 2000, 4000 } - ---- Track restart state. -local restart_count = 0 - ---- Build the LSP settings table from the resolved config. ----@param config BasiliskConfig ----@return table -local function build_settings(config) - return { - basilisk = { - enabled = config.enabled, - python = config.python, - analysisMode = config.analysis_mode, - inlayHints = { - parameterNames = config.inlay_hints.parameter_names, - variableTypes = config.inlay_hints.variable_types, - }, - formatter = config.formatter, - debugger = { - enabled = config.debugger.enabled, - typeChecking = config.debugger.type_checking, - debugpyPath = config.debugger.debugpy_path, - }, - testExplorer = { - enabled = config.test_explorer.enabled, - framework = config.test_explorer.framework, - pytestPath = config.test_explorer.pytest_path, - args = config.test_explorer.args, - autoDiscoverOnSave = config.test_explorer.auto_discover_on_save, - }, - uv = { - enabled = config.uv.enabled, - executablePath = config.uv.executable_path, - autoSync = config.uv.auto_sync, - }, - }, - } -end - ---- Map LSP message types to Neovim notification levels. ----@param message_type integer? ----@return integer -local function lsp_message_level(message_type) - local message_types = vim.lsp.protocol.MessageType - if message_type == message_types.Error then - return vim.log.levels.ERROR - end - if message_type == message_types.Warning then - return vim.log.levels.WARN - end - return vim.log.levels.INFO -end - ---- Display server showMessage notifications through the plugin logger. ----@param _err lsp.ResponseError? ----@param result lsp.ShowMessageParams? -local function handle_show_message(_err, result) - if not result or type(result.message) ~= "string" or result.message == "" then - return - end - local message = result.message:gsub("^Basilisk:%s*", "") - local level = lsp_message_level(result.type) - if level == vim.log.levels.ERROR then - log.error("%s", message) - elseif level == vim.log.levels.WARN then - log.warn("%s", message) - else - log.info("%s", message) - end -end - ---- Route server logMessage notifications without using Neovim's headless error channel. ----@param _err lsp.ResponseError? ----@param result lsp.LogMessageParams? -local function handle_log_message(_err, result) - if not result or type(result.message) ~= "string" or result.message == "" then - return - end - local message = result.message:gsub("^Basilisk:%s*", "") - local level = lsp_message_level(result.type) - if level == vim.log.levels.ERROR then - log.error("%s", message) - elseif level == vim.log.levels.WARN then - log.warn("%s", message) - else - log.debug("%s", message) - end -end - ---- Root-level configuration documents the server may edit on the user's behalf. ---- Matches the single discovery target in [CONFIGEDITOR-SOURCES]: the server ---- only ever edits `pyproject.toml` (`[tool.basilisk]`); a stray `basilisk.json` ---- is reported as shadowed, never read or edited. -local CONFIG_BASENAMES = { ["pyproject.toml"] = true } - ---- Collect the file paths a WorkspaceEdit touches, across both encodings. ---- Handles `changes` (uri → edits) and `documentChanges` operations ---- (`TextDocumentEdit` and `Create`/`Rename`/`Delete` resource ops). ----@param edit table? lsp.WorkspaceEdit ----@return string[] paths Absolute filesystem paths, deduplicated. -local function collect_edit_paths(edit) - local seen = {} - local function add(uri) - if type(uri) == "string" and uri ~= "" then - seen[vim.uri_to_fname(uri)] = true - end - end - if type(edit) ~= "table" then - return {} - end - for uri in pairs(edit.changes or {}) do - add(uri) - end - for _, change in ipairs(edit.documentChanges or {}) do - add(change.uri or (change.textDocument and change.textDocument.uri)) - end - return vim.tbl_keys(seen) -end - ---- Persist a config-file buffer to disk after the server edited it. ---- Implements [CONFIGEDITOR-SOURCES]: a closed-source apply must become ---- "visible on disk" so the server's in-memory overlay can retire. Neovim's ---- default `workspace/applyEdit` only touches the buffer, so config documents ---- the user never opened stay unsaved without this explicit write. ----@param path string Absolute filesystem path of an edited document. -local function persist_config_document(path) - if not CONFIG_BASENAMES[vim.fn.fnamemodify(path, ":t")] then - return - end - local bufnr = vim.fn.bufnr(path) - if bufnr < 0 or not vim.api.nvim_buf_is_loaded(bufnr) or not vim.bo[bufnr].modified then - return - end - vim.api.nvim_buf_call(bufnr, function() - vim.cmd("silent noautocmd keepalt write") - end) -end - ---- Apply a server-initiated `workspace/applyEdit`, then persist any edited ---- root configuration document to disk. Delegates the buffer edit to Neovim's ---- built-in handler so open buffers, undo, and position encoding stay correct. ----@param err lsp.ResponseError? ----@param params lsp.ApplyWorkspaceEditParams ----@param ctx lsp.HandlerContext ----@return lsp.ApplyWorkspaceEditResult -local function handle_apply_edit(err, params, ctx) - local result = vim.lsp.handlers["workspace/applyEdit"](err, params, ctx) - if result and result.applied then - for _, path in ipairs(collect_edit_paths(params and params.edit)) do - persist_config_document(path) - end - end - return result -end - ---- Install Basilisk message handlers on already-running clients. -function M.install_handlers() - for _, client in ipairs(vim.lsp.get_clients({ name = "basilisk" })) do - client.handlers = client.handlers or {} - client.handlers["window/logMessage"] = handle_log_message - client.handlers["window/showMessage"] = handle_show_message - client.handlers["workspace/applyEdit"] = handle_apply_edit - end -end - ---- Configure and enable the basilisk LSP client. ---- Implements [NVIM-LSP-CLIENT-CONFIGURATION] — vim.lsp.config + vim.lsp.enable ---- with cmd/filetypes/root_markers/settings exactly as the spec documents. ----@param config BasiliskConfig ----@return boolean success -function M.start(config) - if config.binary_path and config.binary_path ~= "" and not binary.is_executable(config.binary_path) then - log.error("binary not found: %s", config.binary_path) - return false - end - - local bin = binary.resolve(config.binary_path) - if not bin then - log.error("binary not found. Run :BasiliskInstall to download the latest release") - return false - end - - vim.lsp.config("basilisk", { - cmd = { bin, "lsp" }, - filetypes = { "python" }, - root_markers = { "pyproject.toml", "setup.py", "setup.cfg", ".git" }, - settings = build_settings(config), - handlers = { - ["window/logMessage"] = handle_log_message, - ["window/showMessage"] = handle_show_message, - ["workspace/applyEdit"] = handle_apply_edit, - }, - init_options = { - analysisMode = config.analysis_mode, - }, - }) - - vim.lsp.enable("basilisk") - M.install_handlers() - restart_count = 0 - return true -end - ---- Restart the LSP server, respecting the backoff policy. ---- Implements [NVIM-LSP-CLIENT-CONFIGURATION-ERROR-RECOVERY] — auto-restart up to ---- MAX_RESTARTS with 1s/2s/4s exponential backoff; :BasiliskRestart forces a reset. ----@param config BasiliskConfig ----@param force? boolean Bypass the restart limit. -function M.restart(config, force) - if force then - restart_count = 0 - end - - if restart_count >= MAX_RESTARTS then - log.error("max restarts reached (%d). Use :BasiliskRestart to force.", MAX_RESTARTS) - return - end - - local delay = BACKOFF_MS[restart_count + 1] or BACKOFF_MS[#BACKOFF_MS] - restart_count = restart_count + 1 - - vim.defer_fn(function() - -- Stop all basilisk clients. - for _, client in ipairs(vim.lsp.get_clients({ name = "basilisk" })) do - client:stop() - end - -- Re-start after a tick so the stop completes. - vim.defer_fn(function() - M.start(config) - end, 100) - end, delay) -end - ---- Reset the restart counter (called by :BasiliskRestart). -function M.reset_restart_count() - restart_count = 0 -end - ---- Get the current restart count (for statusline). ----@return integer -function M.get_restart_count() - return restart_count -end - -return M diff --git a/basilisk.nvim/lua/basilisk/memory.lua b/basilisk.nvim/lua/basilisk/memory.lua deleted file mode 100644 index 5b83790e0..000000000 --- a/basilisk.nvim/lua/basilisk/memory.lua +++ /dev/null @@ -1,179 +0,0 @@ ---- Memory tracking commands for Basilisk. ---- ---- Sends LSP memory commands and displays leak reports and retention ---- paths in floating windows. - -local log = require("basilisk.log") -local ui = require("basilisk.ui") - -local M = {} - ---- Active memory tracking session ID. ----@type string? -local session_id = nil - ---- Common types for :BasiliskMemRefs completion. -local COMMON_TYPES = { - "DataFrame", - "Series", - "Tensor", - "ndarray", - "dict", - "list", - "set", - "tuple", - "str", - "bytes", - "int", - "float", -} - ---- Start memory leak tracking. -function M.start() - local client = ui.get_client() - if not client then - log.warn("no active LSP client") - return - end - - client:request("workspace/executeCommand", { - command = "basilisk.memory.start", - arguments = {}, - }, function(err, result) - if err then - log.error("memory start failed: %s", err.message or tostring(err)) - return - end - if result and result.sessionId then - session_id = result.sessionId - end - log.info("memory tracking started") - end, 0) -end - ---- Stop memory tracking and display leak report. -function M.stop() - local client = ui.get_client() - if not client then - log.warn("no active LSP client") - return - end - - local args = {} - if session_id then - args = { { sessionId = session_id } } - end - - client:request("workspace/executeCommand", { - command = "basilisk.memory.diff", - arguments = args, - }, function(err, result) - if err then - log.error("memory stop failed: %s", err.message or tostring(err)) - return - end - session_id = nil - vim.schedule(function() - M.display_leak_report(result) - end) - end, 0) -end - ---- Query retention paths for a type. ----@param type_name string -function M.refs(type_name) - local client = ui.get_client() - if not client then - log.warn("no active LSP client") - return - end - - client:request("workspace/executeCommand", { - command = "basilisk.memory.references", - arguments = { { typeName = type_name } }, - }, function(err, result) - if err then - log.error("memory refs failed: %s", err.message or tostring(err)) - return - end - vim.schedule(function() - M.display_retention_paths(type_name, result) - end) - end, 0) -end - ---- Display a leak report in a floating window. ---- Implements [NVIM-USER-COMMANDS-MEMORY-UI] — leak report and retention paths ---- (display_retention_paths) render in floats; COMMON_TYPES drives :BasiliskMemRefs ---- completion (complete_refs). ----@param result? table Leak report from the LSP server. -function M.display_leak_report(result) - if not result then - ui.open_float("Memory Leak Report", { "No leak data available." }, "basilisk-memory") - return - end - - local lines = { "Memory Leak Report", "" } - local leaks = result.leaks or {} - - for _, leak in ipairs(leaks) do - lines[#lines + 1] = string.format( - " %s: %d objects, %s", - leak.typeName or "?", - leak.count or 0, - leak.totalSize or "?" - ) - if leak.location then - lines[#lines + 1] = string.format(" at %s:%d", leak.location.file or "?", leak.location.line or 0) - end - end - - if #leaks == 0 then - lines[#lines + 1] = " No leaks detected." - end - - ui.open_float("Memory Leak Report", lines, "basilisk-memory") -end - ---- Display retention paths in a floating window. ----@param type_name string ----@param result? table Retention paths from the LSP server. -function M.display_retention_paths(type_name, result) - if not result then - ui.open_float("Retention Paths: " .. type_name, { "No retention data available." }, "basilisk-memory") - return - end - - local lines = { "Retention Paths for: " .. type_name, "" } - local paths = result.retentionPaths or {} - - for i, path in ipairs(paths) do - local confidence = path.confidence or 0 - lines[#lines + 1] = string.format(" Path %d (confidence: %.0f%%):", i, confidence * 100) - for _, step in ipairs(path.steps or {}) do - lines[#lines + 1] = string.format(" -> %s (%s)", step.name or "?", step.kind or "?") - end - lines[#lines + 1] = "" - end - - if #paths == 0 then - lines[#lines + 1] = " No retention paths found." - end - - ui.open_float("Retention Paths: " .. type_name, lines, "basilisk-memory") -end - ---- Completion function for :BasiliskMemRefs. ----@param lead string ----@return string[] -function M.complete_refs(lead) - local matches = {} - for _, t in ipairs(COMMON_TYPES) do - if t:lower():find(lead:lower(), 1, true) then - matches[#matches + 1] = t - end - end - return matches -end - -return M diff --git a/basilisk.nvim/lua/basilisk/modules.lua b/basilisk.nvim/lua/basilisk/modules.lua deleted file mode 100644 index a67f24b89..000000000 --- a/basilisk.nvim/lua/basilisk/modules.lua +++ /dev/null @@ -1,302 +0,0 @@ ---- Module Explorer panel for Basilisk. ---- ---- Renders the workspace module tree in a split buffer. Data is fetched ---- from the LSP server via `basilisk.workspaceModules`. - -local ui = require("basilisk.ui") -local log = require("basilisk.log") - -local M = {} - ---- State for the module explorer buffer. ----@type integer? -local modules_buf = nil ----@type integer? -local modules_win = nil - ---- Fold state tracking: set of module names that are collapsed. ----@type table -local collapsed = {} - ---- Cached module data from last fetch. ----@type table[]? -local cached_modules = nil - ---- Fetch module tree from the LSP server. ----@param callback fun(modules: table[]) -local function fetch_modules(callback) - local client = ui.get_client() - if not client then - log.warn("no active LSP client for module explorer") - callback({}) - return - end - client:request("workspace/executeCommand", { - command = "basilisk.workspaceModules", - arguments = { {} }, - }, function(err, result) - if err then - log.error("workspaceModules failed: %s", err.message or tostring(err)) - callback({}) - return - end - local modules = (result and result.modules) or {} - cached_modules = modules - callback(modules) - end, 0) -end - ---- Render the module tree into buffer lines. ----@param modules table[] ----@return string[] ----@return table[] highlights { line, col_start, col_end, hl_group } -local function render_tree(modules) - local lines = {} - local highlights = {} - - for _, mod in ipairs(modules) do - local is_collapsed = collapsed[mod.name] - local icon = is_collapsed and "▸" or "▾" - local kind_label = mod.kind == "package" and "[pkg]" or "[mod]" - lines[#lines + 1] = string.format("%s %s %s", icon, mod.name, kind_label) - highlights[#highlights + 1] = { - line = #lines - 1, - col_start = 0, - col_end = #icon, - hl_group = "Directory", - } - - if not is_collapsed and mod.symbols then - for _, sym in ipairs(mod.symbols) do - local sym_icon = ({ - class = "●", - ["function"] = "ƒ", - variable = "◆", - constant = "◇", - typeAlias = "τ", - })[sym.kind] or "·" - - local annotation = sym.annotated and "" or " [untyped]" - local private = (sym.name:sub(1, 1) == "_" and sym.name:sub(1, 2) ~= "__") and " (private)" or "" - lines[#lines + 1] = string.format(" %s %s%s%s", sym_icon, sym.name, annotation, private) - - -- Highlight unannotated symbols. - if not sym.annotated then - highlights[#highlights + 1] = { - line = #lines - 1, - col_start = 0, - col_end = #lines[#lines], - hl_group = "DiagnosticWarn", - } - end - - -- Render class children. - if sym.children then - for _, child in ipairs(sym.children) do - local child_ann = child.annotated and "" or " [untyped]" - lines[#lines + 1] = string.format(" · %s%s", child.name, child_ann) - if not child.annotated then - highlights[#highlights + 1] = { - line = #lines - 1, - col_start = 0, - col_end = #lines[#lines], - hl_group = "DiagnosticWarn", - } - end - end - end - end - end - end - - if #lines == 0 then - lines = { " (no modules found)" } - end - - return lines, highlights -end - ---- Apply highlight groups to the buffer. ----@param buf integer ----@param highlights table[] -local function apply_highlights(buf, highlights) - local ns = vim.api.nvim_create_namespace("basilisk_modules") - vim.api.nvim_buf_clear_namespace(buf, ns, 0, -1) - for _, hl in ipairs(highlights) do - vim.api.nvim_buf_add_highlight(buf, ns, hl.hl_group, hl.line, hl.col_start, hl.col_end) - end -end - ---- Refresh the modules buffer content. -local function refresh_buffer() - if not modules_buf or not vim.api.nvim_buf_is_valid(modules_buf) then - return - end - local modules = cached_modules or {} - local lines, highlights = render_tree(modules) - vim.bo[modules_buf].modifiable = true - vim.api.nvim_buf_set_lines(modules_buf, 0, -1, false, lines) - vim.bo[modules_buf].modifiable = false - apply_highlights(modules_buf, highlights) -end - ---- Set up keybindings for the modules buffer. ----@param buf integer -local function setup_keybindings(buf) - local opts = { buffer = buf, nowait = true } - - -- - open file at symbol. - vim.keymap.set("n", "", function() - local line = vim.api.nvim_win_get_cursor(0)[1] - local text = vim.api.nvim_buf_get_lines(buf, line - 1, line, false)[1] or "" - -- Find the module for this line by scanning cached_modules. - if cached_modules then - local current_line = 0 - for _, mod in ipairs(cached_modules) do - current_line = current_line + 1 - if current_line == line then - vim.cmd("wincmd p") - vim.cmd("edit " .. vim.fn.fnameescape(mod.path)) - return - end - if not collapsed[mod.name] and mod.symbols then - for _, sym in ipairs(mod.symbols) do - current_line = current_line + 1 - if current_line == line then - vim.cmd("wincmd p") - vim.cmd("edit " .. vim.fn.fnameescape(mod.path)) - vim.api.nvim_win_set_cursor(0, { sym.line + 1, 0 }) - return - end - if sym.children then - current_line = current_line + #sym.children - end - end - end - end - end - end, opts) - - -- o - toggle fold. - vim.keymap.set("n", "o", function() - local line = vim.api.nvim_win_get_cursor(0)[1] - if cached_modules then - local current_line = 0 - for _, mod in ipairs(cached_modules) do - current_line = current_line + 1 - if current_line == line then - collapsed[mod.name] = not collapsed[mod.name] - refresh_buffer() - return - end - if not collapsed[mod.name] and mod.symbols then - for _, sym in ipairs(mod.symbols) do - current_line = current_line + 1 - if sym.children then - current_line = current_line + #sym.children - end - end - end - end - end - end, opts) - - -- r - refresh. - vim.keymap.set("n", "r", function() - fetch_modules(function() - vim.schedule(refresh_buffer) - end) - end, opts) - - -- y - copy import path. - vim.keymap.set("n", "y", function() - local line = vim.api.nvim_win_get_cursor(0)[1] - if cached_modules then - local current_line = 0 - for _, mod in ipairs(cached_modules) do - current_line = current_line + 1 - if current_line == line then - vim.fn.setreg("+", "import " .. mod.name) - log.info("copied: import %s", mod.name) - return - end - if not collapsed[mod.name] and mod.symbols then - for _, sym in ipairs(mod.symbols) do - current_line = current_line + 1 - if current_line == line then - local import_path = string.format("from %s import %s", mod.name, sym.name) - vim.fn.setreg("+", import_path) - log.info("copied: %s", import_path) - return - end - if sym.children then - current_line = current_line + #sym.children - end - end - end - end - end - end, opts) - - -- q - close. - vim.keymap.set("n", "q", function() - M.close() - end, opts) -end - ---- Open the module explorer in a vertical split. -function M.open() - if modules_win and vim.api.nvim_win_is_valid(modules_win) then - vim.api.nvim_set_current_win(modules_win) - return - end - - modules_buf = vim.api.nvim_create_buf(false, true) - vim.bo[modules_buf].bufhidden = "wipe" - vim.bo[modules_buf].filetype = "basilisk-modules" - vim.bo[modules_buf].modifiable = false - - vim.cmd("topleft 40vsplit") - modules_win = vim.api.nvim_get_current_win() - vim.api.nvim_win_set_buf(modules_win, modules_buf) - vim.wo[modules_win].number = false - vim.wo[modules_win].relativenumber = false - vim.wo[modules_win].signcolumn = "no" - vim.wo[modules_win].wrap = false - vim.wo[modules_win].winfixwidth = true - - setup_keybindings(modules_buf) - - fetch_modules(function() - vim.schedule(refresh_buffer) - end) -end - ---- Close the module explorer. -function M.close() - if modules_win and vim.api.nvim_win_is_valid(modules_win) then - vim.api.nvim_win_close(modules_win, true) - end - modules_win = nil - modules_buf = nil -end - ---- Toggle the module explorer. -function M.toggle() - if modules_win and vim.api.nvim_win_is_valid(modules_win) then - M.close() - else - M.open() - end -end - ---- Refresh the module explorer (called from notification handler). -function M.refresh() - if modules_buf and vim.api.nvim_buf_is_valid(modules_buf) then - fetch_modules(function() - vim.schedule(refresh_buffer) - end) - end -end - -return M diff --git a/basilisk.nvim/lua/basilisk/notice.lua b/basilisk.nvim/lua/basilisk/notice.lua new file mode 100644 index 000000000..d59d58556 --- /dev/null +++ b/basilisk.nvim/lua/basilisk/notice.lua @@ -0,0 +1,8 @@ +-- GENERATED FILE — DO NOT EDIT. +-- Source: docs/specs/DOCS-WITHDRAWAL-MESSAGING-SPEC.md [WITHDRAWAL-INERT-TEXT] +-- Regenerate: python3 scripts/gen_withdrawal_copy.py +local text = "Basilisk is unlisted. Its type checker is inert and checks nothing.\n\nBasilisk's type checker was producing incorrect results. The code responsible is not isolated to a known set of rules, so we cannot say how many rules are affected. We asked for Basilisk to be removed from the python/typing conformance results, and it has been removed: https://github.com/python/typing/pull/2330\n\nA code-quality tool that does not produce correct results is worse than useless. Remove Basilisk from your pipeline, your pre-commit hooks, and your editor. This command failed on purpose. It is not a finding about your code.\n\nWe are not fixing this code. We are rebuilding from the ground up as a new product, shipping only what can be trusted. If type checking ever returns, it will be externally audited before release.\n\nA full public account: https://www.christianfindlay.com/blog/basilisk-conformance-apology" +return { + text = text, + lines = vim.split(text, "\n", { plain = true }), +} diff --git a/basilisk.nvim/lua/basilisk/profiling.lua b/basilisk.nvim/lua/basilisk/profiling.lua deleted file mode 100644 index 93c37756a..000000000 --- a/basilisk.nvim/lua/basilisk/profiling.lua +++ /dev/null @@ -1,210 +0,0 @@ ---- Profiling commands for Basilisk. ---- ---- Sends LSP profiler commands and displays results in floating windows, ---- quickfix lists, and heat map extmarks. - -local log = require("basilisk.log") -local ui = require("basilisk.ui") - -local M = {} - ---- Namespace for profiling extmarks (heat map). -local ns = vim.api.nvim_create_namespace("basilisk-profiling") - ---- Active profiling session ID. ----@type string? -local session_id = nil - ---- Start profiling. ----@param pid? integer Optional process ID to profile. -function M.start(pid) - local client = ui.get_client() - if not client then - log.warn("no active LSP client") - return - end - - local args = {} - if pid then - args = { { pid = pid } } - end - - client:request("workspace/executeCommand", { - command = "basilisk.profiler.start", - arguments = args, - }, function(err, result) - if err then - log.error("profiler start failed: %s", err.message or tostring(err)) - return - end - if result and result.sessionId then - session_id = result.sessionId - end - log.info("profiling started") - end, 0) -end - ---- Stop profiling and display results. -function M.stop() - local client = ui.get_client() - if not client then - log.warn("no active LSP client") - return - end - - local args = {} - if session_id then - args = { { sessionId = session_id } } - end - - client:request("workspace/executeCommand", { - command = "basilisk.profiler.stop", - arguments = args, - }, function(err, result) - if err then - log.error("profiler stop failed: %s", err.message or tostring(err)) - return - end - session_id = nil - vim.schedule(function() - M.display_results(result) - end) - end, 0) -end - ---- Take a snapshot without stopping. -function M.snapshot() - local client = ui.get_client() - if not client then - log.warn("no active LSP client") - return - end - - local args = {} - if session_id then - args = { { sessionId = session_id } } - end - - client:request("workspace/executeCommand", { - command = "basilisk.profiler.snapshot", - arguments = args, - }, function(err, result) - if err then - log.error("profiler snapshot failed: %s", err.message or tostring(err)) - return - end - vim.schedule(function() - M.display_results(result) - end) - end, 0) -end - ---- Display profiling results in a floating window and quickfix list. ---- Implements [NVIM-USER-COMMANDS-PROFILING-UI] — hot-function list in a float + ---- quickfix list, with heat-map extmarks (apply_heat_map) and speedscope export ---- (export_flamegraph) for the flamegraph view. ----@param result? table Profiling results from the LSP server. -function M.display_results(result) - if not result then - ui.open_float("Profiling Results", { "No profiling data available." }, "basilisk-profiling") - return - end - - local lines = { "Hot Functions:", "" } - local qf_items = {} - - local hot_functions = result.hotFunctions or {} - for i, func in ipairs(hot_functions) do - local line = string.format( - "%3d. %6.1f%% %s (%s:%d)", - i, - func.percentage or 0, - func.name or "?", - func.file or "?", - func.line or 0 - ) - lines[#lines + 1] = line - qf_items[#qf_items + 1] = { - filename = func.file, - lnum = func.line or 0, - text = string.format("%.1f%% — %s", func.percentage or 0, func.name or "?"), - } - end - - if #hot_functions == 0 then - lines[#lines + 1] = " (no hot functions recorded)" - end - - -- Show floating window. - ui.open_float("Profiling Results", lines, "basilisk-profiling") - - -- Populate quickfix list. - if #qf_items > 0 then - vim.fn.setqflist(qf_items, "r") - log.info("profiling results added to quickfix list (:copen)") - end - - -- Apply heat map extmarks. - M.apply_heat_map(hot_functions) -end - ---- Apply heat map extmarks on hot lines. ----@param hot_functions table[] -function M.apply_heat_map(hot_functions) - -- Clear previous heat map. - for _, buf in ipairs(vim.api.nvim_list_bufs()) do - if vim.api.nvim_buf_is_loaded(buf) then - vim.api.nvim_buf_clear_namespace(buf, ns, 0, -1) - end - end - - for _, func in ipairs(hot_functions or {}) do - local file = func.file - local line = (func.line or 1) - 1 - local pct = func.percentage or 0 - if file then - -- Find buffer for this file. - for _, buf in ipairs(vim.api.nvim_list_bufs()) do - local name = vim.api.nvim_buf_get_name(buf) - if name == file and vim.api.nvim_buf_is_loaded(buf) then - local hl = pct > 50 and "DiagnosticError" - or pct > 20 and "DiagnosticWarn" - or "DiagnosticHint" - pcall(vim.api.nvim_buf_set_extmark, buf, ns, line, 0, { - virt_text = { { string.format(" %.1f%%", pct), hl } }, - virt_text_pos = "eol", - }) - end - end - end - end -end - ---- Open the flamegraph SVG exported by the LSP server in the browser. ---- Implements [PROFILE-VIEWER-DELIVERY]: speedscope.app can NEVER fetch a ---- `file://` profileURL (an https page may not read local files), so we open ---- the local self-contained SVG instead and log the speedscope JSON path for ---- manual import at https://www.speedscope.app. ----@param result? table Profiling results from the LSP `profiler.stop` response. -function M.export_flamegraph(result) - if not result or not result.flamegraphPath then - local reason = result and result.exportError or "no flamegraph available" - log.warn("flamegraph export unavailable: %s", reason) - return - end - if vim.fn.filereadable(result.flamegraphPath) == 0 then - log.error("flamegraph file missing: %s", result.flamegraphPath) - return - end - - vim.ui.open("file://" .. result.flamegraphPath) - log.info("flamegraph opened: %s", result.flamegraphPath) - if result.outputFile then - log.info( - "speedscope JSON: %s (import manually at https://www.speedscope.app)", - result.outputFile - ) - end -end - -return M diff --git a/basilisk.nvim/lua/basilisk/statusline.lua b/basilisk.nvim/lua/basilisk/statusline.lua deleted file mode 100644 index 650a225f9..000000000 --- a/basilisk.nvim/lua/basilisk/statusline.lua +++ /dev/null @@ -1,132 +0,0 @@ ---- Status line component for Basilisk. ---- ---- Compatible with lualine.nvim, heirline.nvim, or any status line ---- that accepts a function returning a string. ---- ---- Implements [NVIM-STATUS-LINE] — exposes lualine_component and the ---- starting/ready/error/stopped states with the spec's icons, error/warn counts, ---- and colors. - -local M = {} - ----@alias BasiliskState "starting"|"ready"|"error"|"stopped" - ---- Current server state. ----@type BasiliskState -local state = "stopped" - ---- Whether state was set manually (should not be overridden by update). -local state_pinned = false - ---- Cached diagnostic counts. -local error_count = 0 -local warn_count = 0 - ---- State display configuration. -local STATE_DISPLAY = { - starting = { icon = "\u{27f3}", text = "Basilisk", color = "DiagnosticWarn" }, - ready = { icon = "\u{2713}", text = "Basilisk", color = "DiagnosticOk" }, - error = { icon = "\u{2717}", text = "Basilisk", color = "DiagnosticError" }, - stopped = { icon = "\u{2298}", text = "Basilisk", color = "Comment" }, -} - ---- Update the cached state from LSP client status. -function M.update() - -- Do not override manually-pinned states (e.g., "error" after max restarts). - if state_pinned then - return - end - - local clients = vim.lsp.get_clients({ name = "basilisk" }) - if #clients == 0 then - state = "stopped" - error_count = 0 - warn_count = 0 - return - end - - state = "ready" - - -- Count diagnostics across all buffers. - local errors = 0 - local warns = 0 - for _, diag in ipairs(vim.diagnostic.get(nil, { namespace = nil })) do - if diag.source == "basilisk" or (diag.code and tostring(diag.code):match("^BSK")) then - if diag.severity == vim.diagnostic.severity.ERROR then - errors = errors + 1 - elseif diag.severity == vim.diagnostic.severity.WARN then - warns = warns + 1 - end - end - end - error_count = errors - warn_count = warns -end - ---- Get the status line text. ----@return string -function M.get() - M.update() - local display = STATE_DISPLAY[state] - local text = display.icon .. " " .. display.text - if state == "ready" and (error_count > 0 or warn_count > 0) then - text = text .. string.format(" (%dE %dW)", error_count, warn_count) - end - return text -end - ---- Get the highlight group for the current state. ----@return string -function M.get_color() - M.update() - local display = STATE_DISPLAY[state] - if state == "ready" and error_count > 0 then - return "DiagnosticWarn" - end - return display.color -end - ---- Lualine-compatible component table. -M.lualine_component = { - function() - return M.get() - end, - color = function() - return { fg = vim.api.nvim_get_hl(0, { name = M.get_color() }).fg } - end, -} - ---- Set the state directly (for use by lsp.lua on error/restart). ---- Pinned states ("starting", "error") are not overridden by update(). ---- "ready" and "stopped" unpin, allowing normal update flow. ----@param new_state BasiliskState -function M.set_state(new_state) - state = new_state - state_pinned = (new_state == "starting" or new_state == "error") -end - ---- Active profiler progress data (nil when not profiling). ----@type {pid: integer, elapsedSeconds: number, totalSamples: integer}? -local profiler_progress = nil - ---- Update profiler progress from a basilisk/profiler/progress notification. ----@param progress {pid: integer, elapsedSeconds: number, totalSamples: integer}? -function M.set_profiler_status(progress) - profiler_progress = progress -end - ---- Get the profiler portion of the status line (empty when not profiling). ----@return string -function M.get_profiler() - if not profiler_progress then - return "" - end - return string.format( - " [Profiling PID %d %ds %d samples]", - profiler_progress.pid or 0, - profiler_progress.elapsedSeconds or 0, - profiler_progress.totalSamples or 0 - ) -end - -return M diff --git a/basilisk.nvim/lua/basilisk/tab_tracking.lua b/basilisk.nvim/lua/basilisk/tab_tracking.lua deleted file mode 100644 index dae4e2400..000000000 --- a/basilisk.nvim/lua/basilisk/tab_tracking.lua +++ /dev/null @@ -1,85 +0,0 @@ ---- Tab tracking for openFilesOnly analysis mode. ---- ---- In openFilesOnly mode, diagnostics should clear when files close. ---- Neovim doesn't reliably fire didClose when a buffer is hidden, so ---- we track buffer visibility and send didClose manually. - -local log = require("basilisk.log") -local ui = require("basilisk.ui") - -local M = {} - ---- Set of URIs we know are visible in windows. ----@type table -local known_open_uris = {} - ---- Collect all Python file URIs currently visible in windows. ----@return table -local function collect_visible_python_uris() - local uris = {} - for _, win in ipairs(vim.api.nvim_list_wins()) do - local buf = vim.api.nvim_win_get_buf(win) - if vim.bo[buf].filetype == "python" then - local name = vim.api.nvim_buf_get_name(buf) - if name ~= "" then - uris[vim.uri_from_fname(name)] = true - end - end - end - return uris -end - ---- Check for closed Python tabs and send didClose. ----@param config BasiliskConfig -local function check_closed_tabs(config) - if config.analysis_mode ~= "openFilesOnly" then - return - end - - local client = ui.get_client() - if not client then - return - end - - local current_uris = collect_visible_python_uris() - - -- Find URIs that were open but are no longer visible. - for uri in pairs(known_open_uris) do - if not current_uris[uri] then - client:notify("textDocument/didClose", { - textDocument = { uri = uri }, - }) - log.debug("sent didClose for hidden buffer: %s", uri) - end - end - - known_open_uris = current_uris -end - ---- Set up tab tracking autocmds. ----@param config BasiliskConfig -function M.setup(config) - if config.analysis_mode ~= "openFilesOnly" then - return - end - - local group = vim.api.nvim_create_augroup("BasiliskTabTracking", { clear = true }) - - -- Track when buffers become hidden or windows change. - vim.api.nvim_create_autocmd({ "BufHidden", "WinClosed", "BufDelete" }, { - group = group, - pattern = "*.py", - callback = function() - vim.defer_fn(function() - check_closed_tabs(config) - end, 100) - end, - }) - - -- Seed with currently visible buffers. - known_open_uris = collect_visible_python_uris() - - log.debug("tab tracking enabled for openFilesOnly mode") -end - -return M diff --git a/basilisk.nvim/lua/basilisk/testing.lua b/basilisk.nvim/lua/basilisk/testing.lua deleted file mode 100644 index 4d179d620..000000000 --- a/basilisk.nvim/lua/basilisk/testing.lua +++ /dev/null @@ -1,507 +0,0 @@ ---- Test explorer for Basilisk. ---- ---- Discovers tests via pytest, displays a tree UI in a side panel, ---- and supports run/debug integration. ---- ---- Implements [NVIM-TEST-EXPLORER] — Neovim tree UI, keymaps, and nvim-dap ---- integration for the test explorer (architecture in LSP-TEST-INTEGRATION-SPEC). - -local log = require("basilisk.log") - -local M = {} - ---- Namespace for test diagnostics. -local ns = vim.api.nvim_create_namespace("basilisk-test") - ---- Test tree data structure. ----@class BasiliskTestNode ----@field id string Fully qualified test ID. ----@field name string Display name. ----@field kind "file"|"class"|"function" ----@field file? string ----@field line? integer ----@field status "unknown"|"running"|"passed"|"failed" ----@field children BasiliskTestNode[] - ---- The root test tree. ----@type BasiliskTestNode[] -local test_tree = {} - ---- The test explorer buffer. ----@type integer? -local tree_buf = nil - ---- The test explorer window. ----@type integer? -local tree_win = nil - ---- Flat list of rendered node IDs (maps line number to test node). ----@type BasiliskTestNode[] -local rendered_nodes = {} - ---- Status icons. -local STATUS_ICONS = { - unknown = "○", - running = "◌", - passed = "●", - failed = "✗", -} - ---- Status highlight groups. -local STATUS_HL = { - unknown = "Comment", - running = "DiagnosticWarn", - passed = "DiagnosticOk", - failed = "DiagnosticError", -} - ---- Parse pytest --collect-only output into a test tree. ----@param output string ----@return BasiliskTestNode[] -function M.parse_pytest_output(output) - local tree = {} - local file_nodes = {} - local class_nodes = {} - - for line in output:gmatch("[^\n]+") do - -- Skip empty lines and summary lines. - if line:match("^%s*$") or line:match("^=") or line:match("^no tests") then - goto continue - end - - -- Parse test IDs: file.py::Class::test_name or file.py::test_name. - local file, rest = line:match("^(.+%.py)::(.+)$") - if not file then - goto continue - end - - -- Ensure file node exists. - if not file_nodes[file] then - file_nodes[file] = { - id = file, - name = vim.fn.fnamemodify(file, ":t"), - kind = "file", - file = file, - status = "unknown", - children = {}, - } - tree[#tree + 1] = file_nodes[file] - end - local file_node = file_nodes[file] - - -- Split rest into class::test or just test. - local class_name, test_name = rest:match("^(.+)::(.+)$") - if class_name then - local class_key = file .. "::" .. class_name - if not class_nodes[class_key] then - class_nodes[class_key] = { - id = class_key, - name = class_name, - kind = "class", - file = file, - status = "unknown", - children = {}, - } - file_node.children[#file_node.children + 1] = class_nodes[class_key] - end - local class_node = class_nodes[class_key] - class_node.children[#class_node.children + 1] = { - id = line, - name = test_name, - kind = "function", - file = file, - status = "unknown", - children = {}, - } - else - file_node.children[#file_node.children + 1] = { - id = line, - name = rest, - kind = "function", - file = file, - status = "unknown", - children = {}, - } - end - - ::continue:: - end - - -- Update the module-level tree so refresh_display() picks it up. - test_tree = tree - - return tree -end - ---- Render the test tree into buffer lines. ----@param nodes BasiliskTestNode[] ----@param indent integer ----@param lines string[] ----@param node_map BasiliskTestNode[] -local function render_tree(nodes, indent, lines, node_map) - local prefix = string.rep(" ", indent) - for _, node in ipairs(nodes) do - local icon = STATUS_ICONS[node.status] or "○" - lines[#lines + 1] = prefix .. icon .. " " .. node.name - node_map[#node_map + 1] = node - if #node.children > 0 then - render_tree(node.children, indent + 1, lines, node_map) - end - end -end - ---- Refresh the tree buffer display. -function M.refresh_display() - if not tree_buf or not vim.api.nvim_buf_is_valid(tree_buf) then - return - end - - local lines = {} - rendered_nodes = {} - render_tree(test_tree, 0, lines, rendered_nodes) - - if #lines == 0 then - lines = { " No tests discovered.", " Run :BasiliskTestDiscover" } - end - - vim.bo[tree_buf].modifiable = true - vim.api.nvim_buf_set_lines(tree_buf, 0, -1, false, lines) - vim.bo[tree_buf].modifiable = false -end - ---- Get the test node at the current cursor line. ----@return BasiliskTestNode? -local function get_node_at_cursor() - if not tree_win or not vim.api.nvim_win_is_valid(tree_win) then - return nil - end - local row = vim.api.nvim_win_get_cursor(tree_win)[1] - return rendered_nodes[row] -end - ---- Discover tests using pytest. ----@param config BasiliskConfig -function M.discover(config) - log.info("discovering tests...") - - local cmd = { config.test_explorer.pytest_path, "--collect-only", "-q" } - for _, arg in ipairs(config.test_explorer.args) do - cmd[#cmd + 1] = arg - end - - vim.fn.jobstart(cmd, { - stdout_buffered = true, - on_stdout = function(_, data) - if not data then - return - end - local output = table.concat(data, "\n") - vim.schedule(function() - test_tree = M.parse_pytest_output(output) - M.refresh_display() - log.info("discovered %d test files", #test_tree) - end) - end, - on_stderr = function(_, data) - if data and data[1] ~= "" then - log.debug("pytest stderr: %s", table.concat(data, "\n")) - end - end, - }) -end - ---- Run a test (or all tests if no ID given). ----@param config BasiliskConfig ----@param test_id? string -function M.run(config, test_id) - local cmd = { config.test_explorer.pytest_path, "-v", "--tb=short" } - for _, arg in ipairs(config.test_explorer.args) do - cmd[#cmd + 1] = arg - end - if test_id then - cmd[#cmd + 1] = test_id - end - - -- Mark as running. - M.set_status(test_id, "running") - M.refresh_display() - - vim.fn.jobstart(cmd, { - stdout_buffered = true, - on_stdout = function(_, data) - if not data then - return - end - vim.schedule(function() - M.parse_test_results(table.concat(data, "\n")) - M.refresh_display() - end) - end, - on_exit = function(_, exit_code) - vim.schedule(function() - if exit_code == 0 then - log.info("tests passed") - else - log.warn("tests failed (exit code %d)", exit_code) - end - end) - end, - }) -end - ---- Debug a test using nvim-dap. ----@param config BasiliskConfig ----@param test_id string -function M.debug(config, test_id) - local dap_ok, dap = pcall(require, "dap") - if not dap_ok then - log.error("nvim-dap required for debugging tests") - return - end - - dap.run({ - type = "basilisk", - request = "launch", - name = "Debug: " .. test_id, - module = "pytest", - args = { "-xvs", test_id }, - justMyCode = true, - }) -end - ---- Parse test results from pytest output and update the tree. ----@param output string -function M.parse_test_results(output) - for line in output:gmatch("[^\n]+") do - -- Match lines like: test_file.py::test_name PASSED/FAILED - local test_id, result = line:match("^(.+%.py::.+)%s+(PASSED)") - if not test_id then - test_id, result = line:match("^(.+%.py::.+)%s+(FAILED)") - end - if test_id and result then - local status = result == "PASSED" and "passed" or "failed" - M.set_status(test_id, status) - end - end - - -- Set inline diagnostics for failures. - M.update_diagnostics() -end - ---- Set the status of a test node by ID. ----@param test_id? string ----@param status string -function M.set_status(test_id, status) - if not test_id then - return - end - local function walk(nodes) - for _, node in ipairs(nodes) do - if node.id == test_id then - node.status = status - return true - end - if walk(node.children) then - return true - end - end - return false - end - walk(test_tree) -end - ---- Update inline diagnostics for failed tests. -function M.update_diagnostics() - -- Clear previous diagnostics. - vim.diagnostic.reset(ns) - - local diagnostics_by_buf = {} - local function collect(nodes) - for _, node in ipairs(nodes) do - if node.status == "failed" and node.file and node.line then - local bufnr = vim.fn.bufnr(node.file) - if bufnr ~= -1 then - if not diagnostics_by_buf[bufnr] then - diagnostics_by_buf[bufnr] = {} - end - diagnostics_by_buf[bufnr][#diagnostics_by_buf[bufnr] + 1] = { - lnum = (node.line or 1) - 1, - col = 0, - severity = vim.diagnostic.severity.ERROR, - source = "basilisk-test", - message = "Test failed: " .. node.name, - } - end - end - collect(node.children) - end - end - collect(test_tree) - - for bufnr, diags in pairs(diagnostics_by_buf) do - vim.diagnostic.set(ns, bufnr, diags) - end -end - ---- Create or show the test explorer panel. ----@param config BasiliskConfig -function M.open(config) - if tree_win and vim.api.nvim_win_is_valid(tree_win) then - vim.api.nvim_set_current_win(tree_win) - return - end - - -- Create buffer. - tree_buf = vim.api.nvim_create_buf(false, true) - vim.bo[tree_buf].filetype = "basilisk-tests" - vim.bo[tree_buf].bufhidden = "wipe" - vim.bo[tree_buf].swapfile = false - - -- Create split. - local pos = config.test_explorer.position - local width = config.test_explorer.width - - if pos == "bottom" then - vim.cmd("botright split") - vim.api.nvim_win_set_height(0, 15) - elseif pos == "left" then - vim.cmd("topleft vsplit") - vim.api.nvim_win_set_width(0, width) - else - vim.cmd("botright vsplit") - vim.api.nvim_win_set_width(0, width) - end - - tree_win = vim.api.nvim_get_current_win() - vim.api.nvim_win_set_buf(tree_win, tree_buf) - vim.wo[tree_win].number = false - vim.wo[tree_win].relativenumber = false - vim.wo[tree_win].signcolumn = "no" - vim.wo[tree_win].winfixwidth = true - - -- Set up keymaps. - local buf = tree_buf - vim.keymap.set("n", "", function() - local node = get_node_at_cursor() - if node and node.kind == "function" then - M.run(config, node.id) - end - end, { buffer = buf, desc = "Run test" }) - - vim.keymap.set("n", "d", function() - local node = get_node_at_cursor() - if node and node.kind == "function" then - M.debug(config, node.id) - end - end, { buffer = buf, desc = "Debug test" }) - - vim.keymap.set("n", "R", function() - -- Re-run failed tests. - local function collect_failed(nodes, ids) - for _, node in ipairs(nodes) do - if node.status == "failed" and node.kind == "function" then - ids[#ids + 1] = node.id - end - collect_failed(node.children, ids) - end - end - local failed = {} - collect_failed(test_tree, failed) - for _, id in ipairs(failed) do - M.run(config, id) - end - end, { buffer = buf, desc = "Re-run failed tests" }) - - vim.keymap.set("n", "q", function() - M.close() - end, { buffer = buf, desc = "Close test explorer" }) - - M.refresh_display() -end - ---- Close the test explorer panel. -function M.close() - if tree_win and vim.api.nvim_win_is_valid(tree_win) then - vim.api.nvim_win_close(tree_win, true) - end - tree_win = nil - tree_buf = nil -end - ---- Toggle the test explorer panel. ----@param config BasiliskConfig -function M.toggle(config) - if tree_win and vim.api.nvim_win_is_valid(tree_win) then - M.close() - else - M.open(config) - end -end - ---- Set up auto-discover on save. ----@param config BasiliskConfig -function M.setup_auto_discover(config) - if not config.test_explorer.auto_discover_on_save then - return - end - - vim.api.nvim_create_autocmd("BufWritePost", { - pattern = "*.py", - group = vim.api.nvim_create_augroup("BasiliskTestAutoDiscover", { clear = true }), - callback = function() - if tree_buf and vim.api.nvim_buf_is_valid(tree_buf) then - M.discover(config) - end - end, - }) -end - ---- Parse coverage.xml and apply gutter highlights. ----@param coverage_path? string Path to coverage.xml. Defaults to "coverage.xml". -function M.apply_coverage(coverage_path) - local path = coverage_path or "coverage.xml" - local fh = io.open(path, "r") - if not fh then - log.debug("no coverage file found at %s", path) - return - end - - local content = fh:read("*a") - fh:close() - - local cov_ns = vim.api.nvim_create_namespace("basilisk-coverage") - - -- Clear previous coverage marks. - for _, buf in ipairs(vim.api.nvim_list_bufs()) do - if vim.api.nvim_buf_is_loaded(buf) then - vim.api.nvim_buf_clear_namespace(buf, cov_ns, 0, -1) - end - end - - -- Parse line coverage from XML (simplified parser for Cobertura format). - -- Match and - local current_file = nil - for line in content:gmatch("[^\n]+") do - local filename = line:match('filename="([^"]+)"') - if filename then - current_file = filename - end - local line_num, hits = line:match('number="(%d+)"%s+hits="(%d+)"') - if line_num and hits and current_file then - local lnum = tonumber(line_num) - 1 - local hit_count = tonumber(hits) - for _, buf in ipairs(vim.api.nvim_list_bufs()) do - local name = vim.api.nvim_buf_get_name(buf) - if name:find(current_file, 1, true) and vim.api.nvim_buf_is_loaded(buf) then - local hl = hit_count > 0 and "DiagnosticOk" or "DiagnosticError" - pcall(vim.api.nvim_buf_set_extmark, buf, cov_ns, lnum, 0, { - sign_text = hit_count > 0 and "▎" or "▎", - sign_hl_group = hl, - }) - end - end - end - end -end - -return M diff --git a/basilisk.nvim/lua/basilisk/type_health.lua b/basilisk.nvim/lua/basilisk/type_health.lua deleted file mode 100644 index ca9e53ed7..000000000 --- a/basilisk.nvim/lua/basilisk/type_health.lua +++ /dev/null @@ -1,230 +0,0 @@ ---- Type Health panel for Basilisk. ---- ---- Renders per-module type coverage statistics in a split buffer with ---- colored highlights. Data is fetched from the LSP server via ---- `basilisk.typeHealth`. - -local ui = require("basilisk.ui") -local log = require("basilisk.log") - -local M = {} - ----@type integer? -local health_buf = nil ----@type integer? -local health_win = nil - ---- Fetch type health from the LSP server. ----@param callback fun(data: table) -local function fetch_health(callback) - local client = ui.get_client() - if not client then - log.warn("no active LSP client for type health") - callback({}) - return - end - client:request("workspace/executeCommand", { - command = "basilisk.typeHealth", - arguments = { {} }, - }, function(err, result) - if err then - log.error("typeHealth failed: %s", err.message or tostring(err)) - callback({}) - return - end - callback(result or {}) - end, 0) -end - ---- Build a text progress bar. ----@param percent number ----@param width? number ----@return string -local function progress_bar(percent, width) - width = width or 20 - local filled = math.floor(percent / 100 * width + 0.5) - return string.rep("█", filled) .. string.rep("░", width - filled) -end - ---- Render type health data into lines and highlights. ----@param data table ----@return string[] ----@return table[] -local function render_health(data) - local lines = {} - local highlights = {} - local ws = data.workspace or {} - - -- Header. - lines[#lines + 1] = "Type Health — Workspace Summary" - lines[#lines + 1] = "" - lines[#lines + 1] = string.format( - " Coverage: %s %d%%", - progress_bar(ws.coveragePercent or 100), - ws.coveragePercent or 100 - ) - lines[#lines + 1] = string.format( - " Symbols: %d / %d annotated", - ws.annotatedSymbols or 0, - ws.totalSymbols or 0 - ) - lines[#lines + 1] = string.format(" Errors: %d", ws.errors or 0) - lines[#lines + 1] = string.format(" Warnings: %d", ws.warnings or 0) - lines[#lines + 1] = string.format( - " Files: %d (%d adopted)", - ws.totalFiles or 0, - ws.adoptedFiles or 0 - ) - lines[#lines + 1] = "" - - -- Highlight the coverage line. - local cov = ws.coveragePercent or 100 - local cov_hl = cov >= 90 and "DiagnosticOk" or (cov >= 50 and "DiagnosticWarn" or "DiagnosticError") - highlights[#highlights + 1] = { line = 2, col_start = 0, col_end = #lines[3], hl_group = cov_hl } - - -- Per-module table. - lines[#lines + 1] = "Per-Module Breakdown (sorted by coverage)" - lines[#lines + 1] = string.rep("─", 60) - - local modules = data.modules or {} - for _, mod in ipairs(modules) do - local badge = mod.adopted and " [adopted]" or "" - local issues = {} - if mod.errors > 0 then issues[#issues + 1] = mod.errors .. "E" end - if mod.warnings > 0 then issues[#issues + 1] = mod.warnings .. "W" end - local issue_str = #issues > 0 and (" — " .. table.concat(issues, " ")) or "" - - lines[#lines + 1] = string.format( - " %s %3d%% %s%s%s", - progress_bar(mod.coveragePercent, 10), - mod.coveragePercent, - mod.name, - issue_str, - badge - ) - - -- Color by coverage level. - local mod_hl = mod.coveragePercent >= 90 and "DiagnosticOk" - or (mod.coveragePercent >= 50 and "DiagnosticWarn" or "DiagnosticError") - highlights[#highlights + 1] = { - line = #lines - 1, - col_start = 0, - col_end = #lines[#lines], - hl_group = mod_hl, - } - end - - if #modules == 0 then - lines[#lines + 1] = " (no modules analysed)" - end - - return lines, highlights -end - ---- Apply highlights to the buffer. ----@param buf integer ----@param highlights table[] -local function apply_highlights(buf, highlights) - local ns = vim.api.nvim_create_namespace("basilisk_type_health") - vim.api.nvim_buf_clear_namespace(buf, ns, 0, -1) - for _, hl in ipairs(highlights) do - vim.api.nvim_buf_add_highlight(buf, ns, hl.hl_group, hl.line, hl.col_start, hl.col_end) - end -end - ---- Open the type health panel in a split buffer. -function M.open() - if health_win and vim.api.nvim_win_is_valid(health_win) then - vim.api.nvim_set_current_win(health_win) - return - end - - health_buf = vim.api.nvim_create_buf(false, true) - vim.bo[health_buf].bufhidden = "wipe" - vim.bo[health_buf].filetype = "basilisk-health" - vim.bo[health_buf].modifiable = false - - vim.cmd("botright 15split") - health_win = vim.api.nvim_get_current_win() - vim.api.nvim_win_set_buf(health_win, health_buf) - vim.wo[health_win].number = false - vim.wo[health_win].relativenumber = false - vim.wo[health_win].signcolumn = "no" - vim.wo[health_win].wrap = false - - -- Keybindings. - local opts = { buffer = health_buf, nowait = true } - vim.keymap.set("n", "q", function() M.close() end, opts) - vim.keymap.set("n", "r", function() M.refresh() end, opts) - vim.keymap.set("n", "", function() - -- Open module file at cursor. - local line = vim.api.nvim_win_get_cursor(0)[1] - local text = vim.api.nvim_buf_get_lines(health_buf, line - 1, line, false)[1] or "" - -- Extract module name from the line (after percentage). - local name = text:match("%d+%% (.+)") - if name then - name = name:gsub(" %— .*", ""):gsub(" %[adopted%]", ""):gsub("^%s+", "") - end - if not name then return end - -- Look up path from LSP. - local client = ui.get_client() - if client then - client:request("workspace/executeCommand", { - command = "basilisk.workspaceModules", - arguments = { { scope = name } }, - }, function(err, result) - if err or not result then return end - for _, mod in ipairs(result.modules or {}) do - if mod.name == name then - vim.schedule(function() - vim.cmd("wincmd p") - vim.cmd("edit " .. vim.fn.fnameescape(mod.path)) - end) - return - end - end - end, 0) - end - end, opts) - - M.refresh() -end - ---- Close the type health panel. -function M.close() - if health_win and vim.api.nvim_win_is_valid(health_win) then - vim.api.nvim_win_close(health_win, true) - end - health_win = nil - health_buf = nil -end - ---- Refresh the type health panel. -function M.refresh() - if not health_buf or not vim.api.nvim_buf_is_valid(health_buf) then - return - end - fetch_health(function(data) - vim.schedule(function() - if not health_buf or not vim.api.nvim_buf_is_valid(health_buf) then - return - end - local lines, highlights = render_health(data) - vim.bo[health_buf].modifiable = true - vim.api.nvim_buf_set_lines(health_buf, 0, -1, false, lines) - vim.bo[health_buf].modifiable = false - apply_highlights(health_buf, highlights) - end) - end) -end - ---- Toggle the type health panel. -function M.toggle() - if health_win and vim.api.nvim_win_is_valid(health_win) then - M.close() - else - M.open() - end -end - -return M diff --git a/basilisk.nvim/lua/basilisk/ui.lua b/basilisk.nvim/lua/basilisk/ui.lua deleted file mode 100644 index 017a75ab5..000000000 --- a/basilisk.nvim/lua/basilisk/ui.lua +++ /dev/null @@ -1,50 +0,0 @@ ---- Shared UI helpers for Basilisk floating windows and LSP client lookup. - -local M = {} - ---- Get the first active basilisk LSP client, or nil. ----@return vim.lsp.Client? -function M.get_client() - local clients = vim.lsp.get_clients({ name = "basilisk" }) - return clients[1] -end - ---- Open a floating window with the given lines. ----@param title string ----@param lines string[] ----@param filetype? string Buffer filetype (default "basilisk"). ----@return integer buf, integer win -function M.open_float(title, lines, filetype) - local buf = vim.api.nvim_create_buf(false, true) - vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines) - vim.bo[buf].modifiable = false - vim.bo[buf].bufhidden = "wipe" - vim.bo[buf].filetype = filetype or "basilisk" - - local width = 80 - local height = math.min(#lines, 30) - for _, line in ipairs(lines) do - width = math.max(width, #line + 2) - end - width = math.min(width, math.floor(vim.o.columns * 0.8)) - - local win = vim.api.nvim_open_win(buf, true, { - relative = "editor", - width = width, - height = height, - col = math.floor((vim.o.columns - width) / 2), - row = math.floor((vim.o.lines - height) / 2), - style = "minimal", - border = "rounded", - title = " " .. title .. " ", - title_pos = "center", - }) - - vim.keymap.set("n", "q", function() - vim.api.nvim_win_close(win, true) - end, { buffer = buf }) - - return buf, win -end - -return M diff --git a/basilisk.nvim/lua/basilisk/update.lua b/basilisk.nvim/lua/basilisk/update.lua deleted file mode 100644 index cc7fea7f3..000000000 --- a/basilisk.nvim/lua/basilisk/update.lua +++ /dev/null @@ -1,141 +0,0 @@ ---- In-editor install and upgrade of the basilisk binary. ---- ---- Implements [NVIM-BINARY-UPGRADE] — the flows behind :BasiliskUpdate and ---- :BasiliskInstall. Reuses binary.download() (the resolve() step-8 engine); ---- the curl/extract logic is never duplicated here. - -local binary = require("basilisk.binary") -local log = require("basilisk.log") - -local M = {} - ---- Refusal advice per install source ([NVIM-BINARY-UPGRADE-SOURCES]): ---- :BasiliskUpdate never clobbers a binary another tool owns — it steers the ---- user to that tool's own upgrade command instead. -local SOURCE_ADVICE = { - dev = "resolved binary is a local dev build (0.0.0) — rebuild your checkout instead of overwriting it with a release", - homebrew = "binary is managed by Homebrew — run `brew upgrade basilisk` instead", - scoop = "binary is managed by Scoop — run `scoop update basilisk` instead", - cargo = "binary was installed by cargo — run `cargo install --git " - .. binary.GITHUB_URL - .. " basilisk-cli` instead", -} - ---- Ask before touching the network, so the update notice has a real accept ---- step in the TUI ([NVIM-BINARY-UPGRADE-CONFIRM]). ----@param prompt string ----@param verb "Update"|"Install" ----@param on_accept fun() -local function confirm(prompt, verb, on_accept) - local accept = verb .. " now" - vim.ui.select({ accept, "Later" }, { prompt = prompt }, function(choice) - if choice == accept then - on_accept() - end - end) -end - ---- Download the latest release into the managed cache, point the plugin ---- config at it, and restart the LSP client on the new binary. ----@param config BasiliskConfig -local function download_and_restart(config) - local path, version = binary.download() - if not path then - log.error("download failed — check your network and :checkhealth basilisk") - return - end - config.binary_path = path - local plugin_ok, plugin = pcall(require, "basilisk") - if plugin_ok and plugin.config and plugin.config ~= config then - plugin.config.binary_path = path - end - local lsp = require("basilisk.lsp") - lsp.reset_restart_count() - lsp.restart(config, true) - log.info("installed %s — restarting the LSP server", version) -end - ---- :BasiliskUpdate — upgrade a plugin-managed (or manual) install to the ---- latest GitHub release. No-op when already current; refuses installs that ---- belong to a package manager or a dev checkout. ----@param config BasiliskConfig -function M.update(config) - local current = binary.locate(config.binary_path) - if not current then - M.install(config) - return - end - - local advice = SOURCE_ADVICE[binary.install_source(current)] - if advice then - log.warn("%s", advice) - return - end - - local release = binary.fetch_latest_release() - if not release then - log.error("could not reach GitHub for the latest release — check your network") - return - end - - local current_version = binary.version(current) - if current_version and not binary.is_newer_version(current_version, release.tag_name) then - log.info("already up to date (%s)", current_version) - return - end - - confirm( - string.format( - "Update basilisk %s → %s (downloads from GitHub releases)?", - current_version or "unknown version", - release.tag_name - ), - "Update", - function() - download_and_restart(config) - end - ) -end - ---- :BasiliskInstall — first-use bootstrap when no binary is resolvable ---- ([NVIM-BINARY-UPGRADE-INSTALL]). Surfaces the auto-download that ---- resolve() step 7 performs, but announced and behind a confirmation. ----@param config BasiliskConfig -function M.install(config) - local existing = binary.locate(config.binary_path) - if existing then - log.info( - "basilisk already installed: %s (%s) — use :BasiliskUpdate to upgrade", - existing, - binary.version(existing) or "unknown version" - ) - return - end - - local release = binary.fetch_latest_release() - if not release then - log.error("could not reach GitHub for the latest release — check your network") - return - end - - local asset = binary.platform_asset_name() - if not asset then - -- No release archive exists for this platform (Intel macOS), so the only - -- route is a from-source build ([NVIM-BINARY-UPGRADE-ASSETS]). - log.error( - "no prebuilt binary for this platform — build from source with `cargo install --git %s basilisk-cli`", - binary.GITHUB_URL - ) - return - end - - confirm( - string.format("Install basilisk %s (downloads %s from GitHub releases)?", release.tag_name, asset), - "Install", - function() - download_and_restart(config) - end - ) -end - -return M diff --git a/basilisk.nvim/plugin/basilisk.lua b/basilisk.nvim/plugin/basilisk.lua index ee34a3285..d3605dde0 100644 --- a/basilisk.nvim/plugin/basilisk.lua +++ b/basilisk.nvim/plugin/basilisk.lua @@ -1,10 +1,14 @@ --- Auto-loaded entry point for basilisk.nvim. ---- Guards against double-loading and provides the setup trigger. +--- +--- Implements [WITHDRAWAL-SURFACES]. The plugin is a notice: loading it says so +--- once per session and does nothing else. Nothing is registered, so removing +--- the plugin from a config is the only remaining action. if vim.g.loaded_basilisk then return end vim.g.loaded_basilisk = true --- Defer actual setup to require('basilisk').setup() so users control --- when configuration is applied. This file only sets the guard. +vim.schedule(function() + require("basilisk").announce() +end) diff --git a/basilisk.nvim/tests/basilisk/after_lsp_spec.lua b/basilisk.nvim/tests/basilisk/after_lsp_spec.lua deleted file mode 100644 index 30b09d6bc..000000000 --- a/basilisk.nvim/tests/basilisk/after_lsp_spec.lua +++ /dev/null @@ -1,56 +0,0 @@ ---- Tests for after/lsp/basilisk.lua — the native vim.lsp.config fallback for ---- users who never call require("basilisk").setup() ---- ([NVIM-LSP-CLIENT-CONFIGURATION-FALLBACK]). ---- ---- Neovim's built-in LSP loader requires this file to evaluate to a table. ---- Returning nil when no binary resolves surfaces to the user as ---- "after/lsp/basilisk.lua: not a table" — issue #370, symptom 3. - -describe("after/lsp/basilisk.lua", function() - local binary = require("basilisk.binary") - - --- Absolute path to the file under test (…/basilisk.nvim/after/lsp/basilisk.lua). - local function config_file() - local spec = debug.getinfo(1, "S").source:sub(2) - return vim.fn.fnamemodify(spec, ":h:h:h") .. "/after/lsp/basilisk.lua" - end - - --- Evaluate the file with `binary.resolve` forced to `resolved`. - ---@param resolved string? - ---@return any - local function load_with(resolved) - local original = binary.resolve - binary.resolve = function() - return resolved - end - local ok, result = pcall(dofile, config_file()) - binary.resolve = original - assert(ok, tostring(result)) - return result - end - - it("returns a table even when no binary resolves (issue #370)", function() - local config = load_with(nil) - - assert.are.equal( - "table", - type(config), - "Neovim's LSP loader errors with 'not a table' on anything else" - ) - end) - - it("still declares a runnable command when no binary resolves", function() - local config = load_with(nil) - - assert.are.equal("table", type(config.cmd), "cmd must survive an unresolved binary") - assert.are.equal("basilisk", vim.fn.fnamemodify(config.cmd[1], ":t")) - assert.are.equal("lsp", config.cmd[2]) - assert.are.same({ "python" }, config.filetypes) - end) - - it("uses the resolved binary when one is found", function() - local config = load_with("/opt/basilisk/bin/basilisk") - - assert.are.same({ "/opt/basilisk/bin/basilisk", "lsp" }, config.cmd) - end) -end) diff --git a/basilisk.nvim/tests/basilisk/binary_locate_spec.lua b/basilisk.nvim/tests/basilisk/binary_locate_spec.lua deleted file mode 100644 index ea7d35a18..000000000 --- a/basilisk.nvim/tests/basilisk/binary_locate_spec.lua +++ /dev/null @@ -1,124 +0,0 @@ ---- Tests for basilisk.binary.locate() — the download-free half of the ---- resolution cascade ([NVIM-BINARY-UPGRADE-INSTALL]). ---- ---- Covers the managed-cache scan ([NVIM-BINARY-UPGRADE-MANAGED-DISCOVERY], ---- `lua/basilisk/binary.lua` `newest_managed()` / cascade step 7): a binary the ---- plugin downloaded itself must stay resolvable from disk alone, with no ---- GitHub round trip — issue #370. - -describe("basilisk.binary.locate", function() - local binary = require("basilisk.binary") - - local MANAGED_ROOT = vim.fn.stdpath("data") .. "/basilisk" - - --- Write an executable stub at `//basilisk`. - ---@param version string - ---@return string path - local function install_managed(version) - local dir = MANAGED_ROOT .. "/" .. version - local path = dir .. "/basilisk" - vim.fn.mkdir(dir, "p") - vim.fn.writefile({ "#!/bin/sh", 'echo "basilisk ' .. version:gsub("^v", "") .. '"' }, path) - vim.fn.setfperm(path, "rwxr-xr-x") - return path - end - - --- Blind every cascade step except the managed cache, and make any network - --- call a hard failure — locate() is defined as download-free. - ---@param executable_paths table Paths that count as executable. - ---@return function restore - local function isolate_cascade(executable_paths) - local originals = { - executable = vim.fn.executable, - exepath = vim.fn.exepath, - env = vim.env.BASILISK_PATH, - fetch = binary.fetch_latest_release, - } - vim.fn.executable = function(path) - return executable_paths[path] and 1 or 0 - end - vim.fn.exepath = function() - return "" - end - vim.env.BASILISK_PATH = nil - binary.fetch_latest_release = function() - error("locate() must resolve from disk — it must never touch the network") - end - return function() - vim.fn.executable = originals.executable - vim.fn.exepath = originals.exepath - vim.env.BASILISK_PATH = originals.env - binary.fetch_latest_release = originals.fetch - end - end - - it("finds a plugin-managed install with no network access (issue #370)", function() - local version = "v9.99.0" - local managed = install_managed(version) - local restore = isolate_cascade({ [managed] = true }) - - local ok, result = pcall(binary.locate, nil) - - restore() - vim.fn.delete(MANAGED_ROOT .. "/" .. version, "rf") - - assert(ok, tostring(result)) - assert.are.equal( - managed, - result, - "a binary the plugin downloaded itself must stay resolvable from disk alone" - ) - end) - - it("returns the newest managed version when several are installed", function() - local older = install_managed("v0.9.0") - local newest = install_managed("v0.10.2") - local oldest = install_managed("v0.8.7") - local restore = isolate_cascade({ [older] = true, [newest] = true, [oldest] = true }) - - local ok, result = pcall(binary.locate, nil) - - restore() - for _, version in ipairs({ "v0.9.0", "v0.10.2", "v0.8.7" }) do - vim.fn.delete(MANAGED_ROOT .. "/" .. version, "rf") - end - - assert(ok, tostring(result)) - assert.are.equal(newest, result, "0.10.2 beats 0.9.0 — semver, not lexicographic order") - end) - - it("ignores a version directory left behind by a failed download", function() - local dir = MANAGED_ROOT .. "/v9.98.0" - vim.fn.mkdir(dir, "p") - -- An interrupted extraction leaves the directory without the binary. - local restore = isolate_cascade({}) - - local ok, result = pcall(binary.locate, nil) - - restore() - vim.fn.delete(dir, "rf") - - assert(ok, tostring(result)) - assert.is_nil(result, "a version dir with no executable is not an install") - end) - - it("reports nothing installed when the managed cache does not exist", function() - local stash = MANAGED_ROOT .. ".locate-spec-stash" - local had_cache = vim.fn.isdirectory(MANAGED_ROOT) == 1 - if had_cache then - vim.fn.rename(MANAGED_ROOT, stash) - end - local restore = isolate_cascade({}) - - local ok, result = pcall(binary.locate, nil) - - restore() - if had_cache then - vim.fn.delete(MANAGED_ROOT, "rf") - vim.fn.rename(stash, MANAGED_ROOT) - end - - assert(ok, "scanning an absent cache dir must not error: " .. tostring(result)) - assert.is_nil(result) - end) -end) diff --git a/basilisk.nvim/tests/basilisk/binary_spec.lua b/basilisk.nvim/tests/basilisk/binary_spec.lua deleted file mode 100644 index 1aba91a59..000000000 --- a/basilisk.nvim/tests/basilisk/binary_spec.lua +++ /dev/null @@ -1,819 +0,0 @@ ---- Tests for basilisk.binary module. ---- ---- Covers: resolve cascade, version parsing, semver comparison, ---- platform detection, GitHub release fetching, auto-download, ---- and async update checking. - -describe("basilisk.binary", function() - local binary = require("basilisk.binary") - - -- ── resolve cascade ────────────────────────────────────────────────────── - - describe("resolve", function() - it("returns nil when no binary exists", function() - local original_env = vim.env.BASILISK_PATH - vim.env.BASILISK_PATH = nil - - local result = binary.resolve("/nonexistent/path/to/basilisk") - assert.is_true(result == nil or type(result) == "string") - - vim.env.BASILISK_PATH = original_env - end) - - it("respects BASILISK_PATH env var", function() - local original = vim.env.BASILISK_PATH - vim.env.BASILISK_PATH = vim.fn.exepath("ls") - if vim.env.BASILISK_PATH ~= "" then - local result = binary.resolve() - assert.are.equal(vim.env.BASILISK_PATH, result) - end - vim.env.BASILISK_PATH = original - end) - - it("prefers configured path over env var", function() - local original = vim.env.BASILISK_PATH - local ls_path = vim.fn.exepath("ls") - if ls_path ~= "" then - vim.env.BASILISK_PATH = "/nonexistent/should/not/be/used" - local result = binary.resolve(ls_path) - assert.are.equal(ls_path, result) - end - vim.env.BASILISK_PATH = original - end) - - it("prefers configured path over well-known locations", function() - local cat_path = vim.fn.exepath("cat") - if cat_path ~= "" then - local result = binary.resolve(cat_path) - assert.are.equal(cat_path, result) - end - end) - - it("warns when configured path is not executable", function() - local notifications = {} - local orig_notify = vim.notify - vim.notify = function(msg, level) - notifications[#notifications + 1] = { msg = msg, level = level } - end - - binary.resolve("/totally/bogus/path/basilisk-nope") - - vim.notify = orig_notify - local found_warning = false - for _, notif in ipairs(notifications) do - if notif.msg:find("configured binary_path not found") and notif.level == vim.log.levels.WARN then - found_warning = true - break - end - end - assert.is_true(found_warning, "should warn when configured path doesn't exist") - end) - - it("falls through to env var when configured path is invalid", function() - local original = vim.env.BASILISK_PATH - local ls_path = vim.fn.exepath("ls") - if ls_path ~= "" then - vim.env.BASILISK_PATH = ls_path - -- Suppress the warning notification. - local orig_notify = vim.notify - vim.notify = function() end - local result = binary.resolve("/nonexistent/configured/path") - vim.notify = orig_notify - assert.are.equal(ls_path, result) - end - vim.env.BASILISK_PATH = original - end) - - it("finds binary on OS PATH when nothing else matches", function() - local original = vim.env.BASILISK_PATH - vim.env.BASILISK_PATH = nil - -- "ls" is always on PATH — use it as a proxy. - -- We can't easily test this for "basilisk" but we verify the cascade - -- reaches step 6 by checking exepath is called. - local result = binary.resolve(nil) - -- Result may be nil (no basilisk installed) or a real path — both are valid. - assert.is_true(result == nil or type(result) == "string") - vim.env.BASILISK_PATH = original - end) - end) - - -- ── version ────────────────────────────────────────────────────────────── - - describe("version", function() - it("returns nil for non-existent binary", function() - assert.is_nil(binary.version("/nonexistent/binary")) - end) - - it("returns nil for non-executable path", function() - -- A regular file that exists but isn't executable. - local tmpfile = vim.fn.tempname() - local fh = io.open(tmpfile, "w") - fh:write("not a binary") - fh:close() - vim.fn.setfperm(tmpfile, "rw-r--r--") - assert.is_nil(binary.version(tmpfile)) - vim.fn.delete(tmpfile) - end) - - it("returns a trimmed string for a valid binary", function() - local ls_path = vim.fn.exepath("ls") - if ls_path ~= "" then - local result = binary.version(ls_path) - if result then - assert.are.equal(result, vim.trim(result), "version should be trimmed") - assert.is_true(#result > 0, "version should not be empty") - end - end - end) - end) - - -- ── is_newer_version ────────────────────────────────────────────────────── - - describe("is_newer_version", function() - -- Major version bumps. - it("detects newer major version", function() - assert.is_true(binary.is_newer_version("0.2.1", "1.0.0")) - end) - - it("detects much newer major version", function() - assert.is_true(binary.is_newer_version("1.0.0", "5.0.0")) - end) - - -- Minor version bumps. - it("detects newer minor version", function() - assert.is_true(binary.is_newer_version("0.2.1", "0.3.0")) - end) - - it("detects newer minor version with lower patch", function() - assert.is_true(binary.is_newer_version("0.2.9", "0.3.0")) - end) - - -- Patch version bumps. - it("detects newer patch version", function() - assert.is_true(binary.is_newer_version("0.2.1", "0.2.2")) - end) - - it("detects newer patch from zero", function() - assert.is_true(binary.is_newer_version("0.2.0", "0.2.1")) - end) - - -- Same / older. - it("returns false for same version", function() - assert.is_false(binary.is_newer_version("0.2.1", "0.2.1")) - end) - - it("returns false for same version with v prefix", function() - assert.is_false(binary.is_newer_version("v0.2.1", "v0.2.1")) - end) - - it("returns false when current is newer major", function() - assert.is_false(binary.is_newer_version("1.0.0", "0.9.9")) - end) - - it("returns false when current is newer minor", function() - assert.is_false(binary.is_newer_version("0.5.0", "0.4.9")) - end) - - it("returns false when current is newer patch", function() - assert.is_false(binary.is_newer_version("0.2.3", "0.2.2")) - end) - - -- Prefix stripping. - it("handles v prefix on latest only", function() - assert.is_true(binary.is_newer_version("0.2.1", "v0.3.0")) - end) - - it("handles v prefix on current only", function() - assert.is_true(binary.is_newer_version("v0.2.1", "0.3.0")) - end) - - it("handles v prefix on both", function() - assert.is_true(binary.is_newer_version("v0.2.1", "v0.3.0")) - end) - - it("handles 'basilisk ' prefix from --version output", function() - assert.is_true(binary.is_newer_version("basilisk 0.2.1", "v0.3.0")) - end) - - it("handles 'basilisk ' prefix with same version", function() - assert.is_false(binary.is_newer_version("basilisk 0.3.0", "v0.3.0")) - end) - - it("handles 'basilisk ' prefix on both sides", function() - assert.is_true(binary.is_newer_version("basilisk 0.1.0", "basilisk 0.2.0")) - end) - - -- Edge cases. - it("handles versions with only major.minor (no patch)", function() - -- parse_semver returns 0 for missing patch. - assert.is_true(binary.is_newer_version("0.2", "0.3.0")) - end) - - it("handles empty string as current", function() - assert.is_true(binary.is_newer_version("", "0.1.0")) - end) - - it("handles garbage input gracefully", function() - -- All components parse to 0 → same version → not newer. - assert.is_false(binary.is_newer_version("garbage", "garbage")) - end) - end) - - -- ── platform_asset_name ────────────────────────────────────────────────── - - describe("platform_asset_name", function() - it("returns a valid asset name for the current platform", function() - local name, is_windows = binary.platform_asset_name() - assert.is_not_nil(name, "should detect platform on CI/dev machines") - assert.is_true(type(is_windows) == "boolean") - end) - - it("asset name starts with 'basilisk-'", function() - local name = binary.platform_asset_name() - if name then - assert.is_true(name:match("^basilisk%-") ~= nil, "should start with 'basilisk-'") - end - end) - - it("asset name contains architecture", function() - local name = binary.platform_asset_name() - if name then - local has_arch = name:match("aarch64") or name:match("x86_64") - assert.is_truthy(has_arch, "should contain aarch64 or x86_64") - end - end) - - it("asset name contains OS identifier", function() - local name = binary.platform_asset_name() - if name then - local has_os = name:match("apple%-darwin") or name:match("unknown%-linux%-gnu") or name:match("pc%-windows%-msvc") - assert.is_truthy(has_os, "should contain OS identifier") - end - end) - - -- The archives release.yml actually publishes ([NVIM-BINARY-UPGRADE-ASSETS]). - -- Anything else means download() silently finds no asset. - local PUBLISHED_ARCHIVES = { - ["basilisk-x86_64-unknown-linux-gnu.tar.gz"] = true, - ["basilisk-aarch64-unknown-linux-gnu.tar.gz"] = true, - ["basilisk-aarch64-apple-darwin.zip"] = true, - ["basilisk-x86_64-pc-windows-msvc.zip"] = true, - ["basilisk-aarch64-pc-windows-msvc.zip"] = true, - } - - it("asset name is one of the published release archives", function() - local name = binary.platform_asset_name() - if name then - assert.is_true( - PUBLISHED_ARCHIVES[name] == true, - "asset name not published by release.yml: " .. name - ) - end - end) - - it("Linux asset ends with .tar.gz", function() - local name = binary.platform_asset_name() - if name and vim.uv.os_uname().sysname:lower() == "linux" then - assert.is_truthy(name:match("%.tar%.gz$"), "Linux should end with .tar.gz") - end - end) - - it("macOS asset is the exact release archive (zip, aarch64-only)", function() - local uname = vim.uv.os_uname() - if uname.sysname:lower() ~= "darwin" then - return - end - local name = binary.platform_asset_name() - local machine = uname.machine:lower() - if machine == "arm64" or machine == "aarch64" then - assert.are.equal("basilisk-aarch64-apple-darwin.zip", name) - else - -- No x86_64-apple-darwin archive is published — must not fabricate one. - assert.is_nil(name) - end - end) - - it("Windows asset ends with .zip", function() - local name, is_windows = binary.platform_asset_name() - if name and is_windows then - assert.is_truthy(name:match("%.zip$"), "Windows should end with .zip") - end - end) - - it("matches the pattern from basilisk-common release::asset_name", function() - local name = binary.platform_asset_name() - if name then - -- Format: basilisk-{arch}-{os}.{ext} - local arch, os_str = name:match("^basilisk%-([^%-]+)%-(.+)%.tar%.gz$") - if not arch then - arch, os_str = name:match("^basilisk%-([^%-]+)%-(.+)%.zip$") - end - assert.is_truthy(arch, "should match asset_name format: got " .. name) - assert.is_truthy(os_str, "should have OS string in asset name") - end - end) - end) - - -- ── fetch_latest_release ───────────────────────────────────────────────── - - describe("fetch_latest_release", function() - it("returns a table with tag_name when GitHub is reachable", function() - local release = binary.fetch_latest_release() - if release then - assert.is_true(type(release.tag_name) == "string", "tag_name should be a string") - assert.is_true(#release.tag_name > 0, "tag_name should not be empty") - end - end) - - it("release has assets array", function() - local release = binary.fetch_latest_release() - if release then - assert.is_true(type(release.assets) == "table", "assets should be a table") - end - end) - - it("each asset has name and browser_download_url", function() - local release = binary.fetch_latest_release() - if release and release.assets and #release.assets > 0 then - for _, asset in ipairs(release.assets) do - assert.is_true(type(asset.name) == "string", "asset.name should be a string") - assert.is_true(#asset.name > 0, "asset.name should not be empty") - assert.is_true( - type(asset.browser_download_url) == "string", - "asset.browser_download_url should be a string" - ) - assert.is_truthy( - asset.browser_download_url:match("^https://"), - "download URL should be HTTPS" - ) - end - end - end) - - -- The user-facing requirement is that the plugin can OBTAIN a binary for - -- this platform, which is strictly stronger than "the newest release - -- happens to carry one": it still fails when no release publishes this - -- asset, and it additionally covers the fallback path. Asserting only - -- against `fetch_latest_release()` would go red whenever a release is - -- published before its upload job runs, while users were downloading fine. - -- [NVIM-BINARY-UPGRADE-ASSETS] - it("a downloadable asset exists for our platform", function() - local our_asset = binary.platform_asset_name() - if not our_asset then - return - end - -- Same contract as every other live test here: the API is rate-limited - -- for unauthenticated callers (403), and an unreachable GitHub is an - -- environment fact, not a product defect. When it IS reachable the - -- assertion below is real and unconditional. - if not binary.fetch_latest_release() and not binary.fetch_releases() then - pending("GitHub unreachable") - return - end - local release, url = binary.find_release_with_asset(our_asset) - assert.is_truthy(release, "no release publishes an asset for: " .. our_asset) - assert.is_truthy(url, "resolved release must carry a download URL") - assert.is_truthy( - url:match("^https://"), - "download URL should be HTTPS, got: " .. tostring(url) - ) - end) - - it("skips a newest release that publishes no assets", function() - -- The #370 dead end: a release exists from its tag before its upload job - -- runs, so `releases/latest` can legitimately carry zero assets. Stopping - -- there returns nothing; the resolver must keep looking. - local wanted = "basilisk-x86_64-unknown-linux-gnu.tar.gz" - local latest = binary.fetch_latest_release - local list = binary.fetch_releases - binary.fetch_latest_release = function() - return { tag_name = "v9.9.9", assets = {} } - end - binary.fetch_releases = function() - return { - { tag_name = "v9.9.9", assets = {} }, - { - tag_name = "v9.9.8", - assets = { { name = wanted, browser_download_url = "https://example.com/a.tar.gz" } }, - }, - } - end - local ok, release, url = pcall(binary.find_release_with_asset, wanted) - binary.fetch_latest_release = latest - binary.fetch_releases = list - assert.is_true(ok, "resolver must not error on an asset-less newest release") - assert.is_truthy(release, "resolver must fall back past the empty release") - assert.are.equal("v9.9.8", release.tag_name) - assert.are.equal("https://example.com/a.tar.gz", url) - end) - - it("returns nothing when no release publishes our asset", function() - local latest = binary.fetch_latest_release - local list = binary.fetch_releases - binary.fetch_latest_release = function() - return { tag_name = "v9.9.9", assets = {} } - end - binary.fetch_releases = function() - return { { tag_name = "v9.9.9", assets = {} } } - end - local ok, release, url = pcall(binary.find_release_with_asset, "no-such-asset.tar.gz") - binary.fetch_latest_release = latest - binary.fetch_releases = list - assert.is_true(ok, "resolver must not error when nothing matches") - assert.is_nil(release, "must not invent a release") - assert.is_nil(url, "must not invent a download URL") - end) - - it("tag_name looks like a semver version", function() - local release = binary.fetch_latest_release() - if release then - local stripped = release.tag_name:gsub("^v", "") - assert.is_truthy( - stripped:match("^%d+%.%d+%.%d+"), - "tag should be semver-ish, got: " .. release.tag_name - ) - end - end) - end) - - -- ── download ───────────────────────────────────────────────────────────── - - describe("download", function() - it("downloads and extracts a working binary (requires network)", function() - local asset_name = binary.platform_asset_name() - if not asset_name then - pending("no published asset for this platform") - return - end - -- The release download() resolves is NOT always the newest one: a release - -- published before its upload job ran carries zero assets, and download() - -- skips past it. Pinning the version assertion below to the release the - -- binary ACTUALLY came from is stronger than pinning it to the newest - -- tag — it ties the reported version to the artifact on disk. - -- [NVIM-BINARY-UPGRADE-ASSETS] - local release = binary.find_release_with_asset(asset_name) - if not release then - pending("GitHub unreachable — skipping download test") - return - end - - local path, version = binary.download() - if not path then - pending("Download failed — may be a transient network issue") - return - end - - -- Path assertions. - assert.is_true(type(path) == "string", "path should be a string") - assert.is_true(#path > 0, "path should not be empty") - assert.is_true(vim.fn.filereadable(path) == 1, "downloaded binary should exist on disk") - assert.is_true(vim.fn.executable(path) == 1, "downloaded binary should be executable") - - -- Version assertions. - assert.is_true(type(version) == "string", "version should be a string") - assert.is_true(#version > 0, "version should not be empty") - assert.are.equal(release.tag_name, version, "version must match the release the binary came from") - - -- Path should be under stdpath("data")/basilisk//. - local expected_dir = vim.fn.stdpath("data") .. "/basilisk/" .. version - assert.is_truthy( - path:find(expected_dir, 1, true), - "binary should be in version-specific cache dir" - ) - - -- Binary name should be 'basilisk' (or 'basilisk.exe' on Windows). - local basename = vim.fn.fnamemodify(path, ":t") - assert.is_true( - basename == "basilisk" or basename == "basilisk.exe", - "binary name should be basilisk, got: " .. basename - ) - - -- Clean up. - vim.fn.delete(expected_dir, "rf") - end) - - it("returns cached binary on second call without re-downloading", function() - local release = binary.fetch_latest_release() - if not release then - pending("GitHub unreachable") - return - end - - local path1, version1 = binary.download() - if not path1 then - pending("Download failed") - return - end - - -- Second call should return the same path from cache. - local path2, version2 = binary.download() - assert.are.equal(path1, path2, "second call should return cached path") - assert.are.equal(version1, version2, "second call should return same version") - - -- Clean up. - local dir = vim.fn.stdpath("data") .. "/basilisk/" .. version1 - vim.fn.delete(dir, "rf") - end) - - it("extracts Windows zips with tar, not unzip (stock Windows has no unzip)", function() - local original_system = vim.fn.system - local original_asset = binary.platform_asset_name - local original_fetch = binary.fetch_latest_release - - -- Capture every shell command download() issues; run a no-op through - -- the real system() so vim.v.shell_error stays 0 (it is read-only). - local commands = {} - vim.fn.system = function(cmd) - table.insert(commands, cmd) - return original_system({ "true" }) - end - binary.platform_asset_name = function() - return "basilisk-x86_64-pc-windows-msvc.zip", true - end - binary.fetch_latest_release = function() - return { - tag_name = "v0.0.0-windows-test", - assets = { - { - name = "basilisk-x86_64-pc-windows-msvc.zip", - browser_download_url = "https://example.invalid/basilisk.zip", - }, - }, - } - end - - local ok, err = pcall(function() - binary.download() - - local extract_cmd - for _, cmd in ipairs(commands) do - if type(cmd) == "table" and (cmd[1] == "unzip" or cmd[1] == "tar") then - extract_cmd = cmd - end - end - assert.is_truthy(extract_cmd, "download() should have attempted an extraction") - assert.are.equal( - "tar", - extract_cmd[1], - "Windows zips must extract via in-box tar.exe (bsdtar, Windows 10 1803+) — " - .. "stock Windows has no unzip, got: " .. tostring(extract_cmd[1]) - ) - end) - - vim.fn.system = original_system - binary.platform_asset_name = original_asset - binary.fetch_latest_release = original_fetch - vim.fn.delete(vim.fn.stdpath("data") .. "/basilisk/v0.0.0-windows-test", "rf") - - assert(ok, err) - end) - end) - - -- ── install_source ─────────────────────────────────────────────────────── - - describe("install_source", function() - it("classifies the plugin-managed cache dir as managed", function() - local managed = vim.fn.stdpath("data") .. "/basilisk/v0.33.0/basilisk" - assert.are.equal("managed", binary.install_source(managed)) - end) - - it("classifies Homebrew prefixes as homebrew", function() - assert.are.equal("homebrew", binary.install_source("/opt/homebrew/bin/basilisk")) - assert.are.equal("homebrew", binary.install_source("/usr/local/Cellar/basilisk/0.33.0/bin/basilisk")) - assert.are.equal("homebrew", binary.install_source("/home/linuxbrew/.linuxbrew/bin/basilisk")) - end) - - it("classifies scoop shims as scoop", function() - assert.are.equal("scoop", binary.install_source("C:/Users/dev/scoop/shims/basilisk.exe")) - end) - - it("classifies ~/.cargo/bin as cargo", function() - local cargo_bin = vim.fs.normalize("~/.cargo/bin/basilisk") - assert.are.equal("cargo", binary.install_source(cargo_bin)) - end) - - it("classifies a 0.0.0-PLACEHOLDER binary as dev", function() - local tmpfile = vim.fn.tempname() - local fh = io.open(tmpfile, "w") - fh:write("#!/bin/sh\necho 'basilisk 0.0.0-PLACEHOLDER'\n") - fh:close() - vim.fn.setfperm(tmpfile, "rwxr-xr-x") - assert.are.equal("dev", binary.install_source(tmpfile)) - vim.fn.delete(tmpfile) - end) - - it("classifies everything else as manual", function() - assert.are.equal("manual", binary.install_source("/some/random/place/basilisk")) - end) - end) - - -- ── upgrade_hint ───────────────────────────────────────────────────────── - - describe("upgrade_hint", function() - it("points managed and manual installs at :BasiliskUpdate", function() - assert.is_truthy(binary.upgrade_hint("managed"):find(":BasiliskUpdate", 1, true)) - assert.is_truthy(binary.upgrade_hint("manual"):find(":BasiliskUpdate", 1, true)) - end) - - it("points package-manager installs at their own upgrade command", function() - assert.is_truthy(binary.upgrade_hint("homebrew"):find("brew upgrade basilisk", 1, true)) - assert.is_truthy(binary.upgrade_hint("scoop"):find("scoop update basilisk", 1, true)) - assert.is_truthy( - binary.upgrade_hint("cargo"):find( - "cargo install --git https://github.com/Nimblesite/Basilisk basilisk-cli", - 1, - true - ) - ) - end) - - it("never advises the unpublished bare cargo install (issue #370)", function() - -- `basilisk-cli` is not on crates.io, so the bare form always fails with - -- "could not find basilisk-cli in registry". Every cargo hint must carry - -- --git ([NVIM-BINARY-UPGRADE-SOURCES]). - for _, source in ipairs({ "managed", "manual", "homebrew", "scoop", "cargo" }) do - local hint = binary.upgrade_hint(source) - local bare = hint:find("cargo install basilisk%-cli") - assert.is_nil(bare, source .. " hint must not name the unpublished crates.io install") - end - end) - - it("returns nil for dev builds (no upgrade nag)", function() - assert.is_nil(binary.upgrade_hint("dev")) - end) - end) - - -- ── check_for_updates ──────────────────────────────────────────────────── - - describe("check_for_updates", function() - it("notice names an actionable command, not :checkhealth", function() - -- Fake binary reporting an ancient version from a "manual" location. - local tmpfile = vim.fn.tempname() - local fh = io.open(tmpfile, "w") - fh:write("#!/bin/sh\necho 'basilisk 0.0.1'\n") - fh:close() - vim.fn.setfperm(tmpfile, "rwxr-xr-x") - - -- Stub vim.system so no network is hit and the callback runs promptly. - local orig_system = vim.system - ---@diagnostic disable-next-line: duplicate-set-field - vim.system = function(_cmd, _opts, on_exit) - on_exit({ code = 0, stdout = '{"tag_name": "v99.99.99"}' }) - return {} - end - - local notifications = {} - local orig_notify = vim.notify - vim.notify = function(msg) - notifications[#notifications + 1] = msg - end - - binary.check_for_updates(tmpfile) - vim.wait(1000, function() - return #notifications > 0 - end) - - vim.notify = orig_notify - vim.system = orig_system - vim.fn.delete(tmpfile) - - assert.is_true(#notifications > 0, "should notify about the update") - local msg = notifications[1] - assert.is_truthy(msg:find(":BasiliskUpdate", 1, true), "notice must name :BasiliskUpdate, got: " .. msg) - assert.is_falsy(msg:find("checkhealth", 1, true), "notice must not dead-end into :checkhealth") - end) - - it("stays silent for dev builds", function() - local tmpfile = vim.fn.tempname() - local fh = io.open(tmpfile, "w") - fh:write("#!/bin/sh\necho 'basilisk 0.0.0-PLACEHOLDER'\n") - fh:close() - vim.fn.setfperm(tmpfile, "rwxr-xr-x") - - local orig_system = vim.system - local fetched = false - ---@diagnostic disable-next-line: duplicate-set-field - vim.system = function(_cmd, _opts, on_exit) - fetched = true - on_exit({ code = 0, stdout = '{"tag_name": "v99.99.99"}' }) - return {} - end - - local notifications = {} - local orig_notify = vim.notify - vim.notify = function(msg) - notifications[#notifications + 1] = msg - end - - binary.check_for_updates(tmpfile) - vim.wait(200, function() - return #notifications > 0 - end) - - vim.notify = orig_notify - vim.system = orig_system - vim.fn.delete(tmpfile) - - assert.are.equal(0, #notifications, "dev builds must not be nagged about releases") - assert.is_false(fetched, "dev builds should not even hit the release API") - end) - - it("does not error for non-existent binary", function() - assert.has_no.errors(function() - binary.check_for_updates("/nonexistent/binary") - end) - end) - - it("does not error for a valid binary path", function() - local ls_path = vim.fn.exepath("ls") - if ls_path ~= "" then - assert.has_no.errors(function() - binary.check_for_updates(ls_path) - end) - end - end) - - it("runs asynchronously without blocking", function() - local ls_path = vim.fn.exepath("ls") - if ls_path == "" then - return - end - - -- Time the call — should return immediately since it's async. - local start = vim.uv.hrtime() - binary.check_for_updates(ls_path) - local elapsed_ms = (vim.uv.hrtime() - start) / 1e6 - - -- Async call should return in under 100ms (no network blocking). - assert.is_true( - elapsed_ms < 100, - "check_for_updates should be async, took " .. elapsed_ms .. "ms" - ) - end) - - it("notifies user when update is available (simulated)", function() - -- Simulate by calling is_newer_version directly — the actual async - -- notification path is tested by checking it doesn't crash. - local would_notify = binary.is_newer_version("0.0.1", "99.99.99") - assert.is_true(would_notify, "should detect that 99.99.99 > 0.0.1") - end) - - it("does not notify when already on latest (simulated)", function() - local would_notify = binary.is_newer_version("99.99.99", "0.0.1") - assert.is_false(would_notify, "should not flag downgrade as update") - end) - end) - - -- ── resolve with auto-download integration ─────────────────────────────── - - describe("resolve with auto-download fallback", function() - it("resolve returns a path even without local install (requires network)", function() - local release = binary.fetch_latest_release() - if not release then - pending("GitHub unreachable") - return - end - - -- Clear env and use bogus configured path to force download cascade. - local original = vim.env.BASILISK_PATH - vim.env.BASILISK_PATH = nil - - -- Suppress notifications. - local orig_notify = vim.notify - local notifications = {} - vim.notify = function(msg, level) - notifications[#notifications + 1] = { msg = msg, level = level } - end - - local result = binary.resolve("/nonexistent/configured/path") - - vim.notify = orig_notify - vim.env.BASILISK_PATH = original - - -- On a machine without basilisk installed, this should have - -- downloaded from GitHub. On a machine with it, it found it locally. - if result then - assert.is_true(type(result) == "string") - assert.is_true(vim.fn.executable(result) == 1) - - -- If it came from download, check the notification. - if result:find(vim.fn.stdpath("data") .. "/basilisk/") then - local found_download_msg = false - for _, notif in ipairs(notifications) do - if notif.msg:find("downloading") or notif.msg:find("installed") then - found_download_msg = true - break - end - end - assert.is_true(found_download_msg, "should notify about download progress") - - -- Clean up the downloaded binary. - local version_dir = result:match("(.*/basilisk/[^/]+)/") - if version_dir then - vim.fn.delete(version_dir, "rf") - end - end - end - end) - end) -end) diff --git a/basilisk.nvim/tests/basilisk/codelens_spec.lua b/basilisk.nvim/tests/basilisk/codelens_spec.lua deleted file mode 100644 index e7adfbc65..000000000 --- a/basilisk.nvim/tests/basilisk/codelens_spec.lua +++ /dev/null @@ -1,119 +0,0 @@ ---- Tests for basilisk.codelens module. ---- ---- Pins [NVIM-LSP-CLIENT-CONFIGURATION-API-MAPPINGS] (Code Lens row): the ---- plugin must activate code lens through `vim.lsp.codelens.enable` whenever the ---- runtime exposes it (Neovim 0.12+, which installs its own debounced refresh), ---- and fall back to `refresh()` plus a manual BufEnter/InsertLeave loop only on ---- 0.10/0.11 — `refresh()` is deprecated on 0.12 and removed on 0.13, so calling ---- it on a modern runtime is a deprecation warning today and a break tomorrow. ---- ---- Both branches are exercised on ONE Neovim by swapping the `vim.lsp.codelens` ---- table, so the version the tests happen to run on never decides which half of ---- the contract is checked. - -describe("basilisk.codelens", function() - local codelens = require("basilisk.codelens") - - local original - local calls - - before_each(function() - original = vim.lsp.codelens - calls = { enable = {}, refresh = {} } - end) - - after_each(function() - vim.lsp.codelens = original - end) - - --- Install a stub `vim.lsp.codelens` recording its calls. `with_enable` - --- decides whether the modern API appears to exist. - local function stub_codelens(with_enable) - local stub = { - refresh = function(opts) - table.insert(calls.refresh, opts) - end, - } - if with_enable then - stub.enable = function(on, opts) - table.insert(calls.enable, { on = on, opts = opts }) - end - end - vim.lsp.codelens = stub - end - - describe("activate on a runtime with vim.lsp.codelens.enable", function() - it("enables code lens for the buffer and never calls the deprecated refresh", function() - local bufnr = vim.api.nvim_create_buf(false, true) - stub_codelens(true) - - codelens.activate(bufnr) - - assert.equals(1, #calls.enable, "must enable code lens exactly once") - assert.is_true(calls.enable[1].on, "must enable, not disable") - assert.equals(bufnr, calls.enable[1].opts.bufnr, "must target the given buffer") - assert.equals(0, #calls.refresh, "refresh() is deprecated on 0.12+ and must not be called") - - vim.api.nvim_buf_delete(bufnr, { force = true }) - end) - - it("registers no refresh autocmds — the API installs its own", function() - local bufnr = vim.api.nvim_create_buf(false, true) - stub_codelens(true) - - codelens.activate(bufnr) - local autocmds = vim.api.nvim_get_autocmds({ - event = { "BufEnter", "InsertLeave" }, - buffer = bufnr, - }) - - assert.equals(0, #autocmds, "duplicating the built-in refresh loop would double-request lenses") - - vim.api.nvim_buf_delete(bufnr, { force = true }) - end) - end) - - describe("activate on a runtime without vim.lsp.codelens.enable", function() - it("refreshes immediately for the buffer", function() - local bufnr = vim.api.nvim_create_buf(false, true) - stub_codelens(false) - - codelens.activate(bufnr) - - assert.is_true(#calls.refresh >= 1, "0.10/0.11 must get an initial refresh") - assert.equals(bufnr, calls.refresh[1].bufnr, "must refresh the given buffer") - - vim.api.nvim_buf_delete(bufnr, { force = true }) - end) - - it("keeps lenses current by refreshing on BufEnter and InsertLeave", function() - local bufnr = vim.api.nvim_create_buf(false, true) - stub_codelens(false) - - codelens.activate(bufnr) - local before = #calls.refresh - vim.api.nvim_exec_autocmds("BufEnter", { buffer = bufnr }) - vim.api.nvim_exec_autocmds("InsertLeave", { buffer = bufnr }) - - assert.equals(before + 2, #calls.refresh, "both events must re-request lenses") - assert.equals(bufnr, calls.refresh[#calls.refresh].bufnr, "every refresh stays buffer-scoped") - - vim.api.nvim_buf_delete(bufnr, { force = true }) - end) - - it("scopes its autocmds to the buffer it was given", function() - local bufnr = vim.api.nvim_create_buf(false, true) - local other = vim.api.nvim_create_buf(false, true) - stub_codelens(false) - - codelens.activate(bufnr) - local before = #calls.refresh - vim.api.nvim_exec_autocmds("BufEnter", { buffer = other }) - - assert.equals(before, #calls.refresh, "another buffer's events must not refresh this one") - - vim.api.nvim_buf_delete(bufnr, { force = true }) - vim.api.nvim_buf_delete(other, { force = true }) - end) - end) -end) diff --git a/basilisk.nvim/tests/basilisk/commands_spec.lua b/basilisk.nvim/tests/basilisk/commands_spec.lua deleted file mode 100644 index 55ad3fd20..000000000 --- a/basilisk.nvim/tests/basilisk/commands_spec.lua +++ /dev/null @@ -1,180 +0,0 @@ ---- 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") - - -- Register all commands once with default config. - local resolved = config.resolve() - commands.register(resolved) - - --- Helper: assert a user command is registered. - ---@param name string - local function assert_command_exists(name) - local user_commands = vim.api.nvim_get_commands({}) - assert.is_not_nil(user_commands[name], name .. " should be registered") - end - - describe("core commands", function() - it("registers BasiliskRestart", function() - assert_command_exists("BasiliskRestart") - end) - - it("registers BasiliskInfo", function() - assert_command_exists("BasiliskInfo") - end) - - it("registers BasiliskOrganizeImports", function() - assert_command_exists("BasiliskOrganizeImports") - end) - - it("registers BasiliskFixFile", function() - assert_command_exists("BasiliskFixFile") - end) - - it("registers BasiliskFixWorkspace", function() - assert_command_exists("BasiliskFixWorkspace") - end) - - it("registers BasiliskAdoptFile", function() - assert_command_exists("BasiliskAdoptFile") - end) - - it("registers BasiliskAdoptWorkspace", function() - assert_command_exists("BasiliskAdoptWorkspace") - end) - - it("registers BasiliskUnadoptFile", function() - assert_command_exists("BasiliskUnadoptFile") - end) - - it("registers BasiliskShowOutput", function() - assert_command_exists("BasiliskShowOutput") - end) - - it("registers BasiliskUpdate", function() - assert_command_exists("BasiliskUpdate") - end) - - it("registers BasiliskInstall", function() - assert_command_exists("BasiliskInstall") - end) - end) - - describe("refactoring commands", function() - it("registers BasiliskExtractVariable", function() - assert_command_exists("BasiliskExtractVariable") - end) - - it("registers BasiliskExtractConstant", function() - assert_command_exists("BasiliskExtractConstant") - end) - - it("registers BasiliskConvertUnion", function() - assert_command_exists("BasiliskConvertUnion") - end) - - it("registers BasiliskImplementMethods", function() - assert_command_exists("BasiliskImplementMethods") - end) - end) - - describe("profiling commands", function() - it("registers BasiliskProfile", function() - assert_command_exists("BasiliskProfile") - end) - - it("registers BasiliskProfileStop", function() - assert_command_exists("BasiliskProfileStop") - end) - - it("registers BasiliskProfileSnapshot", function() - assert_command_exists("BasiliskProfileSnapshot") - end) - end) - - describe("memory commands", function() - it("registers BasiliskMemLeak", function() - assert_command_exists("BasiliskMemLeak") - end) - - it("registers BasiliskMemStop", function() - assert_command_exists("BasiliskMemStop") - end) - - it("registers BasiliskMemRefs", function() - assert_command_exists("BasiliskMemRefs") - end) - end) - - describe("debug commands", function() - it("registers BasiliskDebugFile", function() - assert_command_exists("BasiliskDebugFile") - end) - end) - - describe("test commands", function() - it("registers BasiliskTestDiscover", function() - assert_command_exists("BasiliskTestDiscover") - end) - - it("registers BasiliskTestRun", function() - assert_command_exists("BasiliskTestRun") - end) - - it("registers BasiliskTestDebug", function() - assert_command_exists("BasiliskTestDebug") - end) - - it("registers BasiliskTestToggle", function() - assert_command_exists("BasiliskTestToggle") - end) - end) - - describe("uv commands", function() - it("registers BasiliskUvSync", function() - assert_command_exists("BasiliskUvSync") - end) - - it("registers BasiliskUvAdd", function() - assert_command_exists("BasiliskUvAdd") - end) - - it("registers BasiliskUvAddDev", function() - assert_command_exists("BasiliskUvAddDev") - end) - - it("registers BasiliskUvRemove", function() - assert_command_exists("BasiliskUvRemove") - end) - - it("registers BasiliskUvLock", function() - assert_command_exists("BasiliskUvLock") - end) - - it("registers BasiliskUvCreateEnv", function() - assert_command_exists("BasiliskUvCreateEnv") - end) - end) - - 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_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/config_spec.lua b/basilisk.nvim/tests/basilisk/config_spec.lua deleted file mode 100644 index 91abd76a4..000000000 --- a/basilisk.nvim/tests/basilisk/config_spec.lua +++ /dev/null @@ -1,124 +0,0 @@ ---- Tests for basilisk.config module. ---- ---- Tests [NVIM-NEOVIM-ONLY-CONFIGURATION] and the keymap defaults from ---- [NVIM-DEFAULT-KEYMAPS-BASILISK-SPECIFIC] (prefix "b"). - -describe("basilisk.config", function() - local config = require("basilisk.config") - - describe("defaults", function() - it("has correct analysis_mode default", function() - assert.are.equal("wholeModule", config.defaults.analysis_mode) - end) - - it("has inlay hints enabled by default", function() - assert.is_true(config.defaults.inlay_hints.parameter_names) - assert.is_true(config.defaults.inlay_hints.variable_types) - end) - - it("uses the embedded ruff formatter by default", function() - -- [LSPFMT-CONFIG]: "ruff" = the formatter embedded in the basilisk - -- binary; no external ruff executable setting exists any more. - assert.are.equal("ruff", config.defaults.formatter) - end) - - it("has debugger enabled by default", function() - assert.is_true(config.defaults.debugger.enabled) - assert.is_false(config.defaults.debugger.type_checking) - assert.are.equal("debugpy", config.defaults.debugger.debugpy_path) - end) - - it("has test explorer with correct defaults", function() - assert.is_true(config.defaults.test_explorer.enabled) - assert.are.equal("auto", config.defaults.test_explorer.framework) - assert.are.equal("pytest", config.defaults.test_explorer.pytest_path) - assert.are.same({}, config.defaults.test_explorer.args) - assert.is_true(config.defaults.test_explorer.auto_discover_on_save) - assert.are.equal("right", config.defaults.test_explorer.position) - assert.are.equal(40, config.defaults.test_explorer.width) - end) - - it("has uv enabled by default", function() - assert.is_true(config.defaults.uv.enabled) - assert.is_nil(config.defaults.uv.executable_path) - assert.is_false(config.defaults.uv.auto_sync) - end) - - it("has keymaps enabled by default", function() - assert.is_true(config.defaults.keymaps.enabled) - assert.are.equal("b", config.defaults.keymaps.prefix) - end) - - it("has correct log_level default", function() - assert.are.equal("info", config.defaults.log_level) - end) - end) - - describe("resolve", function() - it("returns defaults when no opts given", function() - local resolved = config.resolve() - assert.are.equal("wholeModule", resolved.analysis_mode) - assert.are.equal("ruff", resolved.formatter) - end) - - it("returns defaults when empty opts given", function() - local resolved = config.resolve({}) - assert.are.equal("wholeModule", resolved.analysis_mode) - end) - - it("merges user opts over defaults", function() - local resolved = config.resolve({ - analysis_mode = "openFilesOnly", - formatter = "none", - }) - assert.are.equal("openFilesOnly", resolved.analysis_mode) - assert.are.equal("none", resolved.formatter) - -- Other defaults preserved. - assert.is_true(resolved.debugger.enabled) - end) - - it("deep merges nested tables", function() - local resolved = config.resolve({ - inlay_hints = { parameter_names = false }, - }) - assert.is_false(resolved.inlay_hints.parameter_names) - assert.is_true(resolved.inlay_hints.variable_types) - end) - end) - - describe("validate", function() - it("returns no errors for valid config", function() - local resolved = config.resolve() - local errors = config.validate(resolved) - assert.are.equal(0, #errors) - end) - - it("catches invalid analysis_mode", function() - local resolved = config.resolve({ analysis_mode = "invalid" }) - local errors = config.validate(resolved) - assert.are.equal(1, #errors) - assert.truthy(errors[1]:find("analysis_mode")) - end) - - it("catches invalid test_explorer.framework", function() - local resolved = config.resolve({ test_explorer = { framework = "invalid" } }) - local errors = config.validate(resolved) - assert.are.equal(1, #errors) - assert.truthy(errors[1]:find("framework")) - end) - - it("catches invalid test_explorer.position", function() - local resolved = config.resolve({ test_explorer = { position = "top" } }) - local errors = config.validate(resolved) - assert.are.equal(1, #errors) - assert.truthy(errors[1]:find("position")) - end) - - it("catches invalid log_level", function() - local resolved = config.resolve({ log_level = "verbose" }) - local errors = config.validate(resolved) - assert.are.equal(1, #errors) - assert.truthy(errors[1]:find("log_level")) - end) - end) -end) diff --git a/basilisk.nvim/tests/basilisk/info_spec.lua b/basilisk.nvim/tests/basilisk/info_spec.lua deleted file mode 100644 index 7d5a6e8f3..000000000 --- a/basilisk.nvim/tests/basilisk/info_spec.lua +++ /dev/null @@ -1,176 +0,0 @@ ---- Tests for basilisk.info — info panel. - -describe("basilisk.info", function() - local info = require("basilisk.info") - local config_mod = require("basilisk.config") - - after_each(function() - info.close() - end) - - describe("show", function() - it("opens a floating window", function() - local config = config_mod.resolve() - info.show(config) - local found_float = false - for _, win in ipairs(vim.api.nvim_list_wins()) do - local win_config = vim.api.nvim_win_get_config(win) - if win_config.relative and win_config.relative ~= "" then - found_float = true - break - end - end - assert.is_true(found_float, "should open a floating window") - end) - - it("float contains 'Basilisk' text", function() - local config = config_mod.resolve() - info.show(config) - local found = false - for _, win in ipairs(vim.api.nvim_list_wins()) do - local win_config = vim.api.nvim_win_get_config(win) - if win_config.relative and win_config.relative ~= "" then - local buf = vim.api.nvim_win_get_buf(win) - local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) - for _, line in ipairs(lines) do - if line:find("Basilisk") then - found = true - break - end - end - break - end - end - assert.is_true(found, "should contain 'Basilisk'") - end) - - it("shows server status", function() - local config = config_mod.resolve() - info.show(config) - local found = false - for _, win in ipairs(vim.api.nvim_list_wins()) do - local win_config = vim.api.nvim_win_get_config(win) - if win_config.relative and win_config.relative ~= "" then - local buf = vim.api.nvim_win_get_buf(win) - local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) - for _, line in ipairs(lines) do - if line:find("Status") then - found = true - break - end - end - break - end - end - assert.is_true(found, "should show Status line") - end) - - it("shows analysis mode", function() - local config = config_mod.resolve() - info.show(config) - local found = false - for _, win in ipairs(vim.api.nvim_list_wins()) do - local win_config = vim.api.nvim_win_get_config(win) - if win_config.relative and win_config.relative ~= "" then - local buf = vim.api.nvim_win_get_buf(win) - local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) - for _, line in ipairs(lines) do - if line:find("Mode") then - found = true - break - end - end - break - end - end - assert.is_true(found, "should show Mode") - end) - - it("shows integration statuses", function() - local config = config_mod.resolve() - info.show(config) - local found_formatter = false - local found_uv = false - for _, win in ipairs(vim.api.nvim_list_wins()) do - local win_config = vim.api.nvim_win_get_config(win) - if win_config.relative and win_config.relative ~= "" then - local buf = vim.api.nvim_win_get_buf(win) - local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) - for _, line in ipairs(lines) do - if line:find("Formatter") then found_formatter = true end - if line:find("uv") then found_uv = true end - end - break - end - end - assert.is_true(found_formatter, "should show Formatter status") - assert.is_true(found_uv, "should show uv status") - end) - - it("closes existing float before opening new one", function() - local config = config_mod.resolve() - info.show(config) - local count_before = 0 - for _, win in ipairs(vim.api.nvim_list_wins()) do - local wc = vim.api.nvim_win_get_config(win) - if wc.relative and wc.relative ~= "" then count_before = count_before + 1 end - end - info.show(config) - local count_after = 0 - for _, win in ipairs(vim.api.nvim_list_wins()) do - local wc = vim.api.nvim_win_get_config(win) - if wc.relative and wc.relative ~= "" then count_after = count_after + 1 end - end - assert.are.equal(count_before, count_after, "should not accumulate floating windows") - end) - end) - - describe("close", function() - it("closes the floating window", function() - local config = config_mod.resolve() - info.show(config) - info.close() - local found_float = false - for _, win in ipairs(vim.api.nvim_list_wins()) do - local win_config = vim.api.nvim_win_get_config(win) - if win_config.relative and win_config.relative ~= "" then - found_float = true - break - end - end - assert.is_false(found_float, "should close floating window") - end) - - it("double close does not error", function() - local config = config_mod.resolve() - info.show(config) - info.close() - assert.has_no.errors(function() - info.close() - end) - end) - - it("close without show does not error", function() - assert.has_no.errors(function() - info.close() - end) - end) - end) - - describe("refresh", function() - it("does not error when panel is not open", function() - assert.has_no.errors(function() - local config = config_mod.resolve() - info.refresh(config) - end) - end) - - it("updates content when panel is open", function() - local config = config_mod.resolve() - info.show(config) - assert.has_no.errors(function() - info.refresh(config) - end) - end) - end) -end) diff --git a/basilisk.nvim/tests/basilisk/log_spec.lua b/basilisk.nvim/tests/basilisk/log_spec.lua deleted file mode 100644 index 361230374..000000000 --- a/basilisk.nvim/tests/basilisk/log_spec.lua +++ /dev/null @@ -1,242 +0,0 @@ ---- Tests for basilisk.log module. ---- ---- Covers: level filtering, all log functions, file logging lifecycle, ---- format string handling, and level boundary behavior. - -describe("basilisk.log", function() - local log = require("basilisk.log") - - -- Capture vim.notify calls. - local notifications = {} - local orig_notify - - before_each(function() - notifications = {} - orig_notify = vim.notify - vim.notify = function(msg, level) - notifications[#notifications + 1] = { msg = msg, level = level } - end - end) - - after_each(function() - vim.notify = orig_notify - log.close_file() - log.set_level("info") - end) - - describe("set_level", function() - it("accepts valid log levels", function() - for _, level in ipairs({ "trace", "debug", "info", "warn", "error" }) do - assert.has_no.errors(function() - log.set_level(level) - end) - end - end) - - it("ignores invalid log levels without error", function() - assert.has_no.errors(function() - log.set_level("invalid") - log.set_level("") - log.set_level("TRACE") - end) - end) - end) - - describe("level filtering", function() - it("info level suppresses debug messages", function() - log.set_level("info") - log.debug("should not appear") - assert.are.equal(0, #notifications, "debug should be suppressed at info level") - end) - - it("info level suppresses trace messages", function() - log.set_level("info") - log.trace("should not appear") - assert.are.equal(0, #notifications, "trace should be suppressed at info level") - end) - - it("info level allows info messages", function() - log.set_level("info") - log.info("visible") - assert.are.equal(1, #notifications) - assert.truthy(notifications[1].msg:find("visible")) - end) - - it("info level allows warn messages", function() - log.set_level("info") - log.warn("warning") - assert.are.equal(1, #notifications) - end) - - it("info level allows error messages", function() - log.set_level("info") - log.error("error") - assert.are.equal(1, #notifications) - end) - - it("error level suppresses info and warn", function() - log.set_level("error") - log.info("suppressed") - log.warn("suppressed") - assert.are.equal(0, #notifications) - end) - - it("error level allows error messages", function() - log.set_level("error") - log.error("visible") - assert.are.equal(1, #notifications) - end) - - it("trace level allows all messages", function() - log.set_level("trace") - log.trace("t") - log.debug("d") - log.info("i") - log.warn("w") - log.error("e") - assert.are.equal(5, #notifications) - end) - end) - - describe("format strings", function() - it("formats string arguments", function() - log.set_level("info") - log.info("hello %s", "world") - assert.truthy(notifications[1].msg:find("hello world")) - end) - - it("formats numeric arguments", function() - log.set_level("info") - log.info("count: %d", 42) - assert.truthy(notifications[1].msg:find("count: 42")) - end) - - it("formats multiple arguments", function() - log.set_level("info") - log.info("%s has %d items", "list", 3) - assert.truthy(notifications[1].msg:find("list has 3 items")) - end) - - it("all messages are prefixed with [basilisk]", function() - log.set_level("trace") - log.trace("test") - log.debug("test") - log.info("test") - log.warn("test") - log.error("test") - for _, notif in ipairs(notifications) do - assert.truthy(notif.msg:match("^%[basilisk%]"), "should be prefixed with [basilisk]") - end - end) - end) - - describe("notify levels", function() - it("trace sends TRACE level", function() - log.set_level("trace") - log.trace("msg") - assert.are.equal(vim.log.levels.TRACE, notifications[1].level) - end) - - it("debug sends DEBUG level", function() - log.set_level("debug") - log.debug("msg") - assert.are.equal(vim.log.levels.DEBUG, notifications[1].level) - end) - - it("info sends INFO level", function() - log.set_level("info") - log.info("msg") - assert.are.equal(vim.log.levels.INFO, notifications[1].level) - end) - - it("warn sends WARN level", function() - log.set_level("info") - log.warn("msg") - assert.are.equal(vim.log.levels.WARN, notifications[1].level) - end) - - it("error sends ERROR level", function() - log.set_level("info") - log.error("msg") - assert.are.equal(vim.log.levels.ERROR, notifications[1].level) - end) - end) - - describe("file logging", function() - it("writes messages to file", function() - local tmpfile = vim.fn.tempname() .. ".log" - log.enable_file(tmpfile) - log.set_level("info") - log.info("file log test message") - log.close_file() - - local fh = io.open(tmpfile, "r") - assert.is_not_nil(fh, "log file should exist") - local content = fh:read("*a") - fh:close() - assert.truthy(content:find("file log test message"), "file should contain the logged message") - os.remove(tmpfile) - end) - - it("includes timestamp in file log", function() - local tmpfile = vim.fn.tempname() .. ".log" - log.enable_file(tmpfile) - log.set_level("info") - log.info("timestamp test") - log.close_file() - - local fh = io.open(tmpfile, "r") - local content = fh:read("*a") - fh:close() - assert.truthy(content:match("%d%d%d%d%-%d%d%-%d%d"), "file log should include date") - os.remove(tmpfile) - end) - - it("close_file is idempotent", function() - assert.has_no.errors(function() - log.close_file() - log.close_file() - log.close_file() - end) - end) - - it("enable_file closes previous file", function() - local tmp1 = vim.fn.tempname() .. "_1.log" - local tmp2 = vim.fn.tempname() .. "_2.log" - log.enable_file(tmp1) - log.set_level("info") - log.info("msg1") - log.enable_file(tmp2) - log.info("msg2") - log.close_file() - - local fh1 = io.open(tmp1, "r") - local content1 = fh1:read("*a") - fh1:close() - assert.truthy(content1:find("msg1")) - - local fh2 = io.open(tmp2, "r") - local content2 = fh2:read("*a") - fh2:close() - assert.truthy(content2:find("msg2")) - assert.is_falsy(content2:find("msg1"), "msg1 should only be in first file") - - os.remove(tmp1) - os.remove(tmp2) - end) - - it("suppressed messages are not written to file", function() - local tmpfile = vim.fn.tempname() .. ".log" - log.enable_file(tmpfile) - log.set_level("error") - log.info("should not appear") - log.close_file() - - local fh = io.open(tmpfile, "r") - local content = fh:read("*a") - fh:close() - assert.are.equal("", content, "suppressed messages should not be in file") - os.remove(tmpfile) - end) - end) -end) diff --git a/basilisk.nvim/tests/basilisk/lsp_spec.lua b/basilisk.nvim/tests/basilisk/lsp_spec.lua deleted file mode 100644 index 4aa9765ea..000000000 --- a/basilisk.nvim/tests/basilisk/lsp_spec.lua +++ /dev/null @@ -1,141 +0,0 @@ ---- Tests for basilisk.lsp module. ---- ---- Tests [NVIM-LSP-CLIENT-CONFIGURATION] (start) and ---- [NVIM-LSP-CLIENT-CONFIGURATION-ERROR-RECOVERY] (restart backoff / counter). ---- ---- Covers: restart count, start (with/without binary), restart backoff, ---- settings passthrough. - -describe("basilisk.lsp", function() - local lsp = require("basilisk.lsp") - - after_each(function() - lsp.reset_restart_count() - -- Stop any clients we may have started. - for _, client in ipairs(vim.lsp.get_clients({ name = "basilisk" })) do - client:stop(true) - end - vim.wait(500, function() - return #vim.lsp.get_clients({ name = "basilisk" }) == 0 - end) - end) - - describe("restart_count", function() - it("starts at zero", function() - assert.are.equal(0, lsp.get_restart_count()) - end) - - it("resets to zero", function() - lsp.reset_restart_count() - assert.are.equal(0, lsp.get_restart_count()) - end) - - it("reset is idempotent", function() - lsp.reset_restart_count() - lsp.reset_restart_count() - assert.are.equal(0, lsp.get_restart_count()) - end) - end) - - describe("start", function() - it("returns false when binary is not found", function() - local config = require("basilisk.config").resolve({ binary_path = "/nonexistent/basilisk" }) - -- Suppress the error notification. - local orig_notify = vim.notify - vim.notify = function() end - local result = lsp.start(config) - vim.notify = orig_notify - assert.is_false(result, "should return false when binary not found") - end) - - it("notifies error when binary is not found", function() - local notifications = {} - local orig_notify = vim.notify - vim.notify = function(msg, level) - notifications[#notifications + 1] = { msg = msg, level = level } - end - - local config = require("basilisk.config").resolve({ binary_path = "/nonexistent/basilisk" }) - -- Clear BASILISK_PATH to avoid finding a real binary. - local orig_env = vim.env.BASILISK_PATH - vim.env.BASILISK_PATH = nil - lsp.start(config) - vim.env.BASILISK_PATH = orig_env - vim.notify = orig_notify - - local found_error = false - for _, notif in ipairs(notifications) do - if notif.level == vim.log.levels.ERROR and notif.msg:find("binary not found") then - found_error = true - break - end - end - assert.is_true(found_error, "should notify about missing binary") - end) - - it("returns true when a valid binary is provided", function() - -- Use 'cat' as a fake binary (it'll fail as LSP but start() only checks existence). - local cat_path = vim.fn.exepath("cat") - if cat_path == "" then - pending("cat not on PATH") - return - end - local config = require("basilisk.config").resolve({ binary_path = cat_path }) - local result = lsp.start(config) - assert.is_true(result, "should return true when binary exists") - end) - - it("resets restart count on successful start", function() - local cat_path = vim.fn.exepath("cat") - if cat_path == "" then return end - local config = require("basilisk.config").resolve({ binary_path = cat_path }) - lsp.start(config) - assert.are.equal(0, lsp.get_restart_count()) - end) - end) - - describe("restart", function() - it("respects max restart limit", function() - local config = require("basilisk.config").resolve({ binary_path = "/fake" }) - local orig_notify = vim.notify - local notifications = {} - vim.notify = function(msg, level) - notifications[#notifications + 1] = { msg = msg, level = level } - end - - -- Exhaust restarts by calling restart 4 times (max is 3). - for _ = 1, 4 do - lsp.restart(config) - end - - vim.notify = orig_notify - - local found_max_msg = false - for _, notif in ipairs(notifications) do - if notif.msg:find("max restarts") then - found_max_msg = true - break - end - end - assert.is_true(found_max_msg, "should warn about max restarts reached") - end) - - it("force flag bypasses restart limit", function() - local config = require("basilisk.config").resolve({ binary_path = "/fake" }) - local orig_notify = vim.notify - vim.notify = function() end - - -- Exhaust normal restarts. - for _ = 1, 4 do - lsp.restart(config) - end - - -- Force should work. - assert.has_no.errors(function() - lsp.restart(config, true) - end) - - vim.notify = orig_notify - end) - end) -end) diff --git a/basilisk.nvim/tests/basilisk/memory_spec.lua b/basilisk.nvim/tests/basilisk/memory_spec.lua deleted file mode 100644 index 99dde6ba9..000000000 --- a/basilisk.nvim/tests/basilisk/memory_spec.lua +++ /dev/null @@ -1,695 +0,0 @@ ---- E2E tests for basilisk.memory module. ---- ---- Full parity with vscode-extension/src/test/suite/profiler.test.ts memory suites: ---- 1. Memory Command Registration — user commands exist with descriptions/nargs ---- 2. Display Leak Report — floating window output, edge cases ---- 3. Display Retention Paths — confidence, steps, no-data ---- 4. Completion — type matching, case insensitivity ---- 5. Data Structures — MemoryAllocation, MemorySnapshotResult, MemoryDiff, SuspectedLeak ---- 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) - if config.relative and config.relative ~= "" then - vim.api.nvim_win_close(win, true) - end - end -end - -describe("basilisk.memory", function() - local memory = require("basilisk.memory") - - after_each(function() - close_all_floats() - end) - - -- ── display_leak_report ───────────────────────────────────────────── - - describe("display_leak_report", function() - it("handles nil result gracefully", function() - assert.has_no.errors(function() - memory.display_leak_report(nil) - end) - end) - - it("shows 'no data' message for nil result", function() - memory.display_leak_report(nil) - local found = false - for _, win in ipairs(vim.api.nvim_list_wins()) do - local config = vim.api.nvim_win_get_config(win) - if config.relative and config.relative ~= "" then - local buf = vim.api.nvim_win_get_buf(win) - local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) - local text = table.concat(lines, "\n") - assert.truthy(text:find("No leak data"), "should show no-data message") - found = true - end - end - assert.is_true(found, "should open a floating window") - end) - - it("handles empty leaks array", function() - memory.display_leak_report({ leaks = {} }) - local found = false - for _, win in ipairs(vim.api.nvim_list_wins()) do - local config = vim.api.nvim_win_get_config(win) - if config.relative and config.relative ~= "" then - local buf = vim.api.nvim_win_get_buf(win) - local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) - local text = table.concat(lines, "\n") - assert.truthy(text:find("No leaks detected"), "should say no leaks detected") - found = true - end - end - assert.is_true(found) - end) - - it("displays leaks with type name, count, and size", function() - local result = { - leaks = { - { typeName = "DataFrame", count = 15, totalSize = "1.2MB", location = { file = "/tmp/test.py", line = 42 } }, - { typeName = "dict", count = 100, totalSize = "500KB" }, - }, - } - - memory.display_leak_report(result) - - local found = false - for _, win in ipairs(vim.api.nvim_list_wins()) do - local config = vim.api.nvim_win_get_config(win) - if config.relative and config.relative ~= "" then - local buf = vim.api.nvim_win_get_buf(win) - local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) - local text = table.concat(lines, "\n") - - assert.truthy(text:find("DataFrame"), "must show DataFrame type") - assert.truthy(text:find("dict"), "must show dict type") - assert.truthy(text:find("15 objects"), "must show object count") - assert.truthy(text:find("1.2MB"), "must show size") - assert.truthy(text:find("500KB"), "must show second size") - found = true - end - end - assert.is_true(found, "should open floating window with leak report") - end) - - it("displays location for leaks that have file info", function() - local result = { - leaks = { - { typeName = "list", count = 5, totalSize = "2MB", location = { file = "/app/cache.py", line = 34 } }, - }, - } - - memory.display_leak_report(result) - - local found = false - for _, win in ipairs(vim.api.nvim_list_wins()) do - local config = vim.api.nvim_win_get_config(win) - if config.relative and config.relative ~= "" then - local buf = vim.api.nvim_win_get_buf(win) - local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) - local text = table.concat(lines, "\n") - assert.truthy(text:find("/app/cache.py"), "must show file path") - assert.truthy(text:find("34"), "must show line number") - found = true - end - end - assert.is_true(found) - end) - - it("handles leaks with nil fields", function() - local result = { - leaks = { - { typeName = nil, count = nil, totalSize = nil }, - }, - } - assert.has_no.errors(function() - memory.display_leak_report(result) - end) - end) - end) - - -- ── display_retention_paths ───────────────────────────────────────── - - describe("display_retention_paths", function() - it("handles nil result gracefully", function() - assert.has_no.errors(function() - memory.display_retention_paths("dict", nil) - end) - end) - - it("shows 'no data' message for nil result", function() - memory.display_retention_paths("dict", nil) - local found = false - for _, win in ipairs(vim.api.nvim_list_wins()) do - local config = vim.api.nvim_win_get_config(win) - if config.relative and config.relative ~= "" then - local buf = vim.api.nvim_win_get_buf(win) - local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) - local text = table.concat(lines, "\n") - assert.truthy(text:find("No retention data"), "should show no-data message") - found = true - end - end - assert.is_true(found) - end) - - it("includes target type name in window title content", function() - memory.display_retention_paths("DataFrame", { retentionPaths = {} }) - local found = false - for _, win in ipairs(vim.api.nvim_list_wins()) do - local config = vim.api.nvim_win_get_config(win) - if config.relative and config.relative ~= "" then - local buf = vim.api.nvim_win_get_buf(win) - local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) - local text = table.concat(lines, "\n") - assert.truthy(text:find("DataFrame"), "should include type name in content") - found = true - end - end - assert.is_true(found) - end) - - it("displays retention paths with confidence and steps", function() - local result = { - retentionPaths = { - { - confidence = 0.85, - steps = { - { name = "global_cache", kind = "variable" }, - { name = "__dict__", kind = "attribute" }, - { name = "items", kind = "method" }, - }, - }, - { - confidence = 0.60, - steps = { - { name = "module_level_list", kind = "variable" }, - }, - }, - }, - } - - memory.display_retention_paths("DataFrame", result) - - local found = false - for _, win in ipairs(vim.api.nvim_list_wins()) do - local config = vim.api.nvim_win_get_config(win) - if config.relative and config.relative ~= "" then - local buf = vim.api.nvim_win_get_buf(win) - local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) - local text = table.concat(lines, "\n") - - assert.truthy(text:find("DataFrame"), "must show type name") - assert.truthy(text:find("global_cache"), "must show first step") - assert.truthy(text:find("__dict__"), "must show second step") - assert.truthy(text:find("85%%"), "must show 85%% confidence") - assert.truthy(text:find("60%%"), "must show 60%% confidence") - assert.truthy(text:find("module_level_list"), "must show second path step") - found = true - end - end - assert.is_true(found) - end) - - it("shows 'no paths found' for empty retentionPaths", function() - memory.display_retention_paths("dict", { retentionPaths = {} }) - local found = false - for _, win in ipairs(vim.api.nvim_list_wins()) do - local config = vim.api.nvim_win_get_config(win) - if config.relative and config.relative ~= "" then - local buf = vim.api.nvim_win_get_buf(win) - local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) - local text = table.concat(lines, "\n") - assert.truthy(text:find("No retention paths"), "should indicate no paths") - found = true - end - end - assert.is_true(found) - end) - end) - - -- ── complete_refs ─────────────────────────────────────────────────── - - describe("complete_refs", function() - it("returns DataFrame for 'Data' input", function() - local matches = memory.complete_refs("Data") - assert.are.equal("DataFrame", matches[1]) - end) - - it("returns all types for empty input", function() - local matches = memory.complete_refs("") - assert.is_true(#matches >= 10, "should return many type suggestions, got " .. #matches) - end) - - it("returns dict for 'dic' input", function() - local matches = memory.complete_refs("dic") - local found = false - for _, m in ipairs(matches) do - if m == "dict" then - found = true - end - end - assert.is_true(found, "should find 'dict' for 'dic' prefix") - end) - - it("is case-insensitive", function() - local matches = memory.complete_refs("tensor") - local found = false - for _, m in ipairs(matches) do - if m == "Tensor" then - found = true - end - end - assert.is_true(found, "should find 'Tensor' for lowercase 'tensor'") - end) - - it("returns empty table for non-matching input", function() - local matches = memory.complete_refs("zzzzzznotaType") - assert.are.equal(0, #matches, "should return empty for non-matching input") - end) - - it("includes common Python types", function() - local matches = memory.complete_refs("") - local types_set = {} - for _, m in ipairs(matches) do - types_set[m] = true - end - - assert.is_true(types_set["dict"] or false, "should include dict") - assert.is_true(types_set["list"] or false, "should include list") - assert.is_true(types_set["set"] or false, "should include set") - assert.is_true(types_set["str"] or false, "should include str") - assert.is_true(types_set["DataFrame"] or false, "should include DataFrame") - assert.is_true(types_set["Tensor"] or false, "should include Tensor") - assert.is_true(types_set["ndarray"] or false, "should include ndarray") - end) - - it("returns single-character prefix matches", function() - local matches = memory.complete_refs("d") - assert.is_true(#matches >= 1, "should match at least 'dict' for 'd'") - end) - end) - - -- ── State management ──────────────────────────────────────────────── - - describe("state management", function() - it("start without client does not error", function() - assert.has_no.errors(function() - memory.start() - end) - end) - - it("stop without client does not error", function() - assert.has_no.errors(function() - memory.stop() - end) - end) - - it("refs without client does not error", function() - assert.has_no.errors(function() - memory.refs("dict") - end) - end) - end) -end) - --- ── Suite: Memory Command Registration (VSIX parity) ────────────────────── - -describe("memory — command registration", function() - -- Register commands (idempotent). - local config = require("basilisk.config").defaults - require("basilisk.commands").register(config) - - local MEMORY_COMMANDS = { - "BasiliskMemLeak", - "BasiliskMemStop", - "BasiliskMemRefs", - } - - it("all memory user commands are registered", function() - local all_cmds = vim.api.nvim_get_commands({}) - for _, cmd in ipairs(MEMORY_COMMANDS) do - assert.truthy(all_cmds[cmd], "user command '" .. cmd .. "' should be registered") - end - end) - - it("memory commands have descriptions", function() - local all_cmds = vim.api.nvim_get_commands({}) - for _, cmd in ipairs(MEMORY_COMMANDS) do - local entry = all_cmds[cmd] - assert.truthy(entry, "command '" .. cmd .. "' should exist") - assert.truthy( - command_desc.of(entry), - "command '" .. cmd .. "' should have a description" - ) - end - end) - - it("BasiliskMemLeak takes no arguments", function() - local all_cmds = vim.api.nvim_get_commands({}) - local entry = all_cmds["BasiliskMemLeak"] - assert.truthy(entry) - assert.are.equal("0", entry.nargs) - end) - - it("BasiliskMemStop takes no arguments", function() - local all_cmds = vim.api.nvim_get_commands({}) - local entry = all_cmds["BasiliskMemStop"] - assert.truthy(entry) - assert.are.equal("0", entry.nargs) - end) - - it("BasiliskMemRefs takes exactly 1 argument", function() - local all_cmds = vim.api.nvim_get_commands({}) - local entry = all_cmds["BasiliskMemRefs"] - assert.truthy(entry) - assert.are.equal("1", entry.nargs) - end) - - it("memory commands are distinct from profiler commands", function() - local profiler_cmds = { "BasiliskProfile", "BasiliskProfileStop", "BasiliskProfileSnapshot" } - local memory_set = {} - for _, cmd in ipairs(MEMORY_COMMANDS) do - memory_set[cmd] = true - end - for _, cmd in ipairs(profiler_cmds) do - assert.falsy(memory_set[cmd], "profiler command '" .. cmd .. "' must not be in memory set") - end - end) - - it("BasiliskMemLeak description mentions memory", function() - local all_cmds = vim.api.nvim_get_commands({}) - local entry = all_cmds["BasiliskMemLeak"] - assert.truthy(entry) - 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" - ) - end) - - it("BasiliskMemRefs description mentions references", function() - local all_cmds = vim.api.nvim_get_commands({}) - local entry = all_cmds["BasiliskMemRefs"] - assert.truthy(entry) - 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" - ) - end) -end) - --- ── Suite: Memory Data Structures (VSIX parity) ─────────────────────────── - -describe("memory — data structures", function() - it("MemoryAllocation-like table validates required fields", function() - local alloc = { - file = "/src/data.py", - line = 100, - size = 10485760, - count = 5000, - } - - assert.are.equal("/src/data.py", alloc.file) - assert.are.equal(100, alloc.line) - assert.are.equal(10485760, alloc.size) - assert.are.equal(5000, alloc.count) - end) - - it("MemorySnapshotResult-like table validates required fields", function() - local snapshot = { - memorySessionId = "mem-session-001", - snapshotId = "snap-001", - currentMemory = 50000000, - peakMemory = 75000000, - topAllocations = {}, - } - - assert.are.equal("mem-session-001", snapshot.memorySessionId) - assert.are.equal("snap-001", snapshot.snapshotId) - assert.are.equal(50000000, snapshot.currentMemory) - assert.are.equal(75000000, snapshot.peakMemory) - assert.is_table(snapshot.topAllocations) - end) - - it("MemoryDiffResult-like table validates required fields", function() - local diff = { - beforeSnapshot = "snap-001", - afterSnapshot = "snap-002", - growthEntries = { - { file = "/src/cache.py", line = 10, sizeDiff = 1048576, countDiff = 100 }, - }, - totalGrowth = 1048576, - } - - assert.are.equal("snap-001", diff.beforeSnapshot) - assert.are.equal("snap-002", diff.afterSnapshot) - assert.is_table(diff.growthEntries) - assert.are.equal(1, #diff.growthEntries) - assert.are.equal(1048576, diff.totalGrowth) - end) - - it("SuspectedLeak-like table validates all fields", function() - local leak = { - typeName = "DataFrame", - count = 150, - totalSize = "12.5MB", - confidence = "High", - location = { file = "/src/data.py", line = 42 }, - } - - assert.are.equal("DataFrame", leak.typeName) - assert.are.equal(150, leak.count) - assert.are.equal("12.5MB", leak.totalSize) - assert.are.equal("High", leak.confidence) - assert.is_table(leak.location) - assert.are.equal("/src/data.py", leak.location.file) - assert.are.equal(42, leak.location.line) - end) - - it("LeakConfidence values are ordered correctly", function() - local confidences = { "Low", "Medium", "High", "Definite" } - local order = {} - for i, c in ipairs(confidences) do - order[c] = i - end - assert.is_true(order["Low"] < order["Medium"]) - assert.is_true(order["Medium"] < order["High"]) - assert.is_true(order["High"] < order["Definite"]) - end) - - it("MemorySnapshotResult with populated allocations validates structure", function() - local snapshot = { - memorySessionId = "mem-002", - snapshotId = "snap-002", - currentMemory = 100000000, - peakMemory = 150000000, - topAllocations = { - { file = "/src/model.py", line = 45, size = 5242880, count = 1000 }, - { file = "/src/data.py", line = 12, size = 2097152, count = 500 }, - { file = "/src/cache.py", line = 78, size = 1048576, count = 200 }, - }, - } - - assert.are.equal(3, #snapshot.topAllocations) - assert.is_true( - snapshot.topAllocations[1].size > snapshot.topAllocations[2].size, - "allocations should be ordered by size (largest first)" - ) - assert.is_true(snapshot.peakMemory >= snapshot.currentMemory, - "peak memory should be >= current memory") - end) -end) - --- ── Suite: Realistic Memory Scenarios (VSIX parity) ─────────────────────── - -describe("memory — realistic scenarios", function() - local memory = require("basilisk.memory") - - after_each(function() - close_all_floats() - end) - - it("handles a Django ORM leak pattern", function() - local result = { - leaks = { - { typeName = "QuerySet", count = 500, totalSize = "45MB", location = { file = "/app/views.py", line = 23 } }, - { typeName = "Model", count = 2000, totalSize = "120MB", location = { file = "/app/models.py", line = 15 } }, - { typeName = "dict", count = 10000, totalSize = "8MB" }, - }, - } - - memory.display_leak_report(result) - - local found = false - for _, win in ipairs(vim.api.nvim_list_wins()) do - local config = vim.api.nvim_win_get_config(win) - if config.relative and config.relative ~= "" then - local buf = vim.api.nvim_win_get_buf(win) - local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) - local text = table.concat(lines, "\n") - - assert.truthy(text:find("QuerySet"), "should show QuerySet leak") - assert.truthy(text:find("Model"), "should show Model leak") - assert.truthy(text:find("dict"), "should show dict leak") - assert.truthy(text:find("45MB"), "should show QuerySet size") - assert.truthy(text:find("120MB"), "should show Model size") - assert.truthy(text:find("500 objects"), "should show QuerySet count") - found = true - end - end - assert.is_true(found) - end) - - it("handles a data science memory pattern", function() - local result = { - leaks = { - { typeName = "DataFrame", count = 50, totalSize = "2.1GB", location = { file = "/ml/train.py", line = 88 } }, - { typeName = "ndarray", count = 200, totalSize = "800MB", location = { file = "/ml/preprocess.py", line = 42 } }, - { typeName = "Tensor", count = 100, totalSize = "1.5GB", location = { file = "/ml/model.py", line = 156 } }, - }, - } - - memory.display_leak_report(result) - - local found = false - for _, win in ipairs(vim.api.nvim_list_wins()) do - local config = vim.api.nvim_win_get_config(win) - if config.relative and config.relative ~= "" then - local buf = vim.api.nvim_win_get_buf(win) - local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) - local text = table.concat(lines, "\n") - - assert.truthy(text:find("DataFrame"), "should show DataFrame") - assert.truthy(text:find("ndarray"), "should show ndarray") - assert.truthy(text:find("Tensor"), "should show Tensor") - assert.truthy(text:find("2.1GB"), "should show large size") - found = true - end - end - assert.is_true(found) - end) - - it("handles retention paths with deep reference chains", function() - local result = { - retentionPaths = { - { - confidence = 0.95, - steps = { - { name = "app", kind = "module" }, - { name = "cache", kind = "attribute" }, - { name = "_store", kind = "attribute" }, - { name = "[0]", kind = "index" }, - { name = "__dict__", kind = "attribute" }, - { name = "data", kind = "attribute" }, - }, - }, - }, - } - - memory.display_retention_paths("CachedFrame", result) - - local found = false - for _, win in ipairs(vim.api.nvim_list_wins()) do - local config = vim.api.nvim_win_get_config(win) - if config.relative and config.relative ~= "" then - local buf = vim.api.nvim_win_get_buf(win) - local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) - local text = table.concat(lines, "\n") - - assert.truthy(text:find("CachedFrame"), "should show type name") - assert.truthy(text:find("app"), "should show root step") - assert.truthy(text:find("cache"), "should show cache step") - assert.truthy(text:find("_store"), "should show _store step") - assert.truthy(text:find("95%%"), "should show 95%% confidence") - found = true - end - end - assert.is_true(found) - end) - - it("handles multiple retention paths for same type", function() - local result = { - retentionPaths = { - { - confidence = 0.90, - steps = { - { name = "global_registry", kind = "variable" }, - { name = "items", kind = "method" }, - }, - }, - { - confidence = 0.70, - steps = { - { name = "thread_local", kind = "variable" }, - { name = "buffer", kind = "attribute" }, - }, - }, - { - confidence = 0.40, - steps = { - { name = "__main__", kind = "module" }, - { name = "temp_list", kind = "variable" }, - }, - }, - }, - } - - memory.display_retention_paths("bytes", result) - - local found = false - for _, win in ipairs(vim.api.nvim_list_wins()) do - local config = vim.api.nvim_win_get_config(win) - if config.relative and config.relative ~= "" then - local buf = vim.api.nvim_win_get_buf(win) - local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) - local text = table.concat(lines, "\n") - - assert.truthy(text:find("global_registry"), "should show first path") - assert.truthy(text:find("thread_local"), "should show second path") - assert.truthy(text:find("__main__"), "should show third path") - assert.truthy(text:find("90%%"), "should show 90%% confidence") - assert.truthy(text:find("70%%"), "should show 70%% confidence") - assert.truthy(text:find("40%%"), "should show 40%% confidence") - found = true - end - end - assert.is_true(found) - end) -end) - --- ── Suite: Memory Module API (VSIX parity: Decoration Modules) ────────────── - -describe("memory — module API", function() - local memory = require("basilisk.memory") - - it("exports start function", function() - assert.is_function(memory.start) - end) - - it("exports stop function", function() - assert.is_function(memory.stop) - end) - - it("exports refs function", function() - assert.is_function(memory.refs) - end) - - it("exports display_leak_report function", function() - assert.is_function(memory.display_leak_report) - end) - - it("exports display_retention_paths function", function() - assert.is_function(memory.display_retention_paths) - end) - - it("exports complete_refs function", function() - assert.is_function(memory.complete_refs) - end) -end) diff --git a/basilisk.nvim/tests/basilisk/modules_spec.lua b/basilisk.nvim/tests/basilisk/modules_spec.lua deleted file mode 100644 index 0a629e31b..000000000 --- a/basilisk.nvim/tests/basilisk/modules_spec.lua +++ /dev/null @@ -1,144 +0,0 @@ ---- Tests for basilisk.modules — module explorer panel. - -describe("basilisk.modules", function() - local modules = require("basilisk.modules") - - after_each(function() - modules.close() - end) - - describe("open", function() - it("creates a split window", function() - local before = #vim.api.nvim_tabpage_list_wins(0) - modules.open() - local after = #vim.api.nvim_tabpage_list_wins(0) - assert.is_true(after > before, "should create a new window") - end) - - it("creates a buffer with basilisk-modules filetype", function() - modules.open() - local found = false - for _, buf in ipairs(vim.api.nvim_list_bufs()) do - if vim.api.nvim_buf_is_loaded(buf) and vim.bo[buf].filetype == "basilisk-modules" then - found = true - break - end - end - assert.is_true(found, "should create buffer with basilisk-modules filetype") - end) - - it("buffer is not modifiable", function() - modules.open() - for _, buf in ipairs(vim.api.nvim_list_bufs()) do - if vim.api.nvim_buf_is_loaded(buf) and vim.bo[buf].filetype == "basilisk-modules" then - assert.is_false(vim.bo[buf].modifiable) - break - end - end - end) - - it("disables line numbers in the panel window", function() - modules.open() - local win = vim.api.nvim_get_current_win() - assert.is_false(vim.wo[win].number) - assert.is_false(vim.wo[win].relativenumber) - end) - - it("sets winfixwidth on the panel window", function() - modules.open() - -- The panel window should have winfixwidth set, but it may not - -- be the current window after after_each cleanup. Just verify - -- the open/close cycle works. - local found = false - for _, win in ipairs(vim.api.nvim_tabpage_list_wins(0)) do - if vim.wo[win].winfixwidth then - found = true - break - end - end - assert.is_true(found, "at least one window should have winfixwidth") - end) - - it("re-open focuses existing window instead of creating new one", function() - modules.open() - local count_after_first = #vim.api.nvim_tabpage_list_wins(0) - -- Switch away. - vim.cmd("wincmd p") - modules.open() - assert.are.equal(count_after_first, #vim.api.nvim_tabpage_list_wins(0)) - end) - - it("shows placeholder when no LSP client is available", function() - modules.open() - vim.wait(200) - local found_placeholder = false - for _, buf in ipairs(vim.api.nvim_list_bufs()) do - if vim.api.nvim_buf_is_loaded(buf) and vim.bo[buf].filetype == "basilisk-modules" then - local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) - for _, line in ipairs(lines) do - if line:find("no modules") then - found_placeholder = true - break - end - end - break - end - end - assert.is_true(found_placeholder, "should show 'no modules' placeholder without LSP") - end) - end) - - describe("close", function() - it("removes the panel window", function() - modules.open() - local before = #vim.api.nvim_tabpage_list_wins(0) - modules.close() - local after = #vim.api.nvim_tabpage_list_wins(0) - assert.is_true(after < before, "should remove the window") - end) - - it("double close does not error", function() - modules.open() - modules.close() - assert.has_no.errors(function() - modules.close() - end) - end) - - it("close without open does not error", function() - assert.has_no.errors(function() - modules.close() - end) - end) - end) - - describe("toggle", function() - it("opens when closed", function() - local before = #vim.api.nvim_tabpage_list_wins(0) - modules.toggle() - assert.is_true(#vim.api.nvim_tabpage_list_wins(0) > before) - end) - - it("closes when open", function() - local before = #vim.api.nvim_tabpage_list_wins(0) - modules.toggle() - modules.toggle() - assert.are.equal(before, #vim.api.nvim_tabpage_list_wins(0)) - end) - end) - - describe("refresh", function() - it("does not error when panel is not open", function() - assert.has_no.errors(function() - modules.refresh() - end) - end) - - it("does not error when panel is open", function() - modules.open() - assert.has_no.errors(function() - modules.refresh() - end) - end) - end) -end) diff --git a/basilisk.nvim/tests/basilisk/profiling_spec.lua b/basilisk.nvim/tests/basilisk/profiling_spec.lua deleted file mode 100644 index 897e1d6eb..000000000 --- a/basilisk.nvim/tests/basilisk/profiling_spec.lua +++ /dev/null @@ -1,1794 +0,0 @@ ---- E2E tests for basilisk.profiling module. ---- ---- Full parity with vscode-extension/src/test/suite/profiler.test.ts (9 suites): ---- 1. Command Registration — user commands exist with correct nargs/descriptions ---- 2. Configuration — profiling-related config defaults and validation ---- 3. Status Bar — statusline reflects server state and diagnostic counts ---- 4. Keybindings — keymap defaults ---- 5. Heat Level Classification — 4-level palette boundaries (critical/hot/warm/cool) ---- 6. Data Structures — ProfileResult, ProfileHotLine, ProfileHotFunction shapes ---- 7. Display Results — floating window output, numbered ranking, file/line info ---- 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) - if config.relative and config.relative ~= "" then - vim.api.nvim_win_close(win, true) - end - end -end - -describe("basilisk.profiling", function() - local profiling = require("basilisk.profiling") - - after_each(function() - close_all_floats() - vim.fn.setqflist({}, "r") - end) - - -- ── display_results ───────────────────────────────────────────────── - - describe("display_results", function() - it("handles nil result gracefully without errors", function() - assert.has_no.errors(function() - profiling.display_results(nil) - end) - end) - - it("shows 'no data' message for nil result", function() - profiling.display_results(nil) - local found = false - for _, win in ipairs(vim.api.nvim_list_wins()) do - local config = vim.api.nvim_win_get_config(win) - if config.relative and config.relative ~= "" then - local buf = vim.api.nvim_win_get_buf(win) - local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) - local text = table.concat(lines, "\n") - assert.truthy(text:find("No profiling data"), "should show no-data message") - found = true - end - end - assert.is_true(found, "should open a floating window") - end) - - it("handles empty hotFunctions array", function() - profiling.display_results({ hotFunctions = {} }) - local found = false - for _, win in ipairs(vim.api.nvim_list_wins()) do - local config = vim.api.nvim_win_get_config(win) - if config.relative and config.relative ~= "" then - local buf = vim.api.nvim_win_get_buf(win) - local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) - local text = table.concat(lines, "\n") - assert.truthy(text:find("no hot functions"), "should indicate no hot functions") - found = true - end - end - assert.is_true(found) - end) - - it("handles result with missing hotFunctions key", function() - assert.has_no.errors(function() - profiling.display_results({}) - end) - end) - - it("displays hot functions with correct formatting", function() - local result = { - hotFunctions = { - { name = "process_data", file = "/tmp/test.py", line = 10, percentage = 45.2 }, - { name = "parse_json", file = "/tmp/test.py", line = 25, percentage = 30.1 }, - { name = "render_html", file = "/tmp/views.py", line = 50, percentage = 12.5 }, - }, - } - - profiling.display_results(result) - - local found = false - for _, win in ipairs(vim.api.nvim_list_wins()) do - local config = vim.api.nvim_win_get_config(win) - if config.relative and config.relative ~= "" then - local buf = vim.api.nvim_win_get_buf(win) - local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) - local text = table.concat(lines, "\n") - - -- All function names must appear. - assert.truthy(text:find("process_data"), "must show process_data") - assert.truthy(text:find("parse_json"), "must show parse_json") - assert.truthy(text:find("render_html"), "must show render_html") - - -- Percentages must appear. - assert.truthy(text:find("45.2"), "must show 45.2%% for process_data") - assert.truthy(text:find("30.1"), "must show 30.1%% for parse_json") - assert.truthy(text:find("12.5"), "must show 12.5%% for render_html") - - -- File paths must appear. - assert.truthy(text:find("/tmp/test.py"), "must show file path") - assert.truthy(text:find("/tmp/views.py"), "must show second file path") - - found = true - end - end - assert.is_true(found, "should open a floating window with all functions listed") - end) - - it("displays functions in numbered order", function() - local result = { - hotFunctions = { - { name = "func_a", file = "/tmp/a.py", line = 1, percentage = 80.0 }, - { name = "func_b", file = "/tmp/a.py", line = 2, percentage = 20.0 }, - }, - } - - profiling.display_results(result) - - for _, win in ipairs(vim.api.nvim_list_wins()) do - local config = vim.api.nvim_win_get_config(win) - if config.relative and config.relative ~= "" then - local buf = vim.api.nvim_win_get_buf(win) - local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) - local text = table.concat(lines, "\n") - - -- First function should appear before second. - local pos_a = text:find("func_a") - local pos_b = text:find("func_b") - assert.truthy(pos_a, "func_a must appear") - assert.truthy(pos_b, "func_b must appear") - assert.is_true(pos_a < pos_b, "func_a should appear before func_b (numbered order)") - end - end - end) - - it("handles functions with nil/missing fields", function() - local result = { - hotFunctions = { - { name = nil, file = nil, line = nil, percentage = nil }, - { percentage = 5.0 }, - }, - } - assert.has_no.errors(function() - profiling.display_results(result) - end) - end) - - it("populates quickfix list with hot functions", function() - local result = { - hotFunctions = { - { name = "func_a", file = "/tmp/a.py", line = 5, percentage = 60.0 }, - { name = "func_b", file = "/tmp/b.py", line = 10, percentage = 30.0 }, - }, - } - - profiling.display_results(result) - local qf = vim.fn.getqflist() - - assert.is_true(#qf >= 2, "quickfix should have at least 2 items, got " .. #qf) - assert.truthy(qf[1].text:find("func_a"), "first qf item should be func_a") - assert.truthy(qf[2].text:find("func_b"), "second qf item should be func_b") - assert.are.equal(5, qf[1].lnum, "first qf item line should be 5") - assert.are.equal(10, qf[2].lnum, "second qf item line should be 10") - end) - - it("quickfix items contain percentage in text", function() - local result = { - hotFunctions = { - { name = "hot", file = "/tmp/x.py", line = 1, percentage = 42.5 }, - }, - } - - profiling.display_results(result) - local qf = vim.fn.getqflist() - - assert.is_true(#qf >= 1) - assert.truthy(qf[1].text:find("42.5"), "qf text should include percentage") - assert.truthy(qf[1].text:find("hot"), "qf text should include function name") - end) - - it("does not populate quickfix when no hot functions", function() - vim.fn.setqflist({}, "r") - profiling.display_results({ hotFunctions = {} }) - local qf = vim.fn.getqflist() - assert.are.equal(0, #qf, "quickfix should be empty when no hot functions") - end) - - it("replaces previous quickfix on new results", function() - profiling.display_results({ - hotFunctions = { - { name = "old_func", file = "/tmp/old.py", line = 1, percentage = 50 }, - }, - }) - assert.is_true(#vim.fn.getqflist() >= 1) - close_all_floats() - - profiling.display_results({ - hotFunctions = { - { name = "new_func", file = "/tmp/new.py", line = 2, percentage = 70 }, - }, - }) - local qf = vim.fn.getqflist() - assert.is_true(#qf >= 1) - assert.truthy(qf[1].text:find("new_func"), "quickfix should have new results, not old") - end) - end) - - -- ── apply_heat_map ────────────────────────────────────────────────── - - describe("apply_heat_map", function() - it("handles empty table without errors", function() - assert.has_no.errors(function() - profiling.apply_heat_map({}) - end) - end) - - it("handles nil input without errors", function() - assert.has_no.errors(function() - profiling.apply_heat_map(nil) - end) - end) - - it("applies extmarks to loaded buffers matching file paths", function() - -- Write a real temp file and open it in a window so it's fully loaded. - local tmpfile = vim.fn.tempname() .. ".py" - local fh = io.open(tmpfile, "w") - if fh then - fh:write("def hot_function():\n total = 0\n return total\n") - fh:close() - end - vim.cmd("edit " .. tmpfile) - local buf = vim.api.nvim_get_current_buf() - -- Use canonical name (macOS resolves /var → /private/var). - local canonical = vim.api.nvim_buf_get_name(buf) - - profiling.apply_heat_map({ - { name = "hot_function", file = canonical, line = 1, percentage = 55.0 }, - }) - - -- Check extmarks were applied. - local ns = vim.api.nvim_create_namespace("basilisk-profiling") - local marks = vim.api.nvim_buf_get_extmarks(buf, ns, 0, -1, { details = true }) - assert.is_true(#marks > 0, "should apply at least one extmark for hot function") - - -- Verify the extmark has virtual text with percentage. - local details = marks[1][4] - assert.truthy(details.virt_text, "extmark should have virtual text") - local virt_str = details.virt_text[1][1] - assert.truthy(virt_str:find("55.0"), "virtual text should contain percentage") - - -- Cleanup. - vim.cmd("bdelete!") - os.remove(tmpfile) - end) - - it("uses DiagnosticError highlight for >50% functions", function() - local tmpfile = vim.fn.tempname() .. ".py" - local fh = io.open(tmpfile, "w") - if fh then fh:write("def f(): pass\n") fh:close() end - vim.cmd("edit " .. tmpfile) - local buf = vim.api.nvim_get_current_buf() - local canonical = vim.api.nvim_buf_get_name(buf) - - profiling.apply_heat_map({ { file = canonical, line = 1, percentage = 75.0 } }) - - local ns = vim.api.nvim_create_namespace("basilisk-profiling") - local marks = vim.api.nvim_buf_get_extmarks(buf, ns, 0, -1, { details = true }) - assert.is_true(#marks > 0, "should have extmarks") - local hl = marks[1][4].virt_text[1][2] - assert.are.equal("DiagnosticError", hl, ">50%% should use DiagnosticError") - - vim.cmd("bdelete!") os.remove(tmpfile) - end) - - it("uses DiagnosticWarn highlight for 20-50% functions", function() - local tmpfile = vim.fn.tempname() .. ".py" - local fh = io.open(tmpfile, "w") - if fh then fh:write("def f(): pass\n") fh:close() end - vim.cmd("edit " .. tmpfile) - local buf = vim.api.nvim_get_current_buf() - local canonical = vim.api.nvim_buf_get_name(buf) - - profiling.apply_heat_map({ { file = canonical, line = 1, percentage = 35.0 } }) - - local ns = vim.api.nvim_create_namespace("basilisk-profiling") - local marks = vim.api.nvim_buf_get_extmarks(buf, ns, 0, -1, { details = true }) - assert.is_true(#marks > 0, "should have extmarks") - local hl = marks[1][4].virt_text[1][2] - assert.are.equal("DiagnosticWarn", hl, "20-50%% should use DiagnosticWarn") - - vim.cmd("bdelete!") os.remove(tmpfile) - end) - - it("uses DiagnosticHint highlight for <20% functions", function() - local tmpfile = vim.fn.tempname() .. ".py" - local fh = io.open(tmpfile, "w") - if fh then fh:write("def f(): pass\n") fh:close() end - vim.cmd("edit " .. tmpfile) - local buf = vim.api.nvim_get_current_buf() - local canonical = vim.api.nvim_buf_get_name(buf) - - profiling.apply_heat_map({ { file = canonical, line = 1, percentage = 10.0 } }) - - local ns = vim.api.nvim_create_namespace("basilisk-profiling") - local marks = vim.api.nvim_buf_get_extmarks(buf, ns, 0, -1, { details = true }) - assert.is_true(#marks > 0, "should have extmarks") - local hl = marks[1][4].virt_text[1][2] - assert.are.equal("DiagnosticHint", hl, "<20%% should use DiagnosticHint") - - vim.cmd("bdelete!") os.remove(tmpfile) - end) - - it("clears previous heat map before applying new one", function() - local tmpfile = vim.fn.tempname() .. ".py" - local fh = io.open(tmpfile, "w") - if fh then fh:write("line 1\nline 2\nline 3\n") fh:close() end - vim.cmd("edit " .. tmpfile) - local buf = vim.api.nvim_get_current_buf() - local canonical = vim.api.nvim_buf_get_name(buf) - - profiling.apply_heat_map({ - { file = canonical, line = 1, percentage = 80.0 }, - { file = canonical, line = 2, percentage = 60.0 }, - }) - profiling.apply_heat_map({ - { file = canonical, line = 3, percentage = 90.0 }, - }) - - local ns = vim.api.nvim_create_namespace("basilisk-profiling") - local marks = vim.api.nvim_buf_get_extmarks(buf, ns, 0, -1, { details = true }) - assert.are.equal(1, #marks, "should clear old marks and only show new ones") - - vim.cmd("bdelete!") os.remove(tmpfile) - end) - - it("skips functions with missing file path", function() - assert.has_no.errors(function() - profiling.apply_heat_map({ - { name = "orphan", line = 1, percentage = 50.0 }, - }) - end) - end) - - it("handles multiple files in one heat map call", function() - local tmpfile1 = vim.fn.tempname() .. "_a.py" - local tmpfile2 = vim.fn.tempname() .. "_b.py" - local fh1 = io.open(tmpfile1, "w") - if fh1 then fh1:write("def a(): pass\n") fh1:close() end - local fh2 = io.open(tmpfile2, "w") - if fh2 then fh2:write("def b(): pass\n") fh2:close() end - - -- Open both files. - vim.cmd("edit " .. tmpfile1) - local buf1 = vim.api.nvim_get_current_buf() - local canonical1 = vim.api.nvim_buf_get_name(buf1) - vim.cmd("edit " .. tmpfile2) - local buf2 = vim.api.nvim_get_current_buf() - local canonical2 = vim.api.nvim_buf_get_name(buf2) - - profiling.apply_heat_map({ - { file = canonical1, line = 1, percentage = 40.0 }, - { file = canonical2, line = 1, percentage = 60.0 }, - }) - - local ns = vim.api.nvim_create_namespace("basilisk-profiling") - local marks1 = vim.api.nvim_buf_get_extmarks(buf1, ns, 0, -1, {}) - local marks2 = vim.api.nvim_buf_get_extmarks(buf2, ns, 0, -1, {}) - - assert.is_true(#marks1 > 0, "file 1 should have extmarks") - assert.is_true(#marks2 > 0, "file 2 should have extmarks") - - vim.cmd("bdelete! " .. buf1) vim.cmd("bdelete! " .. buf2) - os.remove(tmpfile1) os.remove(tmpfile2) - end) - end) - - -- ── export_flamegraph ─────────────────────────────────────────────── - - describe("export_flamegraph", function() - it("handles nil result without errors", function() - assert.has_no.errors(function() - profiling.export_flamegraph(nil) - end) - end) - - it("handles result without flamegraphPath field", function() - assert.has_no.errors(function() - profiling.export_flamegraph({}) - end) - end) - - it("opens the LSP-exported flamegraph SVG as a local file", function() - local tmpfile = vim.fn.tempname() .. ".flamegraph.svg" - local fh = assert(io.open(tmpfile, "w")) - fh:write('hot_fn') - fh:close() - local result = { - flamegraphPath = tmpfile, - outputFile = "/tmp/basilisk-x.speedscope.json", - } - - -- Mock vim.ui.open to prevent browser launch. - local original_open = vim.ui.open - local opened_url = nil - vim.ui.open = function(url) - opened_url = url - end - - profiling.export_flamegraph(result) - - -- Restore. - vim.ui.open = original_open - os.remove(tmpfile) - - assert.equals("file://" .. tmpfile, opened_url, "must open the local SVG directly") - end) - - it("does not open anything when the flamegraph file is missing", function() - local original_open = vim.ui.open - local opened_url = nil - vim.ui.open = function(url) - opened_url = url - end - - profiling.export_flamegraph({ flamegraphPath = "/nonexistent/basilisk.flamegraph.svg" }) - - vim.ui.open = original_open - assert.is_nil(opened_url, "missing file must not be handed to the browser") - end) - - -- [PROFILE-VIEWER-DELIVERY] regression: speedscope.app cannot fetch - -- file:// URLs — an https page may not read local files, so a - -- speedscope.app/#profileURL=file://... link ALWAYS fails with - -- "Something went wrong". The plugin must never construct one. - it("never hands speedscope.app a file:// profileURL", function() - local tmpfile = vim.fn.tempname() .. ".flamegraph.svg" - local fh = assert(io.open(tmpfile, "w")) - fh:write("") - fh:close() - - local original_open = vim.ui.open - local opened_urls = {} - vim.ui.open = function(url) - table.insert(opened_urls, url) - end - - profiling.export_flamegraph({ flamegraphPath = tmpfile }) - profiling.export_flamegraph({ speedscopeJson = '{"profiles":[]}' }) - - vim.ui.open = original_open - os.remove(tmpfile) - - for _, url in ipairs(opened_urls) do - assert.is_nil( - url:find("speedscope.app", 1, true), - "no opened URL may point at speedscope.app with local data: " .. url - ) - end - end) - end) - - -- ── Realistic profiling scenarios ─────────────────────────────────── - - describe("realistic scenarios", function() - it("handles a web application profile with many functions", function() - local result = { - hotFunctions = { - { name = "parse_json", file = "/app/src/parser.py", line = 42, percentage = 35.5 }, - { name = "handle_request", file = "/app/src/views.py", line = 15, percentage = 25.0 }, - { name = "query_db", file = "/app/src/database.py", line = 78, percentage = 15.2 }, - { name = "render_template", file = "/app/src/templates.py", line = 33, percentage = 8.1 }, - { name = "serialize_response", file = "/app/src/serializers.py", line = 12, percentage = 5.5 }, - { name = "validate_input", file = "/app/src/validators.py", line = 8, percentage = 3.2 }, - { name = "log_request", file = "/app/src/middleware.py", line = 45, percentage = 2.1 }, - { name = "cache_lookup", file = "/app/src/cache.py", line = 22, percentage = 1.8 }, - }, - } - - profiling.display_results(result) - - -- Verify quickfix has all 8 functions. - local qf = vim.fn.getqflist() - assert.are.equal(8, #qf, "quickfix should have all 8 hot functions") - - -- Verify quickfix items are in order. - assert.truthy(qf[1].text:find("parse_json"), "first should be parse_json (highest CPU)") - assert.truthy(qf[8].text:find("cache_lookup"), "last should be cache_lookup (lowest CPU)") - - -- Verify all file paths are set. - for i, item in ipairs(qf) do - assert.is_true(item.lnum > 0, "qf item " .. i .. " should have positive line number") - end - end) - - it("handles profiling result with zero-percentage functions", function() - local result = { - hotFunctions = { - { name = "idle_func", file = "/tmp/x.py", line = 1, percentage = 0.0 }, - }, - } - - assert.has_no.errors(function() - profiling.display_results(result) - end) - - local qf = vim.fn.getqflist() - assert.is_true(#qf >= 1) - assert.truthy(qf[1].text:find("0.0"), "should show 0.0%% for idle function") - end) - - it("handles profiling result with very high percentage (100%)", function() - local result = { - hotFunctions = { - { name = "monopoly", file = "/tmp/x.py", line = 1, percentage = 100.0 }, - }, - } - - profiling.display_results(result) - - local qf = vim.fn.getqflist() - assert.is_true(#qf >= 1) - assert.truthy(qf[1].text:find("100.0"), "should show 100.0%%") - end) - end) - - -- ── State management ──────────────────────────────────────────────── - - describe("state management", function() - it("start without client does not error", function() - assert.has_no.errors(function() - profiling.start(12345) - end) - end) - - it("stop without client does not error", function() - assert.has_no.errors(function() - profiling.stop() - end) - end) - - it("snapshot without client does not error", function() - assert.has_no.errors(function() - profiling.snapshot() - end) - end) - - it("start with nil pid does not error", function() - assert.has_no.errors(function() - profiling.start(nil) - end) - end) - end) -end) - --- ── Heat Level Classification (VSIX parity: Profiler — Heat Level Classification) ── - -describe("Profiler — Heat Level Classification", function() - --- Classify heat level matching profiling.lua extmark logic. - ---@param pct number - ---@return string - local function classify_heat(pct) - if pct >= 20 then - return "critical" - elseif pct >= 10 then - return "hot" - elseif pct >= 5 then - return "warm" - elseif pct >= 1 then - return "cool" - else - return "none" - end - end - - it("critical heat level (>= 20%%)", function() - assert.are.equal("critical", classify_heat(25.0)) - assert.are.equal("critical", classify_heat(20.0)) - assert.are.equal("critical", classify_heat(100.0)) - end) - - it("hot heat level (10-20%%)", function() - assert.are.equal("hot", classify_heat(15.0)) - assert.are.equal("hot", classify_heat(10.0)) - assert.are.equal("hot", classify_heat(19.9)) - end) - - it("warm heat level (5-10%%)", function() - assert.are.equal("warm", classify_heat(7.0)) - assert.are.equal("warm", classify_heat(5.0)) - assert.are.equal("warm", classify_heat(9.9)) - end) - - it("cool heat level (1-5%%)", function() - assert.are.equal("cool", classify_heat(3.0)) - assert.are.equal("cool", classify_heat(1.0)) - assert.are.equal("cool", classify_heat(4.9)) - end) - - it("below threshold (< 1%%) is not classified", function() - assert.are.equal("none", classify_heat(0.5)) - assert.are.equal("none", classify_heat(0.0)) - assert.are.equal("none", classify_heat(0.99)) - end) - - it("heat level boundaries are mutually exclusive", function() - local test_cases = { - { pct = 25.0, expected = "critical" }, - { pct = 20.0, expected = "critical" }, - { pct = 19.9, expected = "hot" }, - { pct = 10.0, expected = "hot" }, - { pct = 9.9, expected = "warm" }, - { pct = 5.0, expected = "warm" }, - { pct = 4.9, expected = "cool" }, - { pct = 1.0, expected = "cool" }, - { pct = 0.9, expected = "none" }, - } - - for _, tc in ipairs(test_cases) do - assert.are.equal(tc.expected, classify_heat(tc.pct), - string.format("%.1f%% should be %s", tc.pct, tc.expected)) - end - end) - - it("boundary at exactly 1%%", function() - assert.are.equal("cool", classify_heat(1.0)) - assert.are.equal("none", classify_heat(0.99)) - end) - - it("boundary at exactly 5%%", function() - assert.are.equal("warm", classify_heat(5.0)) - assert.are.equal("cool", classify_heat(4.99)) - end) - - it("boundary at exactly 10%%", function() - assert.are.equal("hot", classify_heat(10.0)) - assert.are.equal("warm", classify_heat(9.99)) - end) - - it("boundary at exactly 20%%", function() - assert.are.equal("critical", classify_heat(20.0)) - assert.are.equal("hot", classify_heat(19.99)) - end) -end) - --- ── Module API Exports (VSIX parity: Profiler — Decoration Modules) ───── - -describe("Profiler — Module API", function() - local profiling = require("basilisk.profiling") - - it("exports start function", function() - assert.is_function(profiling.start) - end) - - it("exports stop function", function() - assert.is_function(profiling.stop) - end) - - it("exports snapshot function", function() - assert.is_function(profiling.snapshot) - end) - - it("exports display_results function", function() - assert.is_function(profiling.display_results) - end) - - it("exports apply_heat_map function", function() - assert.is_function(profiling.apply_heat_map) - end) - - it("exports export_flamegraph function", function() - assert.is_function(profiling.export_flamegraph) - end) -end) - --- ── Status Bar (VSIX parity: Profiler — Status Bar / Status Bar Behavior) ── - -describe("Profiler — Status Bar", function() - local statusline = require("basilisk.statusline") - - it("statusline module exists and has get function", function() - assert.is_not_nil(statusline) - assert.is_function(statusline.get) - end) - - it("statusline get returns a non-empty string", function() - local text = statusline.get() - assert.is_string(text) - assert.is_true(#text > 0, "status line text should not be empty") - end) - - it("statusline text includes Basilisk", function() - local text = statusline.get() - assert.truthy(text:find("Basilisk"), "status line should include 'Basilisk'") - end) - - it("get_color returns valid highlight group", function() - local color = statusline.get_color() - assert.is_string(color) - local valid_groups = { - DiagnosticOk = true, - DiagnosticWarn = true, - DiagnosticError = true, - Comment = true, - } - assert.is_true(valid_groups[color] ~= nil, - "color should be a known highlight group, got: " .. color) - end) - - it("set_state to starting changes state", function() - statusline.set_state("starting") - local text = statusline.get() - assert.truthy(text:find("Basilisk")) - -- Restore. - statusline.set_state("stopped") - end) - - it("set_state to error uses DiagnosticError color", function() - statusline.set_state("error") - local color = statusline.get_color() - assert.are.equal("DiagnosticError", color, "error state should use DiagnosticError") - statusline.set_state("stopped") - end) - - it("set_state to stopped uses Comment color", function() - statusline.set_state("stopped") - local color = statusline.get_color() - assert.are.equal("Comment", color, "stopped state should use Comment") - end) - - it("lualine_component is a valid table with function and color", function() - assert.is_table(statusline.lualine_component) - assert.is_function(statusline.lualine_component[1]) - assert.is_function(statusline.lualine_component.color) - end) - - it("lualine_component function returns string", function() - local fn = statusline.lualine_component[1] - local result = fn() - assert.is_string(result) - assert.is_true(#result > 0) - end) -end) - --- ── Data Structures (VSIX parity: ProfileResult, ProfileHotLine, ProfileHotFunction) ── - -describe("Profiler — Data Structures", function() - it("ProfileResult type has all required fields", function() - local result = { - sessionId = "test-session-001", - duration = 5.2, - totalSamples = 1000, - outputFile = "/tmp/test.speedscope.json", - hotFunctions = {}, - hotLines = {}, - } - - assert.are.equal("test-session-001", result.sessionId) - assert.are.equal(5.2, result.duration) - assert.are.equal(1000, result.totalSamples) - assert.are.equal("/tmp/test.speedscope.json", result.outputFile) - assert.is_table(result.hotFunctions) - assert.is_table(result.hotLines) - end) - - it("ProfileHotLine type has required fields", function() - local hot_line = { - file = "/src/app.py", - line = 42, - samples = 500, - percentage = 25.0, - } - - assert.are.equal("/src/app.py", hot_line.file) - assert.are.equal(42, hot_line.line) - assert.are.equal(500, hot_line.samples) - assert.are.equal(25.0, hot_line.percentage) - end) - - it("ProfileHotFunction type has required fields", function() - local hot_func = { - name = "process_data", - file = "/src/pipeline.py", - line = 15, - samples = 800, - percentage = 40.0, - selfPercentage = 30.0, - } - - assert.are.equal("process_data", hot_func.name) - assert.are.equal("/src/pipeline.py", hot_func.file) - assert.are.equal(15, hot_func.line) - assert.are.equal(800, hot_func.samples) - assert.are.equal(40.0, hot_func.percentage) - assert.are.equal(30.0, hot_func.selfPercentage) - assert.is_true(hot_func.selfPercentage <= hot_func.percentage, - "selfPercentage should not exceed percentage") - end) - - it("ProfileResult with populated hotFunctions validates structure", function() - local result = { - sessionId = "populated-session", - duration = 10.5, - totalSamples = 5000, - outputFile = "/tmp/profile.speedscope.json", - hotFunctions = { - { name = "compute", file = "/src/math.py", line = 10, samples = 2500, percentage = 50.0, selfPercentage = 35.0 }, - { name = "transform", file = "/src/utils.py", line = 88, samples = 1000, percentage = 20.0, selfPercentage = 15.0 }, - }, - hotLines = { - { file = "/src/math.py", line = 12, samples = 2000, percentage = 40.0 }, - }, - } - - assert.are.equal(2, #result.hotFunctions, "should have 2 hot functions") - assert.are.equal(1, #result.hotLines, "should have 1 hot line") - assert.are.equal("compute", result.hotFunctions[1].name) - assert.are.equal("transform", result.hotFunctions[2].name) - assert.is_true(result.hotFunctions[1].percentage > result.hotFunctions[2].percentage, - "first function should have higher percentage") - assert.is_true(result.hotFunctions[1].selfPercentage <= result.hotFunctions[1].percentage, - "selfPercentage should not exceed percentage") - end) - - it("MemoryAllocation type has required fields", function() - local alloc = { - file = "/src/data.py", - line = 100, - size = 10485760, - count = 5000, - } - - assert.are.equal("/src/data.py", alloc.file) - assert.are.equal(100, alloc.line) - assert.are.equal(10485760, alloc.size) - assert.are.equal(5000, alloc.count) - end) - - it("MemorySnapshotResult type has required fields", function() - local snapshot = { - memorySessionId = "mem-session-001", - snapshotId = "snap-001", - currentMemory = 50000000, - peakMemory = 75000000, - topAllocations = {}, - } - - assert.are.equal("mem-session-001", snapshot.memorySessionId) - assert.are.equal("snap-001", snapshot.snapshotId) - assert.are.equal(50000000, snapshot.currentMemory) - assert.are.equal(75000000, snapshot.peakMemory) - assert.is_table(snapshot.topAllocations) - end) - - it("MemorySnapshotResult with allocations validates ordering", function() - local snapshot = { - memorySessionId = "mem-full", - snapshotId = "snap-full", - currentMemory = 100000000, - peakMemory = 150000000, - topAllocations = { - { file = "/src/data.py", line = 10, size = 50000000, count = 1000 }, - { file = "/src/cache.py", line = 20, size = 30000000, count = 500 }, - }, - } - - assert.are.equal(2, #snapshot.topAllocations) - assert.is_true(snapshot.topAllocations[1].size > snapshot.topAllocations[2].size, - "allocations should be ordered by size") - assert.is_true(snapshot.currentMemory <= snapshot.peakMemory, - "currentMemory should not exceed peakMemory") - end) - - it("LeakConfidence values are within valid range", function() - local leak = { - typeName = "DataFrame", - confidence = 0.85, - count = 15, - totalSize = "1.2MB", - } - - assert.is_true(leak.confidence >= 0 and leak.confidence <= 1, - "confidence should be between 0 and 1") - end) - - it("SuspectedLeak type has required fields", function() - local leak = { - typeName = "dict", - confidence = 0.72, - count = 50, - totalSize = "500KB", - location = { file = "/app/cache.py", line = 33 }, - } - - assert.are.equal("dict", leak.typeName) - assert.is_number(leak.confidence) - assert.is_number(leak.count) - assert.is_string(leak.totalSize) - assert.is_not_nil(leak.location) - assert.are.equal("/app/cache.py", leak.location.file) - assert.are.equal(33, leak.location.line) - end) - - it("MemoryDiffResult type has required fields", function() - local diff = { - baseSnapshotId = "snap-001", - comparedSnapshotId = "snap-002", - totalGrowth = 25000000, - newAllocations = 150, - freedAllocations = 50, - suspectedLeaks = {}, - } - - assert.are.equal("snap-001", diff.baseSnapshotId) - assert.are.equal("snap-002", diff.comparedSnapshotId) - assert.is_number(diff.totalGrowth) - assert.is_number(diff.newAllocations) - assert.is_number(diff.freedAllocations) - assert.is_table(diff.suspectedLeaks) - assert.is_true(diff.newAllocations > diff.freedAllocations, - "net growth should be positive") - end) -end) - --- ── Decoration Contracts (VSIX parity: Profiler — Decoration Contracts) ── - -describe("Profiler — Decoration Contracts", function() - local profiling = require("basilisk.profiling") - local ns = vim.api.nvim_create_namespace("basilisk-profiling") - - after_each(function() - for _, buf in ipairs(vim.api.nvim_list_bufs()) do - if vim.api.nvim_buf_is_loaded(buf) then - vim.api.nvim_buf_clear_namespace(buf, ns, 0, -1) - end - end - for _, win in ipairs(vim.api.nvim_list_wins()) do - local config = vim.api.nvim_win_get_config(win) - if config.relative and config.relative ~= "" then - pcall(vim.api.nvim_win_close, win, true) - end - end - end) - - it("apply-clear-reapply cycle is idempotent", function() - local tmpfile = vim.fn.tempname() .. ".py" - local fh = io.open(tmpfile, "w") - if fh then fh:write("x = 1\n") fh:close() end - vim.cmd("edit " .. tmpfile) - local buf = vim.api.nvim_get_current_buf() - local canonical = vim.api.nvim_buf_get_name(buf) - - local hot = { { file = canonical, line = 1, percentage = 42.0 } } - - assert.has_no.errors(function() profiling.apply_heat_map(hot) end) - assert.has_no.errors(function() profiling.apply_heat_map({}) end) - assert.has_no.errors(function() profiling.apply_heat_map(hot) end) - - local marks = vim.api.nvim_buf_get_extmarks(buf, ns, 0, -1, {}) - assert.are.equal(1, #marks, "should have exactly 1 mark after reapply") - - vim.cmd("bdelete!") os.remove(tmpfile) - end) - - it("double-clear does not throw", function() - assert.has_no.errors(function() - profiling.apply_heat_map({}) - profiling.apply_heat_map({}) - end) - end) - - it("decorations with multiple files and varying percentages", function() - local tmpfile = vim.fn.tempname() .. ".py" - local fh = io.open(tmpfile, "w") - if fh then fh:write("def hot_func():\n x = 1\n y = 2\n z = x + y\n return z\n") fh:close() end - vim.cmd("edit " .. tmpfile) - local buf = vim.api.nvim_get_current_buf() - local canonical = vim.api.nvim_buf_get_name(buf) - - profiling.apply_heat_map({ - { file = canonical, line = 1, percentage = 55.0 }, - { file = canonical, line = 3, percentage = 25.0 }, - { file = canonical, line = 5, percentage = 3.0 }, - }) - - local marks = vim.api.nvim_buf_get_extmarks(buf, ns, 0, -1, { details = true }) - assert.are.equal(3, #marks, "should have 3 extmarks") - - -- Verify highlight groups match profiling.lua logic. - -- >50% = DiagnosticError, 20-50% = DiagnosticWarn, <20% = DiagnosticHint - assert.are.equal("DiagnosticError", marks[1][4].virt_text[1][2]) - assert.are.equal("DiagnosticWarn", marks[2][4].virt_text[1][2]) - assert.are.equal("DiagnosticHint", marks[3][4].virt_text[1][2]) - - vim.cmd("bdelete!") os.remove(tmpfile) - end) - - it("heat level boundary at exactly 1%% in extmarks", function() - local tmpfile = vim.fn.tempname() .. ".py" - local fh = io.open(tmpfile, "w") - if fh then fh:write("x = 1\n") fh:close() end - vim.cmd("edit " .. tmpfile) - local buf = vim.api.nvim_get_current_buf() - local canonical = vim.api.nvim_buf_get_name(buf) - - profiling.apply_heat_map({ - { file = canonical, line = 1, percentage = 1.0 }, - }) - - local marks = vim.api.nvim_buf_get_extmarks(buf, ns, 0, -1, { details = true }) - assert.is_true(#marks > 0, "1%% should produce an extmark") - -- 1% is <20% so DiagnosticHint. - assert.are.equal("DiagnosticHint", marks[1][4].virt_text[1][2]) - - vim.cmd("bdelete!") os.remove(tmpfile) - end) - - it("extmark virtual text contains percentage value", function() - local tmpfile = vim.fn.tempname() .. ".py" - local fh = io.open(tmpfile, "w") - if fh then fh:write("hot = True\n") fh:close() end - vim.cmd("edit " .. tmpfile) - local buf = vim.api.nvim_get_current_buf() - local canonical = vim.api.nvim_buf_get_name(buf) - - profiling.apply_heat_map({ - { file = canonical, line = 1, percentage = 67.3 }, - }) - - local marks = vim.api.nvim_buf_get_extmarks(buf, ns, 0, -1, { details = true }) - assert.is_true(#marks > 0) - local virt_str = marks[1][4].virt_text[1][1] - assert.truthy(virt_str:find("67.3"), "virtual text should contain the percentage") - - vim.cmd("bdelete!") os.remove(tmpfile) - end) -end) - --- ── Configuration Interaction (VSIX parity: Profiler — Configuration Interaction) ── - -describe("Profiler — Configuration Interaction", function() - local config_mod = require("basilisk.config") - - it("default config validates without errors", function() - local errors = config_mod.validate(config_mod.defaults) - assert.are.equal(0, #errors, "default config should have no validation errors") - end) - - it("test_explorer framework supports auto, pytest, unittest", function() - local valid_frameworks = { "auto", "pytest", "unittest" } - for _, fw in ipairs(valid_frameworks) do - local cfg = vim.tbl_deep_extend("force", {}, config_mod.defaults, { - test_explorer = { framework = fw }, - }) - local errors = config_mod.validate(cfg) - assert.are.equal(0, #errors, fw .. " should be a valid framework") - end - end) - - it("rejects invalid test_explorer framework", function() - local cfg = vim.tbl_deep_extend("force", {}, config_mod.defaults, { - test_explorer = { framework = "nose" }, - }) - local errors = config_mod.validate(cfg) - assert.is_true(#errors > 0, "invalid framework should produce error") - end) - - it("valid analysis modes are accepted", function() - local valid_modes = { "openFilesOnly", "wholeModule", "crossModule" } - for _, mode in ipairs(valid_modes) do - local cfg = vim.tbl_deep_extend("force", {}, config_mod.defaults, { - analysis_mode = mode, - }) - local errors = config_mod.validate(cfg) - assert.are.equal(0, #errors, mode .. " should be a valid analysis mode") - end - end) - - it("valid log levels are accepted", function() - local valid_levels = { "trace", "debug", "info", "warn", "error" } - for _, level in ipairs(valid_levels) do - local cfg = vim.tbl_deep_extend("force", {}, config_mod.defaults, { - log_level = level, - }) - local errors = config_mod.validate(cfg) - assert.are.equal(0, #errors, level .. " should be a valid log level") - end - end) - - it("config merge preserves nested defaults", function() - local resolved = config_mod.resolve({ analysis_mode = "crossModule" }) - assert.are.equal("crossModule", resolved.analysis_mode) - -- Nested defaults should be preserved. - assert.are.equal("ruff", resolved.formatter, "formatter default should be preserved") - assert.is_true(resolved.test_explorer.enabled, "test_explorer.enabled should be preserved") - assert.is_true(resolved.uv.enabled, "uv.enabled should be preserved") - end) - - it("keymaps have default prefix", function() - local resolved = config_mod.resolve() - assert.are.equal("b", resolved.keymaps.prefix) - assert.is_true(resolved.keymaps.enabled) - end) -end) - --- ── Lifecycle Interaction (VSIX parity: Profiler — Lifecycle Interaction) ── - -describe("Profiler — Lifecycle Interaction", function() - local profiling = require("basilisk.profiling") - - after_each(function() - for _, win in ipairs(vim.api.nvim_list_wins()) do - local config = vim.api.nvim_win_get_config(win) - if config.relative and config.relative ~= "" then - pcall(vim.api.nvim_win_close, win, true) - end - end - vim.fn.setqflist({}, "r") - end) - - it("handles special characters in function names", function() - local result = { - hotFunctions = { - { name = "__init__", file = "/tmp/cls.py", line = 1, percentage = 30.0 }, - { name = "", file = "/tmp/cls.py", line = 5, percentage = 15.0 }, - { name = "Class.method", file = "/tmp/cls.py", line = 10, percentage = 10.0 }, - }, - } - - assert.has_no.errors(function() - profiling.display_results(result) - end) - - -- Verify all names appear in float. - for _, win in ipairs(vim.api.nvim_list_wins()) do - local config = vim.api.nvim_win_get_config(win) - if config.relative and config.relative ~= "" then - local buf = vim.api.nvim_win_get_buf(win) - local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) - local text = table.concat(lines, "\n") - assert.truthy(text:find("__init__"), "should show __init__") - assert.truthy(text:find("Class.method"), "should show Class.method") - end - end - end) - - it("handles 20 hot functions without issues", function() - local funcs = {} - for i = 1, 20 do - funcs[i] = { - name = "func_" .. i, - file = "/tmp/many.py", - line = i, - percentage = 100 / i, - } - end - - assert.has_no.errors(function() - profiling.display_results({ hotFunctions = funcs }) - end) - - local qf = vim.fn.getqflist() - assert.are.equal(20, #qf, "quickfix should have all 20 functions") - end) - - it("consecutive display_results calls replace quickfix", function() - profiling.display_results({ - hotFunctions = { - { name = "old_func", file = "/tmp/old.py", line = 1, percentage = 50 }, - }, - }) - assert.is_true(#vim.fn.getqflist() >= 1) - - for _, win in ipairs(vim.api.nvim_list_wins()) do - local config = vim.api.nvim_win_get_config(win) - if config.relative and config.relative ~= "" then - pcall(vim.api.nvim_win_close, win, true) - end - end - - profiling.display_results({ - hotFunctions = { - { name = "new_func", file = "/tmp/new.py", line = 2, percentage = 70 }, - }, - }) - - local qf = vim.fn.getqflist() - assert.is_true(#qf >= 1) - assert.truthy(qf[1].text:find("new_func"), "quickfix should have new results") - end) - - it("web server profiling scenario with 6 functions", function() - local result = { - hotFunctions = { - { name = "db_query", file = "/app/models.py", line = 45, percentage = 45.0 }, - { name = "render_template", file = "/app/views.py", line = 22, percentage = 25.0 }, - { name = "serialize_json", file = "/app/serializers.py", line = 10, percentage = 12.0 }, - { name = "validate_input", file = "/app/validators.py", line = 5, percentage = 8.0 }, - { name = "log_request", file = "/app/middleware.py", line = 30, percentage = 3.0 }, - { name = "parse_headers", file = "/app/http.py", line = 15, percentage = 1.5 }, - }, - } - - profiling.display_results(result) - local qf = vim.fn.getqflist() - assert.are.equal(6, #qf, "quickfix should have all 6 web functions") - - -- Verify ordering: first should be db_query (highest CPU). - assert.truthy(qf[1].text:find("db_query"), "first qf item should be db_query") - assert.truthy(qf[6].text:find("parse_headers"), "last should be parse_headers") - end) - - it("error messages from profiler start/stop are not raw stack traces", function() - -- Without an LSP client, start/stop/snapshot should log warnings, not throw. - assert.has_no.errors(function() - profiling.start(0) - end) - assert.has_no.errors(function() - profiling.stop() - end) - assert.has_no.errors(function() - profiling.snapshot() - end) - end) -end) - --- ── Suite: Command Registration (VSIX parity) ───────────────────────────── - -describe("profiler — command registration", function() - -- Ensure commands are registered (idempotent). - local config = require("basilisk.config").defaults - require("basilisk.commands").register(config) - - local PROFILER_COMMANDS = { - "BasiliskProfile", - "BasiliskProfileStop", - "BasiliskProfileSnapshot", - } - - local MEMORY_COMMANDS = { - "BasiliskMemLeak", - "BasiliskMemStop", - "BasiliskMemRefs", - } - - it("all profiler user commands are registered", function() - local all_cmds = vim.api.nvim_get_commands({}) - for _, cmd in ipairs(PROFILER_COMMANDS) do - assert.truthy(all_cmds[cmd], "user command '" .. cmd .. "' should be registered") - end - end) - - it("profiler commands have descriptions", function() - local all_cmds = vim.api.nvim_get_commands({}) - for _, cmd in ipairs(PROFILER_COMMANDS) do - local entry = all_cmds[cmd] - assert.truthy(entry, "command '" .. cmd .. "' should exist") - assert.truthy( - command_desc.of(entry), - "command '" .. cmd .. "' should have a description" - ) - end - end) - - it("BasiliskProfile accepts optional PID argument (nargs=?)", function() - local all_cmds = vim.api.nvim_get_commands({}) - local entry = all_cmds["BasiliskProfile"] - assert.truthy(entry, "BasiliskProfile should exist") - assert.are.equal("?", entry.nargs, "BasiliskProfile should accept 0 or 1 args") - end) - - it("BasiliskProfileStop takes no arguments (nargs=0)", function() - local all_cmds = vim.api.nvim_get_commands({}) - local entry = all_cmds["BasiliskProfileStop"] - assert.truthy(entry, "BasiliskProfileStop should exist") - assert.are.equal("0", entry.nargs, "BasiliskProfileStop takes no arguments") - end) - - it("BasiliskProfileSnapshot takes no arguments (nargs=0)", function() - local all_cmds = vim.api.nvim_get_commands({}) - local entry = all_cmds["BasiliskProfileSnapshot"] - assert.truthy(entry, "BasiliskProfileSnapshot should exist") - assert.are.equal("0", entry.nargs, "BasiliskProfileSnapshot takes no arguments") - end) - - it("profiler commands are distinct from memory commands", function() - local profiler_set = {} - for _, cmd in ipairs(PROFILER_COMMANDS) do - profiler_set[cmd] = true - end - for _, cmd in ipairs(MEMORY_COMMANDS) do - assert.falsy(profiler_set[cmd], "memory command '" .. cmd .. "' must not be in profiler set") - end - end) - - it("all profiler and memory commands are unique", function() - local all_commands = {} - for _, cmd in ipairs(PROFILER_COMMANDS) do - all_commands[#all_commands + 1] = cmd - end - for _, cmd in ipairs(MEMORY_COMMANDS) do - all_commands[#all_commands + 1] = cmd - end - local seen = {} - for _, cmd in ipairs(all_commands) do - assert.falsy(seen[cmd], "command '" .. cmd .. "' must be unique") - seen[cmd] = true - end - end) - - it("memory commands are also registered", function() - local all_cmds = vim.api.nvim_get_commands({}) - for _, cmd in ipairs(MEMORY_COMMANDS) do - assert.truthy(all_cmds[cmd], "memory command '" .. cmd .. "' should be registered") - end - end) - - it("BasiliskMemRefs takes exactly 1 argument (nargs=1)", function() - local all_cmds = vim.api.nvim_get_commands({}) - local entry = all_cmds["BasiliskMemRefs"] - assert.truthy(entry) - assert.are.equal("1", entry.nargs, "BasiliskMemRefs should take exactly 1 arg") - end) - - it("BasiliskMemLeak takes no arguments", function() - local all_cmds = vim.api.nvim_get_commands({}) - local entry = all_cmds["BasiliskMemLeak"] - assert.truthy(entry) - assert.are.equal("0", entry.nargs) - end) - - it("BasiliskMemStop takes no arguments", function() - local all_cmds = vim.api.nvim_get_commands({}) - local entry = all_cmds["BasiliskMemStop"] - assert.truthy(entry) - assert.are.equal("0", entry.nargs) - end) -end) - --- ── Suite: Configuration (VSIX parity) ───────────────────────────────────── - -describe("profiler — configuration", function() - local config = require("basilisk.config") - - it("config module loads without error", function() - assert.truthy(config) - assert.truthy(config.defaults) - end) - - it("resolve merges defaults with empty options", function() - local resolved = config.resolve({}) - assert.are.equal(true, resolved.enabled, "enabled defaults to true") - assert.are.equal(true, resolved.use_lsp, "use_lsp defaults to true") - assert.are.equal("wholeModule", resolved.analysis_mode) - end) - - it("log_level defaults to 'info'", function() - local resolved = config.resolve({}) - assert.are.equal("info", resolved.log_level) - end) - - it("validate rejects invalid analysis_mode", function() - local bad = vim.tbl_deep_extend("force", {}, config.defaults, { analysis_mode = "invalid" }) - local errors = config.validate(bad) - assert.is_true(#errors > 0, "should report validation error") - end) - - it("validate accepts all valid analysis_mode values", function() - for _, mode in ipairs({ "openFilesOnly", "wholeModule", "crossModule" }) do - local cfg = vim.tbl_deep_extend("force", {}, config.defaults, { analysis_mode = mode }) - local errors = config.validate(cfg) - assert.are.equal(0, #errors, "'" .. mode .. "' should be valid") - end - end) - - it("validate rejects invalid log_level", function() - local bad = vim.tbl_deep_extend("force", {}, config.defaults, { log_level = "banana" }) - local errors = config.validate(bad) - assert.is_true(#errors > 0) - end) - - it("validate accepts all valid log_level values", function() - for _, level in ipairs({ "trace", "debug", "info", "warn", "error" }) do - local cfg = vim.tbl_deep_extend("force", {}, config.defaults, { log_level = level }) - local errors = config.validate(cfg) - assert.are.equal(0, #errors, "'" .. level .. "' should be valid") - end - end) - - it("validate rejects invalid test_explorer framework", function() - local bad = vim.deepcopy(config.defaults) - bad.test_explorer.framework = "jest" - local errors = config.validate(bad) - assert.is_true(#errors > 0) - end) - - it("validate rejects invalid test_explorer position", function() - local bad = vim.deepcopy(config.defaults) - bad.test_explorer.position = "top" - local errors = config.validate(bad) - assert.is_true(#errors > 0) - end) - - it("resolve overrides defaults with user options", function() - local resolved = config.resolve({ log_level = "debug", analysis_mode = "crossModule" }) - assert.are.equal("debug", resolved.log_level) - assert.are.equal("crossModule", resolved.analysis_mode) - end) - - it("debugger defaults are correct", function() - local resolved = config.resolve({}) - assert.are.equal(true, resolved.debugger.enabled) - assert.are.equal(false, resolved.debugger.type_checking) - assert.are.equal("debugpy", resolved.debugger.debugpy_path) - end) - - it("test_explorer defaults are correct", function() - local resolved = config.resolve({}) - assert.are.equal(true, resolved.test_explorer.enabled) - assert.are.equal("auto", resolved.test_explorer.framework) - assert.are.equal("pytest", resolved.test_explorer.pytest_path) - assert.are.equal(true, resolved.test_explorer.auto_discover_on_save) - assert.are.equal("right", resolved.test_explorer.position) - assert.are.equal(40, resolved.test_explorer.width) - end) - - it("formatter defaults are correct", function() - -- [LSPFMT-CONFIG]: the embedded Ruff formatter is the default engine. - local resolved = config.resolve({}) - assert.are.equal("ruff", resolved.formatter) - end) - - it("inlay_hints defaults are correct", function() - local resolved = config.resolve({}) - assert.are.equal(true, resolved.inlay_hints.parameter_names) - assert.are.equal(true, resolved.inlay_hints.variable_types) - end) -end) - --- ── Suite: Status Bar (VSIX parity) ──────────────────────────────────────── - -describe("profiler — statusline", function() - local statusline = require("basilisk.statusline") - - it("module loads", function() - assert.truthy(statusline) - end) - - it("get() returns a non-empty string", function() - local text = statusline.get() - assert.is_string(text) - assert.is_true(#text > 0) - end) - - it("get() contains 'Basilisk'", function() - local text = statusline.get() - assert.truthy(text:find("Basilisk"), "statusline should mention Basilisk") - end) - - it("get_color() returns a highlight group string", function() - local color = statusline.get_color() - assert.is_string(color) - assert.is_true(#color > 0) - end) - - it("set_state('error') uses DiagnosticError", function() - statusline.set_state("error") - assert.are.equal("DiagnosticError", statusline.get_color()) - statusline.set_state("stopped") - end) - - it("set_state('starting') uses DiagnosticWarn", function() - statusline.set_state("starting") - assert.are.equal("DiagnosticWarn", statusline.get_color()) - statusline.set_state("stopped") - end) - - it("set_state('stopped') uses Comment", function() - statusline.set_state("stopped") - assert.are.equal("Comment", statusline.get_color()) - end) - - it("lualine_component is a valid table", function() - assert.is_table(statusline.lualine_component) - assert.is_function(statusline.lualine_component[1]) - assert.is_function(statusline.lualine_component.color) - end) - - it("lualine component function returns statusline text", function() - local text = statusline.lualine_component[1]() - assert.is_string(text) - assert.truthy(text:find("Basilisk")) - end) - - it("lualine component color returns a table with fg", function() - local result = statusline.lualine_component.color() - assert.is_table(result) - -- fg may be nil if highlight not defined in headless mode, but table should exist. - end) -end) - --- ── Suite: Keybindings (VSIX parity) ─────────────────────────────────────── - -describe("profiler — keybindings", function() - local config = require("basilisk.config") - - it("keymaps config has enabled flag", function() - assert.is_table(config.defaults.keymaps) - assert.is_boolean(config.defaults.keymaps.enabled) - end) - - it("keymaps enabled defaults to true", function() - assert.are.equal(true, config.defaults.keymaps.enabled) - end) - - it("keymaps config has prefix string", function() - assert.is_string(config.defaults.keymaps.prefix) - assert.is_true(#config.defaults.keymaps.prefix > 0) - end) - - it("default keymap prefix is b", function() - assert.are.equal("b", config.defaults.keymaps.prefix) - end) - - it("keymaps can be disabled via config", function() - local resolved = config.resolve({ keymaps = { enabled = false } }) - assert.are.equal(false, resolved.keymaps.enabled) - end) - - it("keymap prefix can be customized", function() - local resolved = config.resolve({ keymaps = { prefix = "p" } }) - assert.are.equal("p", resolved.keymaps.prefix) - end) -end) - --- ── Suite: Heat Level Classification (VSIX parity) ───────────────────────── - -describe("profiler — heat level classification", function() - --- Classify heat level from percentage (mirrors profiler-decorations.ts). - ---@param pct number - ---@return string - local function classify_heat(pct) - if pct >= 20 then - return "critical" - elseif pct >= 10 then - return "hot" - elseif pct >= 5 then - return "warm" - elseif pct >= 1 then - return "cool" - else - return "none" - end - end - - it("critical heat level classification (>= 20%)", function() - assert.are.equal("critical", classify_heat(25.0)) - assert.are.equal("critical", classify_heat(20.0)) - assert.are.equal("critical", classify_heat(100.0)) - end) - - it("hot heat level classification (10-20%)", function() - assert.are.equal("hot", classify_heat(15.0)) - assert.are.equal("hot", classify_heat(10.0)) - assert.are.equal("hot", classify_heat(19.9)) - end) - - it("warm heat level classification (5-10%)", function() - assert.are.equal("warm", classify_heat(7.0)) - assert.are.equal("warm", classify_heat(5.0)) - assert.are.equal("warm", classify_heat(9.9)) - end) - - it("cool heat level classification (1-5%)", function() - assert.are.equal("cool", classify_heat(3.0)) - assert.are.equal("cool", classify_heat(1.0)) - assert.are.equal("cool", classify_heat(4.9)) - end) - - it("below threshold (< 1%) is not classified", function() - assert.are.equal("none", classify_heat(0.5)) - assert.are.equal("none", classify_heat(0.0)) - assert.are.equal("none", classify_heat(0.99)) - end) - - it("heat level boundaries are mutually exclusive", function() - local test_cases = { - { pct = 25.0, expected = "critical" }, - { pct = 20.0, expected = "critical" }, - { pct = 19.9, expected = "hot" }, - { pct = 10.0, expected = "hot" }, - { pct = 9.9, expected = "warm" }, - { pct = 5.0, expected = "warm" }, - { pct = 4.9, expected = "cool" }, - { pct = 1.0, expected = "cool" }, - { pct = 0.9, expected = "none" }, - } - - for _, tc in ipairs(test_cases) do - assert.are.equal( - tc.expected, - classify_heat(tc.pct), - string.format("%.1f%% should be '%s'", tc.pct, tc.expected) - ) - end - end) - - it("heat map extmark highlight groups match profiling.lua palette", function() - -- profiling.lua apply_heat_map uses: - -- > 50% → DiagnosticError - -- > 20% → DiagnosticWarn - -- else → DiagnosticHint - local function expected_hl(pct) - if pct > 50 then - return "DiagnosticError" - elseif pct > 20 then - return "DiagnosticWarn" - else - return "DiagnosticHint" - end - end - - assert.are.equal("DiagnosticError", expected_hl(60)) - assert.are.equal("DiagnosticError", expected_hl(51)) - assert.are.equal("DiagnosticWarn", expected_hl(50)) - assert.are.equal("DiagnosticWarn", expected_hl(21)) - assert.are.equal("DiagnosticHint", expected_hl(20)) - assert.are.equal("DiagnosticHint", expected_hl(5)) - assert.are.equal("DiagnosticHint", expected_hl(1)) - end) -end) - --- ── Suite: Data Structures (VSIX parity) ─────────────────────────────────── - -describe("profiler — data structures", function() - it("ProfileResult-like table validates required fields", function() - local result = { - sessionId = "test-session-001", - duration = 5.2, - totalSamples = 1000, - outputFile = "/tmp/test.speedscope.json", - hotFunctions = {}, - hotLines = {}, - } - - assert.are.equal("test-session-001", result.sessionId) - assert.are.equal(5.2, result.duration) - assert.are.equal(1000, result.totalSamples) - assert.are.equal("/tmp/test.speedscope.json", result.outputFile) - assert.is_table(result.hotFunctions) - assert.is_table(result.hotLines) - end) - - it("ProfileHotLine-like table validates required fields", function() - local hot_line = { - file = "/src/app.py", - line = 42, - samples = 500, - percentage = 25.0, - } - - assert.are.equal("/src/app.py", hot_line.file) - assert.are.equal(42, hot_line.line) - assert.are.equal(500, hot_line.samples) - assert.are.equal(25.0, hot_line.percentage) - end) - - it("ProfileHotFunction-like table validates required fields", function() - local hot_func = { - name = "process_data", - file = "/src/pipeline.py", - line = 15, - samples = 800, - percentage = 40.0, - selfPercentage = 30.0, - } - - assert.are.equal("process_data", hot_func.name) - assert.are.equal("/src/pipeline.py", hot_func.file) - assert.are.equal(15, hot_func.line) - assert.are.equal(800, hot_func.samples) - assert.are.equal(40.0, hot_func.percentage) - assert.are.equal(30.0, hot_func.selfPercentage) - assert.is_true(hot_func.selfPercentage <= hot_func.percentage, - "selfPercentage should not exceed percentage") - end) - - it("MemoryAllocation-like table validates required fields", function() - local alloc = { - file = "/src/data.py", - line = 100, - size = 10485760, - count = 5000, - } - - assert.are.equal("/src/data.py", alloc.file) - assert.are.equal(100, alloc.line) - assert.are.equal(10485760, alloc.size) - assert.are.equal(5000, alloc.count) - end) - - it("MemorySnapshotResult-like table validates required fields", function() - local snapshot = { - memorySessionId = "mem-session-001", - snapshotId = "snap-001", - currentMemory = 50000000, - peakMemory = 75000000, - topAllocations = {}, - } - - assert.are.equal("mem-session-001", snapshot.memorySessionId) - assert.are.equal("snap-001", snapshot.snapshotId) - assert.are.equal(50000000, snapshot.currentMemory) - assert.are.equal(75000000, snapshot.peakMemory) - assert.is_table(snapshot.topAllocations) - end) - - it("MemoryDiffResult-like table validates required fields", function() - local diff = { - beforeSnapshot = "snap-001", - afterSnapshot = "snap-002", - growthEntries = {}, - totalGrowth = 1048576, - } - - assert.are.equal("snap-001", diff.beforeSnapshot) - assert.are.equal("snap-002", diff.afterSnapshot) - assert.is_table(diff.growthEntries) - assert.are.equal(1048576, diff.totalGrowth) - end) - - it("SuspectedLeak-like table validates required fields", function() - local leak = { - typeName = "DataFrame", - count = 150, - totalSize = "12.5MB", - confidence = "High", - location = { file = "/src/data.py", line = 42 }, - } - - assert.are.equal("DataFrame", leak.typeName) - assert.are.equal(150, leak.count) - assert.are.equal("12.5MB", leak.totalSize) - assert.are.equal("High", leak.confidence) - assert.is_table(leak.location) - assert.are.equal("/src/data.py", leak.location.file) - assert.are.equal(42, leak.location.line) - end) - - it("populated ProfileResult validates hot function ordering", function() - local result = { - sessionId = "populated-session", - duration = 10.5, - totalSamples = 5000, - outputFile = "/tmp/profile.speedscope.json", - hotFunctions = { - { name = "compute", file = "/src/math.py", line = 10, samples = 2500, percentage = 50.0, selfPercentage = 35.0 }, - { name = "transform", file = "/src/utils.py", line = 88, samples = 1000, percentage = 20.0, selfPercentage = 15.0 }, - }, - hotLines = { - { file = "/src/math.py", line = 12, samples = 2000, percentage = 40.0 }, - }, - } - - assert.are.equal(2, #result.hotFunctions) - assert.are.equal(1, #result.hotLines) - assert.are.equal("compute", result.hotFunctions[1].name) - assert.are.equal("transform", result.hotFunctions[2].name) - assert.is_true(result.hotFunctions[1].percentage > result.hotFunctions[2].percentage, - "first function should have higher percentage") - assert.is_true(result.hotFunctions[1].selfPercentage <= result.hotFunctions[1].percentage, - "selfPercentage must not exceed percentage") - end) - - it("LeakConfidence values are ordered correctly", function() - local confidences = { "Low", "Medium", "High", "Definite" } - local order = {} - for i, c in ipairs(confidences) do - order[c] = i - end - assert.is_true(order["Low"] < order["Medium"]) - assert.is_true(order["Medium"] < order["High"]) - assert.is_true(order["High"] < order["Definite"]) - end) -end) diff --git a/basilisk.nvim/tests/basilisk/tab_tracking_spec.lua b/basilisk.nvim/tests/basilisk/tab_tracking_spec.lua deleted file mode 100644 index dcf09c3b1..000000000 --- a/basilisk.nvim/tests/basilisk/tab_tracking_spec.lua +++ /dev/null @@ -1,32 +0,0 @@ ---- Tests for basilisk.tab_tracking module. - -describe("basilisk.tab_tracking", function() - local tab_tracking = require("basilisk.tab_tracking") - local config_mod = require("basilisk.config") - - describe("setup", function() - it("does nothing for wholeModule mode", function() - local config = config_mod.resolve({ analysis_mode = "wholeModule" }) - assert.has_no.errors(function() - tab_tracking.setup(config) - end) - end) - - it("does nothing for crossModule mode", function() - local config = config_mod.resolve({ analysis_mode = "crossModule" }) - assert.has_no.errors(function() - tab_tracking.setup(config) - end) - end) - - it("sets up autocmds for openFilesOnly mode", function() - local config = config_mod.resolve({ analysis_mode = "openFilesOnly" }) - assert.has_no.errors(function() - tab_tracking.setup(config) - end) - -- Verify the augroup was created. - local groups = vim.api.nvim_get_autocmds({ group = "BasiliskTabTracking" }) - assert.is_true(#groups > 0, "should create autocmds for tab tracking") - end) - end) -end) diff --git a/basilisk.nvim/tests/basilisk/type_health_spec.lua b/basilisk.nvim/tests/basilisk/type_health_spec.lua deleted file mode 100644 index 6ce8c9459..000000000 --- a/basilisk.nvim/tests/basilisk/type_health_spec.lua +++ /dev/null @@ -1,127 +0,0 @@ ---- Tests for basilisk.type_health — type health panel. - -describe("basilisk.type_health", function() - local type_health = require("basilisk.type_health") - - after_each(function() - type_health.close() - end) - - describe("open", function() - it("creates a split window", function() - local before = #vim.api.nvim_tabpage_list_wins(0) - type_health.open() - local after = #vim.api.nvim_tabpage_list_wins(0) - assert.is_true(after > before, "should create a new window") - end) - - it("creates a buffer with basilisk-health filetype", function() - type_health.open() - local found = false - for _, buf in ipairs(vim.api.nvim_list_bufs()) do - if vim.api.nvim_buf_is_loaded(buf) and vim.bo[buf].filetype == "basilisk-health" then - found = true - break - end - end - assert.is_true(found, "should create buffer with basilisk-health filetype") - end) - - it("buffer is not modifiable", function() - type_health.open() - for _, buf in ipairs(vim.api.nvim_list_bufs()) do - if vim.api.nvim_buf_is_loaded(buf) and vim.bo[buf].filetype == "basilisk-health" then - assert.is_false(vim.bo[buf].modifiable) - break - end - end - end) - - it("disables line numbers", function() - type_health.open() - local win = vim.api.nvim_get_current_win() - assert.is_false(vim.wo[win].number) - assert.is_false(vim.wo[win].relativenumber) - end) - - it("re-open focuses existing window", function() - type_health.open() - local count = #vim.api.nvim_tabpage_list_wins(0) - vim.cmd("wincmd p") - type_health.open() - assert.are.equal(count, #vim.api.nvim_tabpage_list_wins(0)) - end) - - it("renders header with coverage info", function() - type_health.open() - vim.wait(200) - for _, buf in ipairs(vim.api.nvim_list_bufs()) do - if vim.api.nvim_buf_is_loaded(buf) and vim.bo[buf].filetype == "basilisk-health" then - local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) - local has_header = false - for _, line in ipairs(lines) do - if line:find("Type Health") then - has_header = true - break - end - end - assert.is_true(has_header, "should show Type Health header") - break - end - end - end) - end) - - describe("close", function() - it("removes the panel window", function() - type_health.open() - local before = #vim.api.nvim_tabpage_list_wins(0) - type_health.close() - assert.is_true(#vim.api.nvim_tabpage_list_wins(0) < before) - end) - - it("double close does not error", function() - type_health.open() - type_health.close() - assert.has_no.errors(function() - type_health.close() - end) - end) - - it("close without open does not error", function() - assert.has_no.errors(function() - type_health.close() - end) - end) - end) - - describe("toggle", function() - it("opens when closed", function() - local before = #vim.api.nvim_tabpage_list_wins(0) - type_health.toggle() - assert.is_true(#vim.api.nvim_tabpage_list_wins(0) > before) - end) - - it("closes when open", function() - local before = #vim.api.nvim_tabpage_list_wins(0) - type_health.toggle() - type_health.toggle() - assert.are.equal(before, #vim.api.nvim_tabpage_list_wins(0)) - end) - end) - - describe("refresh", function() - it("does not error when panel is not open", function() - assert.has_no.errors(function() - type_health.refresh() - end) - end) - - it("does not error when panel is open", function() - type_health.open() - assert.has_no.errors(function() - type_health.refresh() - end) - end) - end) -end) diff --git a/basilisk.nvim/tests/basilisk/update_spec.lua b/basilisk.nvim/tests/basilisk/update_spec.lua deleted file mode 100644 index 5d1a9fb4d..000000000 --- a/basilisk.nvim/tests/basilisk/update_spec.lua +++ /dev/null @@ -1,298 +0,0 @@ ---- Tests for basilisk.update — :BasiliskUpdate / :BasiliskInstall flows. ---- ---- Covers [NVIM-BINARY-UPGRADE]: happy-path update, confirmation gate, ---- already-latest no-op, dev-build and package-manager refusals, install ---- bootstrap, and network-failure handling. All network and LSP calls are ---- stubbed — no test here touches GitHub or a real server. - -describe("basilisk.update", function() - local binary = require("basilisk.binary") - local lsp = require("basilisk.lsp") - local update = require("basilisk.update") - - --- Saved originals for everything a test may stub. - local orig = {} - --- Notifications captured during a test. - local notifications - --- lsp.restart invocations captured during a test. - local restarts - - before_each(function() - orig.locate = binary.locate - orig.version = binary.version - orig.fetch_latest_release = binary.fetch_latest_release - orig.download = binary.download - orig.restart = lsp.restart - orig.notify = vim.notify - orig.select = vim.ui.select - - notifications = {} - vim.notify = function(msg, level) - notifications[#notifications + 1] = { msg = msg, level = level } - end - - restarts = {} - ---@diagnostic disable-next-line: duplicate-set-field - lsp.restart = function(cfg, force) - restarts[#restarts + 1] = { cfg = cfg, force = force } - end - - -- Default: accept the first option ("Update now"/"Install now"). - ---@diagnostic disable-next-line: duplicate-set-field - vim.ui.select = function(items, _opts, on_choice) - on_choice(items[1]) - end - end) - - after_each(function() - binary.locate = orig.locate - binary.version = orig.version - binary.fetch_latest_release = orig.fetch_latest_release - binary.download = orig.download - lsp.restart = orig.restart - vim.notify = orig.notify - vim.ui.select = orig.select - end) - - --- Find a captured notification containing `needle`. - local function notified(needle) - for _, notif in ipairs(notifications) do - if notif.msg:find(needle, 1, true) then - return notif - end - end - return nil - end - - -- ── update: happy path ─────────────────────────────────────────────────── - - describe("update", function() - it("downloads, rewires binary_path, and restarts the LSP", function() - binary.locate = function() - return "/some/manual/place/basilisk" - end - binary.version = function() - return "basilisk 0.1.0" - end - binary.fetch_latest_release = function() - return { tag_name = "v99.0.0", assets = {} } - end - local downloaded = false - binary.download = function() - downloaded = true - return "/tmp/fake-cache/v99.0.0/basilisk", "v99.0.0" - end - - local config = { binary_path = nil } - update.update(config) - - assert.is_true(downloaded, "should call binary.download()") - assert.are.equal("/tmp/fake-cache/v99.0.0/basilisk", config.binary_path) - assert.are.equal(1, #restarts, "should restart the LSP once") - assert.is_true(restarts[1].force, "restart must bypass the backoff limit") - assert.is_truthy(notified("v99.0.0"), "should announce the installed version") - end) - - it("does nothing when the user picks Later", function() - binary.locate = function() - return "/some/manual/place/basilisk" - end - binary.version = function() - return "basilisk 0.1.0" - end - binary.fetch_latest_release = function() - return { tag_name = "v99.0.0", assets = {} } - end - local downloaded = false - binary.download = function() - downloaded = true - return "/tmp/x/basilisk", "v99.0.0" - end - ---@diagnostic disable-next-line: duplicate-set-field - vim.ui.select = function(_items, _opts, on_choice) - on_choice(nil) -- user dismissed the prompt - end - - update.update({ binary_path = nil }) - - assert.is_false(downloaded, "declining must not download") - assert.are.equal(0, #restarts) - end) - - it("is a no-op when already on the latest version", function() - binary.locate = function() - return "/some/manual/place/basilisk" - end - binary.version = function() - return "basilisk 99.99.99" - end - binary.fetch_latest_release = function() - return { tag_name = "v0.1.0", assets = {} } - end - local downloaded = false - binary.download = function() - downloaded = true - return nil, nil - end - - update.update({ binary_path = nil }) - - assert.is_false(downloaded) - assert.are.equal(0, #restarts) - assert.is_truthy(notified("up to date"), "should say it is already up to date") - end) - - it("refuses to clobber a Homebrew install", function() - binary.locate = function() - return "/opt/homebrew/bin/basilisk" - end - local downloaded = false - binary.download = function() - downloaded = true - return nil, nil - end - - update.update({ binary_path = nil }) - - assert.is_false(downloaded) - assert.is_truthy(notified("brew upgrade basilisk"), "should point at brew") - end) - - it("refuses to clobber a cargo install", function() - binary.locate = function() - return vim.fs.normalize("~/.cargo/bin/basilisk") - end - local downloaded = false - binary.download = function() - downloaded = true - return nil, nil - end - - update.update({ binary_path = nil }) - - assert.is_false(downloaded) - assert.is_truthy( - notified("cargo install --git https://github.com/Nimblesite/Basilisk basilisk-cli"), - "should point at the cargo command that actually works — the bare " - .. "`cargo install basilisk-cli` is unpublished (issue #370)" - ) - end) - - it("refuses to clobber a local dev build", function() - local tmpfile = vim.fn.tempname() - local fh = io.open(tmpfile, "w") - fh:write("#!/bin/sh\necho 'basilisk 0.0.0-PLACEHOLDER'\n") - fh:close() - vim.fn.setfperm(tmpfile, "rwxr-xr-x") - binary.locate = function() - return tmpfile - end - local downloaded = false - binary.download = function() - downloaded = true - return nil, nil - end - - update.update({ binary_path = nil }) - vim.fn.delete(tmpfile) - - assert.is_false(downloaded) - assert.is_truthy(notified("dev build"), "should explain it is a dev build") - end) - - it("reports an error when GitHub is unreachable", function() - binary.locate = function() - return "/some/manual/place/basilisk" - end - binary.version = function() - return "basilisk 0.1.0" - end - binary.fetch_latest_release = function() - return nil - end - - update.update({ binary_path = nil }) - - assert.are.equal(0, #restarts) - local err = notified("latest release") - assert.is_truthy(err, "should report the fetch failure") - end) - - it("falls back to the install flow when nothing is installed", function() - binary.locate = function() - return nil - end - binary.fetch_latest_release = function() - return { tag_name = "v99.0.0", assets = {} } - end - local downloaded = false - binary.download = function() - downloaded = true - return "/tmp/fake-cache/v99.0.0/basilisk", "v99.0.0" - end - - update.update({ binary_path = nil }) - - assert.is_true(downloaded, "update with no install should bootstrap one") - assert.are.equal(1, #restarts) - end) - end) - - -- ── install ────────────────────────────────────────────────────────────── - - describe("install", function() - it("points at :BasiliskUpdate when a binary already exists", function() - binary.locate = function() - return "/some/manual/place/basilisk" - end - binary.version = function() - return "basilisk 0.1.0" - end - local downloaded = false - binary.download = function() - downloaded = true - return nil, nil - end - - update.install({ binary_path = nil }) - - assert.is_false(downloaded) - assert.is_truthy(notified(":BasiliskUpdate"), "should mention :BasiliskUpdate") - end) - - it("downloads and restarts when nothing is installed", function() - binary.locate = function() - return nil - end - binary.fetch_latest_release = function() - return { tag_name = "v99.0.0", assets = {} } - end - binary.download = function() - return "/tmp/fake-cache/v99.0.0/basilisk", "v99.0.0" - end - - local config = { binary_path = nil } - update.install(config) - - assert.are.equal("/tmp/fake-cache/v99.0.0/basilisk", config.binary_path) - assert.are.equal(1, #restarts) - end) - - it("reports a download failure instead of restarting", function() - binary.locate = function() - return nil - end - binary.fetch_latest_release = function() - return { tag_name = "v99.0.0", assets = {} } - end - binary.download = function() - return nil, nil - end - - update.install({ binary_path = nil }) - - assert.are.equal(0, #restarts) - assert.is_truthy(notified("download failed"), "should report the failure") - end) - end) -end) diff --git a/basilisk.nvim/tests/basilisk/withdrawal_spec.lua b/basilisk.nvim/tests/basilisk/withdrawal_spec.lua new file mode 100644 index 000000000..2c18dc4b8 --- /dev/null +++ b/basilisk.nvim/tests/basilisk/withdrawal_spec.lua @@ -0,0 +1,59 @@ +-- Tests for [WITHDRAWAL-SURFACES]. The plugin's whole contract: it states the +-- approved message and registers nothing. + +local basilisk = require("basilisk") +local health = require("basilisk.health") +local notice = require("basilisk.notice") + +describe("basilisk.nvim is a notice", function() + it("carries the approved statement", function() + assert.truthy(notice.text:find("Basilisk is unlisted%.")) + assert.truthy(notice.text:find("checks nothing", 1, true)) + assert.truthy(notice.text:find("python/typing/pull/2330", 1, true)) + assert.truthy(notice.text:find("basilisk%-conformance%-apology")) + assert.equals(notice.text, basilisk.notice()) + end) + + it("announces as a warning", function() + local seen = {} + basilisk.announce(function(message, level, opts) + seen = { message = message, level = level, opts = opts } + end) + assert.equals(notice.text, seen.message) + assert.equals(vim.log.levels.WARN, seen.level) + assert.equals("Basilisk is unlisted", seen.opts.title) + end) + + it("accepts a legacy setup call without configuring anything", function() + local announced = 0 + basilisk.setup({ cmd = { "basilisk", "lsp" } }, function() + announced = announced + 1 + end) + assert.equals(1, announced) + end) + + it("reports the withdrawal in checkhealth", function() + local started, warned = nil, nil + health.check({ + start = function(name) + started = name + end, + warn = function(message, advice) + warned = { message = message, advice = advice } + end, + }) + assert.equals("basilisk.nvim", started) + assert.truthy(warned.message:find("inert", 1, true)) + assert.equals(notice.lines[1], warned.advice[1]) + end) + + it("registers no LSP client, command or debug adapter", function() + assert.equals(0, #vim.lsp.get_clients()) + for _, name in ipairs({ "Basilisk", "BasiliskCheck", "BasiliskRestart", "BasiliskInfo" }) do + assert.is_nil(vim.fn.exists(":" .. name) == 2 or nil) + end + for _, module in ipairs({ "basilisk.lsp", "basilisk.dap", "basilisk.commands", "basilisk.binary" }) do + assert.is_false(pcall(require, module)) + end + end) +end) diff --git a/basilisk.nvim/tests/command_desc.lua b/basilisk.nvim/tests/command_desc.lua deleted file mode 100644 index e64da5247..000000000 --- a/basilisk.nvim/tests/command_desc.lua +++ /dev/null @@ -1,43 +0,0 @@ ---- 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/basilisk.nvim/tests/dap/debug_spec.lua b/basilisk.nvim/tests/dap/debug_spec.lua deleted file mode 100644 index fabaa9895..000000000 --- a/basilisk.nvim/tests/dap/debug_spec.lua +++ /dev/null @@ -1,410 +0,0 @@ ---- DAP integration E2E tests for basilisk.nvim. ---- ---- Tests [NVIM-DAP-INTEGRATION], [NVIM-DAP-INTEGRATION-ADAPTER-REGISTRATION], ---- [NVIM-DAP-INTEGRATION-DAP-TCP-PROXY], [NVIM-DAP-INTEGRATION-DEFAULT-CONFIGURATIONS]. ---- ---- These tests exercise REAL debug sessions by: ---- 1. Starting the basilisk LSP server ---- 2. Using nvim-dap to launch debugpy via the LSP ---- 3. Setting breakpoints, stepping, and asserting variable values ---- ---- Prerequisites: ---- - basilisk binary (cargo build -p basilisk-cli) ---- - Python 3 + debugpy (pip install debugpy) ---- - nvim-dap on the runtimepath - -local lsp_helpers = require("tests.lsp.helpers") -local dap_helpers = require("tests.dap.helpers") - --- Skip the entire suite if prerequisites are missing. -local binary = lsp_helpers.find_binary() -if not binary then - describe("basilisk DAP integration (SKIPPED — no binary)", function() - it("skipped: basilisk binary not found", function() - pending("basilisk binary not found — build with `cargo build --bin basilisk`") - end) - end) - return -end - -local dap_ok, dap = pcall(require, "dap") -if not dap_ok then - describe("basilisk DAP integration (SKIPPED — no nvim-dap)", function() - it("skipped: nvim-dap not found", function() - pending("nvim-dap not found — install mfussenegger/nvim-dap") - end) - end) - return -end - -if not dap_helpers.is_debugpy_installed() then - describe("basilisk DAP integration (SKIPPED — no debugpy)", function() - it("skipped: debugpy not installed", function() - pending("debugpy not installed — run `pip install debugpy`") - end) - end) - return -end - -local fixture_path = dap_helpers.fixture_path() -if not fixture_path then - describe("basilisk DAP integration (SKIPPED — no fixture)", function() - it("skipped: debug_stepping.py not found", function() - pending("fixture not found — check vscode-extension/src/test/fixtures/debug_stepping.py") - end) - end) - return -end - --- Suppress nvim-dap's interactive prompts in headless mode. --- When nvim-dap calls vim.ui.select (e.g., for thread selection), auto-pick --- the first option to avoid blocking. -vim.ui.select = function(items, opts, on_choice) - on_choice(items[1], 1) -end - --- ── Test Suite ───────────────────────────────────────────────────────────── - -local tmpdir - -describe("basilisk DAP integration", function() - before_each(function() - tmpdir = lsp_helpers.create_tmpdir() - - -- Write a pyproject.toml so basilisk finds a project root. - local fh = io.open(tmpdir .. "/pyproject.toml", "w") - assert(fh, "failed to create pyproject.toml") - fh:write('[project]\nname = "test"\nversion = "0.1.0"\n') - fh:close() - - -- Copy the fixture into the temp directory. - local src = io.open(fixture_path, "r") - assert(src, "failed to read fixture") - local content = src:read("*a") - src:close() - - local dst = io.open(tmpdir .. "/debug_stepping.py", "w") - assert(dst, "failed to write fixture") - dst:write(content) - dst:close() - - -- Configure and start the LSP. - vim.lsp.config("basilisk", { - cmd = { binary, "lsp" }, - filetypes = { "python" }, - root_markers = { "pyproject.toml", ".git" }, - }) - vim.lsp.enable("basilisk") - - -- Set up DAP adapter via the basilisk module. - local basilisk_dap = require("basilisk.dap") - basilisk_dap.setup({ - debugger = { enabled = true }, - python = "python3", - }) - end) - - after_each(function() - dap_helpers.cleanup_session() - lsp_helpers.stop_clients() - lsp_helpers.close_all_buffers() - - -- Clear breakpoints. - dap.clear_breakpoints() - - lsp_helpers.cleanup_tmpdir(tmpdir) - end) - - -- ── Session lifecycle ─────────────────────────────────────────────── - - it("starts and stops a debug session", function() - local filepath = tmpdir .. "/debug_stepping.py" - vim.cmd("edit " .. vim.fn.fnameescape(filepath)) - local buf = vim.api.nvim_get_current_buf() - local ready = lsp_helpers.wait_for_server_ready(buf) - assert.is_true(ready, "LSP server did not become ready") - - -- Launch via nvim-dap. - dap.run({ - type = "basilisk", - request = "launch", - name = "Test: debug_stepping.py", - program = tmpdir .. "/debug_stepping.py", - justMyCode = true, - }) - - -- Wait for the session to start. - local session_active = dap_helpers.wait_for_session() - assert.is_true(session_active, "debug session did not start") - - -- Terminate. - dap.terminate() - local terminated = dap_helpers.wait_for_terminated() - assert.is_true(terminated, "debug session did not terminate") - end) - - -- ── Breakpoint hitting ────────────────────────────────────────────── - - it("hits a breakpoint and stops", function() - local filepath = tmpdir .. "/debug_stepping.py" - vim.cmd("edit " .. vim.fn.fnameescape(filepath)) - local buf = vim.api.nvim_get_current_buf() - lsp_helpers.wait_for_server_ready(buf) - - -- Set breakpoint on line 15 (result = w - 5 in arithmetic()). - -- Using a line deep inside the function avoids module-level stops. - vim.api.nvim_win_set_cursor(0, { 15, 0 }) - dap.toggle_breakpoint() - - dap.run({ - type = "basilisk", - request = "launch", - name = "Test: breakpoint", - program = filepath, - justMyCode = true, - }) - - local stopped = dap_helpers.wait_for_stopped() - assert.is_true(stopped, "did not stop at breakpoint") - - -- Verify we stopped in the right function. - local frames = dap_helpers.get_stack_trace() - assert.is_true(#frames > 0, "no stack frames") - assert.are.equal("arithmetic", frames[1].name) - - -- Verify the variables are set at this point. - local vars = dap_helpers.get_local_variables() - assert.are.equal("60", vars["w"]) - end) - - -- ── Stepping and variable inspection ──────────────────────────────── - - it("steps through arithmetic and inspects variables", function() - local filepath = tmpdir .. "/debug_stepping.py" - vim.cmd("edit " .. vim.fn.fnameescape(filepath)) - local buf = vim.api.nvim_get_current_buf() - lsp_helpers.wait_for_server_ready(buf) - - -- Breakpoint on line 11 (x = 10). - vim.api.nvim_win_set_cursor(0, { 11, 0 }) - dap.toggle_breakpoint() - - dap.run({ - type = "basilisk", - request = "launch", - name = "Test: stepping", - program = filepath, - justMyCode = true, - }) - - local stopped = dap_helpers.wait_for_stopped() - assert.is_true(stopped, "did not stop at breakpoint") - - -- Step to x = 10 (line 11 → 12). - dap_helpers.step_and_wait("next") - local vars = dap_helpers.get_local_variables() - assert.are.equal("10", vars["x"]) - - -- Step to y = 20 (line 12 → 13). - dap_helpers.step_and_wait("next") - vars = dap_helpers.get_local_variables() - assert.are.equal("20", vars["y"]) - - -- Step to z = x + y (line 13 → 14). - dap_helpers.step_and_wait("next") - vars = dap_helpers.get_local_variables() - assert.are.equal("30", vars["z"]) - - -- Step to w = z * 2 (line 14 → 15). - dap_helpers.step_and_wait("next") - vars = dap_helpers.get_local_variables() - assert.are.equal("60", vars["w"]) - - -- Step to result = w - 5 (line 15 → 16). - dap_helpers.step_and_wait("next") - vars = dap_helpers.get_local_variables() - assert.are.equal("55", vars["result"]) - end) - - -- ── Multiple breakpoints + continue ───────────────────────────────── - - it("continues between multiple breakpoints", function() - local filepath = tmpdir .. "/debug_stepping.py" - vim.cmd("edit " .. vim.fn.fnameescape(filepath)) - local buf = vim.api.nvim_get_current_buf() - lsp_helpers.wait_for_server_ready(buf) - - -- Breakpoint on arithmetic line 11 and string_ops line 21. - vim.api.nvim_win_set_cursor(0, { 11, 0 }) - dap.toggle_breakpoint() - vim.api.nvim_win_set_cursor(0, { 21, 0 }) - dap.toggle_breakpoint() - - dap.run({ - type = "basilisk", - request = "launch", - name = "Test: multiple breakpoints", - program = filepath, - justMyCode = true, - }) - - -- First breakpoint: arithmetic(). - local stopped = dap_helpers.wait_for_stopped() - assert.is_true(stopped, "did not stop at first breakpoint") - local frames = dap_helpers.get_stack_trace() - assert.are.equal("arithmetic", frames[1].name) - - -- Continue to second breakpoint: string_ops(). - stopped = dap_helpers.continue_and_wait() - assert.is_true(stopped, "did not stop at second breakpoint") - frames = dap_helpers.get_stack_trace() - assert.are.equal("string_ops", frames[1].name) - end) - - -- ── String operations ─────────────────────────────────────────────── - - it("inspects string variables", function() - local filepath = tmpdir .. "/debug_stepping.py" - vim.cmd("edit " .. vim.fn.fnameescape(filepath)) - local buf = vim.api.nvim_get_current_buf() - lsp_helpers.wait_for_server_ready(buf) - - -- Breakpoint on line 21 (greeting = "hello"). - vim.api.nvim_win_set_cursor(0, { 21, 0 }) - dap.toggle_breakpoint() - - dap.run({ - type = "basilisk", - request = "launch", - name = "Test: strings", - program = filepath, - justMyCode = true, - }) - - dap_helpers.wait_for_stopped() - - -- Step past greeting, name, message. - dap_helpers.step_and_wait("next") -- greeting = "hello" - dap_helpers.step_and_wait("next") -- name = "world" - dap_helpers.step_and_wait("next") -- message = ... - - local vars = dap_helpers.get_local_variables() - assert.are.equal("'hello'", vars["greeting"]) - assert.are.equal("'world'", vars["name"]) - assert.are.equal("'hello world'", vars["message"]) - end) - - -- ── Exception handling ────────────────────────────────────────────── - - it("inspects variables after exception", function() - local filepath = tmpdir .. "/debug_stepping.py" - vim.cmd("edit " .. vim.fn.fnameescape(filepath)) - local buf = vim.api.nvim_get_current_buf() - lsp_helpers.wait_for_server_ready(buf) - - -- Breakpoint on line 93 (return caught). - vim.api.nvim_win_set_cursor(0, { 93, 0 }) - dap.toggle_breakpoint() - - dap.run({ - type = "basilisk", - request = "launch", - name = "Test: exception", - program = filepath, - justMyCode = true, - }) - - local stopped = dap_helpers.wait_for_stopped() - assert.is_true(stopped, "did not stop at breakpoint") - - local vars = dap_helpers.get_local_variables() - assert.are.equal("True", vars["caught"]) - end) - - -- ── Stack trace ───────────────────────────────────────────────────── - - it("shows correct stack trace in nested calls", function() - local filepath = tmpdir .. "/debug_stepping.py" - vim.cmd("edit " .. vim.fn.fnameescape(filepath)) - local buf = vim.api.nvim_get_current_buf() - lsp_helpers.wait_for_server_ready(buf) - - -- Breakpoint inside double() on line 59. - vim.api.nvim_win_set_cursor(0, { 59, 0 }) - dap.toggle_breakpoint() - - dap.run({ - type = "basilisk", - request = "launch", - name = "Test: stack trace", - program = filepath, - justMyCode = true, - }) - - local stopped = dap_helpers.wait_for_stopped() - assert.is_true(stopped, "did not stop at breakpoint") - - local frames = dap_helpers.get_stack_trace() - assert.is_true(#frames >= 2, "expected at least 2 stack frames") - assert.are.equal("double", frames[1].name) - end) - - -- ── Evaluate expressions ──────────────────────────────────────────── - - it("evaluates expressions in debug console", function() - local filepath = tmpdir .. "/debug_stepping.py" - vim.cmd("edit " .. vim.fn.fnameescape(filepath)) - local buf = vim.api.nvim_get_current_buf() - lsp_helpers.wait_for_server_ready(buf) - - -- Breakpoint on line 15 (result = w - 5) — x, y, z, w are all set. - vim.api.nvim_win_set_cursor(0, { 15, 0 }) - dap.toggle_breakpoint() - - dap.run({ - type = "basilisk", - request = "launch", - name = "Test: evaluate", - program = filepath, - justMyCode = true, - }) - - dap_helpers.wait_for_stopped() - - -- Evaluate arithmetic expressions. - local result = dap_helpers.evaluate("x + y") - assert.are.equal("30", result) - - result = dap_helpers.evaluate("z * 2") - assert.are.equal("60", result) - - -- Evaluate a type check. - result = dap_helpers.evaluate("type(x).__name__") - assert.are.equal("'int'", result) - end) - - -- ── Clean termination ─────────────────────────────────────────────── - - it("terminates cleanly after program completes", function() - local filepath = tmpdir .. "/debug_stepping.py" - vim.cmd("edit " .. vim.fn.fnameescape(filepath)) - local buf = vim.api.nvim_get_current_buf() - lsp_helpers.wait_for_server_ready(buf) - - -- No breakpoints — let the program run to completion. - dap.run({ - type = "basilisk", - request = "launch", - name = "Test: clean termination", - program = filepath, - justMyCode = true, - }) - - local session_active = dap_helpers.wait_for_session() - assert.is_true(session_active, "debug session did not start") - - -- Wait for the session to terminate naturally. - local terminated = dap_helpers.wait_for_terminated() - assert.is_true(terminated, "debug session did not terminate after program completed") - end) -end) diff --git a/basilisk.nvim/tests/dap/debug_stepping_spec.lua b/basilisk.nvim/tests/dap/debug_stepping_spec.lua deleted file mode 100644 index fdefac85c..000000000 --- a/basilisk.nvim/tests/dap/debug_stepping_spec.lua +++ /dev/null @@ -1,329 +0,0 @@ ---- DAP stepping E2E tests — per-function fixture coverage. ---- ---- Matches VS Code debug-integration.test.ts tests 6-13: ---- list_ops, dict_ops, nested_call (step into/out), loop_and_accumulate, ---- conditional_branches, type_variety, class_instance, scopes enumeration. - -local lsp_helpers = require("tests.lsp.helpers") -local dap_helpers = require("tests.dap.helpers") - -local binary = lsp_helpers.find_binary() -if not binary then - describe("DAP stepping (SKIPPED — no binary)", function() - it("skipped", function() - pending("basilisk binary not found") - end) - end) - return -end - -local dap_ok, dap = pcall(require, "dap") -if not dap_ok or not dap_helpers.is_debugpy_installed() or not dap_helpers.fixture_path() then - describe("DAP stepping (SKIPPED — missing deps)", function() - it("skipped", function() - pending("nvim-dap, debugpy, or fixture missing") - end) - end) - return -end - -vim.ui.select = function(items, _, on_choice) - on_choice(items[1], 1) -end - -local tmpdir - ---- Launch fixture, set breakpoint, wait for stop. ----@param line integer ----@return boolean stopped -local function launch_at(line) - local filepath = tmpdir .. "/debug_stepping.py" - vim.cmd("edit " .. vim.fn.fnameescape(filepath)) - local buf = vim.api.nvim_get_current_buf() - lsp_helpers.wait_for_server_ready(buf) - vim.api.nvim_win_set_cursor(0, { line, 0 }) - dap.toggle_breakpoint() - dap.run({ - type = "basilisk", - request = "launch", - name = "Test", - program = filepath, - justMyCode = true, - }) - return dap_helpers.wait_for_stopped() -end - -describe("DAP stepping", function() - before_each(function() - tmpdir = lsp_helpers.create_tmpdir() - local fh = io.open(tmpdir .. "/pyproject.toml", "w") - assert(fh) - fh:write('[project]\nname = "test"\nversion = "0.1.0"\n') - fh:close() - local src = io.open(dap_helpers.fixture_path(), "r") - assert(src) - local content = src:read("*a") - src:close() - local dst = io.open(tmpdir .. "/debug_stepping.py", "w") - assert(dst) - dst:write(content) - dst:close() - vim.lsp.config("basilisk", { - cmd = { binary, "lsp" }, - filetypes = { "python" }, - root_markers = { "pyproject.toml", ".git" }, - }) - vim.lsp.enable("basilisk") - require("basilisk.dap").setup({ debugger = { enabled = true }, python = "python3" }) - end) - - after_each(function() - dap_helpers.cleanup_session() - lsp_helpers.stop_clients() - lsp_helpers.close_all_buffers() - dap.clear_breakpoints() - lsp_helpers.cleanup_tmpdir(tmpdir) - end) - - -- ── list_ops: step through and assert list contents ───────────────── - - it("list_ops: step through list mutations", function() - assert.is_true(launch_at(31)) - - -- Step: items = [1, 2, 3] - dap_helpers.step_and_wait("next") - local vars = dap_helpers.get_local_variables() - assert.is_not_nil(vars["items"]) - - -- Step: items.append(4) - dap_helpers.step_and_wait("next") - local result = dap_helpers.evaluate("len(items)") - assert.are.equal("4", result) - - -- Step: items.insert(0, 0) - dap_helpers.step_and_wait("next") - result = dap_helpers.evaluate("items[0]") - assert.are.equal("0", result) - - -- Step: total = sum(items) - dap_helpers.step_and_wait("next") - vars = dap_helpers.get_local_variables() - assert.are.equal("10", vars["total"]) - - -- Step: count = len(items) - dap_helpers.step_and_wait("next") - vars = dap_helpers.get_local_variables() - assert.are.equal("5", vars["count"]) - end) - - -- ── dict_ops: step through and assert dict contents ───────────────── - - it("dict_ops: step through dictionary operations", function() - assert.is_true(launch_at(41)) - - -- Step: data = {"a": 1, "b": 2} - dap_helpers.step_and_wait("next") - assert.are.equal("2", dap_helpers.evaluate("len(data)")) - assert.are.equal("1", dap_helpers.evaluate('data["a"]')) - - -- Step: data["c"] = 3 - dap_helpers.step_and_wait("next") - assert.are.equal("3", dap_helpers.evaluate("len(data)")) - assert.are.equal("3", dap_helpers.evaluate('data["c"]')) - - -- Step: keys = list(data.keys()) - dap_helpers.step_and_wait("next") - assert.are.equal("3", dap_helpers.evaluate("len(keys)")) - - -- Step: total = sum(data.values()) - dap_helpers.step_and_wait("next") - local vars = dap_helpers.get_local_variables() - assert.are.equal("6", vars["total"]) - - -- Step: has_a = "a" in data - dap_helpers.step_and_wait("next") - vars = dap_helpers.get_local_variables() - assert.are.equal("True", vars["has_a"]) - end) - - -- ── nested_call: step into/out ────────────────────────────────────── - - it("nested_call: step into function and back out", function() - assert.is_true(launch_at(51)) - - -- Step: a = 5 - dap_helpers.step_and_wait("next") - local vars = dap_helpers.get_local_variables() - assert.are.equal("5", vars["a"]) - - -- Step INTO: b = double(a) → enter double() - dap_helpers.step_and_wait("stepIn") - local frames = dap_helpers.get_stack_trace() - assert.are.equal("double", frames[1].name) - vars = dap_helpers.get_local_variables() - assert.are.equal("5", vars["n"]) - - -- Step over inside double: result = n * 2 - dap_helpers.step_and_wait("next") - vars = dap_helpers.get_local_variables() - assert.are.equal("10", vars["result"]) - - -- Step OUT back to nested_call - dap_helpers.step_and_wait("stepOut") - frames = dap_helpers.get_stack_trace() - assert.are.equal("nested_call", frames[1].name) - -- After stepOut, we land on the line where b = double(a) completes. - -- The variable may need one more step to be assigned. - dap_helpers.step_and_wait("next") - vars = dap_helpers.get_local_variables() - assert.are.equal("10", vars["b"]) - end) - - -- ── loop_and_accumulate: verify accumulator ───────────────────────── - - it("loop_and_accumulate: verify accumulator at iterations", function() - assert.is_true(launch_at(65)) - - -- Step: total = 0 - dap_helpers.step_and_wait("next") - assert.are.equal("0", dap_helpers.evaluate("total")) - - -- Step into for loop header - dap_helpers.step_and_wait("next") - - -- i=0: total += 0 → 0 - dap_helpers.step_and_wait("next") - assert.are.equal("0", dap_helpers.evaluate("total")) - - -- i=1: for header + body → total = 1 - dap_helpers.step_and_wait("next") - dap_helpers.step_and_wait("next") - assert.are.equal("1", dap_helpers.evaluate("total")) - - -- i=2: → total = 3 - dap_helpers.step_and_wait("next") - dap_helpers.step_and_wait("next") - assert.are.equal("3", dap_helpers.evaluate("total")) - - -- i=3: → total = 6 - dap_helpers.step_and_wait("next") - dap_helpers.step_and_wait("next") - assert.are.equal("6", dap_helpers.evaluate("total")) - - -- i=4: → total = 10 - dap_helpers.step_and_wait("next") - dap_helpers.step_and_wait("next") - assert.are.equal("10", dap_helpers.evaluate("total")) - end) - - -- ── conditional_branches: verify correct branch ───────────────────── - - it("conditional_branches: verifies elif branch taken", function() - -- Break at line 81 (return label) — after branch is resolved. - assert.is_true(launch_at(81)) - - local vars = dap_helpers.get_local_variables() - assert.are.equal("42", vars["x"]) - assert.are.equal("'medium'", vars["label"]) - assert.are.equal("True", dap_helpers.evaluate('label == "medium"')) - assert.are.equal("True", dap_helpers.evaluate('label != "big"')) - assert.are.equal("True", dap_helpers.evaluate('label != "small"')) - end) - - -- ── type_variety: verify Python type representations ──────────────── - - it("type_variety: verifies different Python types", function() - -- Break at line 105 (return an_int) — all vars set. - assert.is_true(launch_at(105)) - - local vars = dap_helpers.get_local_variables() - assert.are.equal("42", vars["an_int"]) - assert.are.equal("3.14", vars["a_float"]) - assert.are.equal("True", vars["a_bool"]) - assert.are.equal("None", vars["a_none"]) - - assert.are.equal("'int'", dap_helpers.evaluate("type(an_int).__name__")) - assert.are.equal("'float'", dap_helpers.evaluate("type(a_float).__name__")) - assert.are.equal("'bool'", dap_helpers.evaluate("type(a_bool).__name__")) - assert.are.equal("True", dap_helpers.evaluate("a_none is None")) - assert.are.equal("3", dap_helpers.evaluate("len(a_tuple)")) - assert.are.equal("3", dap_helpers.evaluate("len(a_set)")) - assert.are.equal("'bytes'", dap_helpers.evaluate("type(a_bytes).__name__")) - end) - - -- ── class_instance: object attributes and method calls ────────────── - - it("class_instance: inspect object attributes and method result", function() - assert.is_true(launch_at(119)) - - -- Step: p = Point(3, 4) - dap_helpers.step_and_wait("next") - assert.are.equal("3", dap_helpers.evaluate("p.x")) - assert.are.equal("4", dap_helpers.evaluate("p.y")) - assert.are.equal("'Point'", dap_helpers.evaluate("type(p).__name__")) - - -- Step: mag = p.magnitude() - dap_helpers.step_and_wait("next") - local vars = dap_helpers.get_local_variables() - assert.are.equal("5.0", vars["mag"]) - assert.are.equal("True", dap_helpers.evaluate("mag == 5.0")) - assert.are.equal("25", dap_helpers.evaluate("p.x ** 2 + p.y ** 2")) - end) - - -- ── scopes: verify locals scope enumeration ───────────────────────── - - it("scopes: Locals scope has correct variables", function() - -- Break at line 13 (z = x + y) — x and y are set. - assert.is_true(launch_at(13)) - - local vars = dap_helpers.get_local_variables() - assert.are.equal("10", vars["x"]) - assert.are.equal("20", vars["y"]) - -- z is not yet set (we're AT line 13, not past it). - assert.is_nil(vars["z"]) - end) - - -- ── watch: complex expression evaluation ──────────────────────────── - - it("watch: evaluates complex expressions at breakpoint", function() - -- Stop at line 15 in arithmetic where x=10, y=20, z=30, w=60. - assert.is_true(launch_at(15)) - - -- Arithmetic - assert.are.equal("60", dap_helpers.evaluate("x + y + z")) - assert.are.equal("6", dap_helpers.evaluate("w // x")) - assert.are.equal("4", dap_helpers.evaluate("w % 7")) - assert.are.equal("60", dap_helpers.evaluate("abs(-w)")) - assert.are.equal("10", dap_helpers.evaluate("min(x, y, z, w)")) - assert.are.equal("60", dap_helpers.evaluate("max(x, y, z, w)")) - - -- Boolean - assert.are.equal("True", dap_helpers.evaluate("x < y")) - assert.are.equal("True", dap_helpers.evaluate("z == x + y")) - assert.are.equal("True", dap_helpers.evaluate("w == z * 2")) - - -- Type checking - assert.are.equal("True", dap_helpers.evaluate("isinstance(x, int)")) - assert.are.equal("False", dap_helpers.evaluate("isinstance(x, str)")) - - -- String formatting - assert.are.equal("'10 + 20 = 30'", dap_helpers.evaluate('f"{x} + {y} = {z}"')) - - -- List comprehension - assert.are.equal("[20, 40, 60]", dap_helpers.evaluate("[v * 2 for v in [x, y, z]]")) - end) - - -- ── REPL: debug console evaluation ────────────────────────────────── - - it("REPL: evaluates expressions in debug console context", function() - assert.is_true(launch_at(13)) - - assert.are.equal("30", dap_helpers.evaluate("x + y")) - assert.are.equal("[10, 20]", dap_helpers.evaluate("[x, y]")) - - local dict_result = dap_helpers.evaluate("dict(a=x, b=y)") - assert.is_not_nil(dict_result) - assert.is_truthy(dict_result:find("a")) - assert.is_truthy(dict_result:find("b")) - end) -end) diff --git a/basilisk.nvim/tests/dap/helpers.lua b/basilisk.nvim/tests/dap/helpers.lua deleted file mode 100644 index b7f6ac1cf..000000000 --- a/basilisk.nvim/tests/dap/helpers.lua +++ /dev/null @@ -1,307 +0,0 @@ ---- DAP integration test helpers for basilisk.nvim. ---- ---- Wraps nvim-dap API for E2E debug session testing. ---- Requires: basilisk binary, debugpy, nvim-dap, Python 3. - -local lsp_helpers = require("tests.lsp.helpers") - -local M = {} - ---- Timeout constants. -M.DEBUG_SESSION_TIMEOUT_MS = 15000 -M.STOPPED_EVENT_TIMEOUT_MS = 10000 - ---- Path to the shared debug stepping fixture. ----@return string? path Absolute path to debug_stepping.py, or nil. -function M.fixture_path() - -- Try multiple resolution strategies. - local candidates = { - -- From cwd (when running `make test-dap` from basilisk.nvim/). - vim.fn.fnamemodify(".", ":p") .. "../vscode-extension/src/test/fixtures/debug_stepping.py", - -- From BASILISK_REPO_ROOT env var (CI). - (vim.env.BASILISK_REPO_ROOT or "") .. "/vscode-extension/src/test/fixtures/debug_stepping.py", - -- Absolute fallback from this file. - vim.fn.fnamemodify(debug.getinfo(1, "S").source:sub(2), ":p:h:h:h:h") - .. "/vscode-extension/src/test/fixtures/debug_stepping.py", - } - for _, path in ipairs(candidates) do - local resolved = vim.fn.fnamemodify(path, ":p") - if vim.fn.filereadable(resolved) == 1 then - return resolved - end - end - return nil -end - ---- Check if debugpy is importable by the system Python. ----@return boolean -function M.is_debugpy_installed() - for _, python in ipairs({ "python3", "python" }) do - local ok = os.execute(python .. " -c 'import debugpy' 2>/dev/null") - if ok then - return true - end - end - return false -end - ---- Wait for a DAP session to enter 'stopped' state (breakpoint or step). ----@param timeout_ms? integer ----@return boolean stopped -function M.wait_for_stopped(timeout_ms) - timeout_ms = timeout_ms or M.STOPPED_EVENT_TIMEOUT_MS - return lsp_helpers.poll_until(function() - local session = require("dap").session() - return session ~= nil and session.stopped_thread_id ~= nil - end, timeout_ms, "DAP stopped event") -end - ---- Wait for a DAP session to become active (initialized). ----@param timeout_ms? integer ----@return boolean active -function M.wait_for_session(timeout_ms) - timeout_ms = timeout_ms or M.DEBUG_SESSION_TIMEOUT_MS - return lsp_helpers.poll_until(function() - return require("dap").session() ~= nil - end, timeout_ms, "DAP session") -end - ---- Wait for the DAP session to terminate. ----@param timeout_ms? integer ----@return boolean terminated -function M.wait_for_terminated(timeout_ms) - timeout_ms = timeout_ms or M.DEBUG_SESSION_TIMEOUT_MS - return lsp_helpers.poll_until(function() - return require("dap").session() == nil - end, timeout_ms, "DAP terminated") -end - ---- Get the variables in the current (topmost) frame's locals scope. ---- ---- Must be called while the session is stopped. ----@param timeout_ms? integer ----@return table variables Map of name → value (as string). -function M.get_local_variables(timeout_ms) - timeout_ms = timeout_ms or 5000 - local dap = require("dap") - local session = dap.session() - if not session then - return {} - end - - -- Get the topmost stack frame. - local frames = {} - local frames_done = false - session:request("stackTrace", { - threadId = session.stopped_thread_id, - startFrame = 0, - levels = 1, - }, function(err, response) - if not err and response and response.stackFrames then - frames = response.stackFrames - end - frames_done = true - end) - vim.wait(timeout_ms, function() - return frames_done - end) - - if #frames == 0 then - return {} - end - - -- Get scopes for the top frame. - local scopes = {} - local scopes_done = false - session:request("scopes", { - frameId = frames[1].id, - }, function(err, response) - if not err and response and response.scopes then - scopes = response.scopes - end - scopes_done = true - end) - vim.wait(timeout_ms, function() - return scopes_done - end) - - -- Find the "Locals" scope. - local locals_ref = nil - for _, scope in ipairs(scopes) do - if scope.name == "Locals" then - locals_ref = scope.variablesReference - break - end - end - if not locals_ref then - return {} - end - - -- Get variables. - local vars = {} - local vars_done = false - session:request("variables", { - variablesReference = locals_ref, - }, function(err, response) - if not err and response and response.variables then - for _, v in ipairs(response.variables) do - vars[v.name] = v.value - end - end - vars_done = true - end) - vim.wait(timeout_ms, function() - return vars_done - end) - - return vars -end - ---- Send a DAP step request and wait for the next stopped event. ----@param step_type "next"|"stepIn"|"stepOut" ----@param timeout_ms? integer ----@return boolean stopped -function M.step_and_wait(step_type, timeout_ms) - timeout_ms = timeout_ms or M.STOPPED_EVENT_TIMEOUT_MS - local dap = require("dap") - local session = dap.session() - if not session then - return false - end - - -- Clear stopped state. - session.stopped_thread_id = nil - - -- Issue the step command. - if step_type == "next" then - dap.step_over() - elseif step_type == "stepIn" then - dap.step_into() - elseif step_type == "stepOut" then - dap.step_out() - end - - return M.wait_for_stopped(timeout_ms) -end - ---- Continue execution and wait for the next stopped event (breakpoint). ----@param timeout_ms? integer ----@return boolean stopped -function M.continue_and_wait(timeout_ms) - timeout_ms = timeout_ms or M.STOPPED_EVENT_TIMEOUT_MS - local dap = require("dap") - local session = dap.session() - if not session then - return false - end - - session.stopped_thread_id = nil - dap.continue() - - return M.wait_for_stopped(timeout_ms) -end - ---- Evaluate an expression in the debug console. ----@param expression string ----@param timeout_ms? integer ----@return string? result The evaluated result as a string. -function M.evaluate(expression, timeout_ms) - timeout_ms = timeout_ms or 5000 - local dap = require("dap") - local session = dap.session() - if not session then - return nil - end - - -- Get the current frame ID for evaluation context. - local frame_id = nil - local frame_done = false - session:request("stackTrace", { - threadId = session.stopped_thread_id, - startFrame = 0, - levels = 1, - }, function(err, response) - if not err and response and response.stackFrames and #response.stackFrames > 0 then - frame_id = response.stackFrames[1].id - end - frame_done = true - end) - vim.wait(timeout_ms, function() - return frame_done - end) - - local result_str = nil - local done = false - session:request("evaluate", { - expression = expression, - frameId = frame_id, - context = "repl", - }, function(err, response) - if not err and response then - result_str = response.result - end - done = true - end) - vim.wait(timeout_ms, function() - return done - end) - - return result_str -end - ---- Get the current stack trace. ----@param timeout_ms? integer ----@return table[] frames List of stack frame objects. -function M.get_stack_trace(timeout_ms) - timeout_ms = timeout_ms or 5000 - local dap = require("dap") - local session = dap.session() - if not session then - return {} - end - - local frames = {} - local done = false - session:request("stackTrace", { - threadId = session.stopped_thread_id, - }, function(err, response) - if not err and response and response.stackFrames then - frames = response.stackFrames - end - done = true - end) - vim.wait(timeout_ms, function() - return done - end) - - return frames -end - ---- Clean up any active DAP session. -function M.cleanup_session() - local dap_ok, dap_mod = pcall(require, "dap") - if not dap_ok then - return - end - - local session = dap_mod.session() - if not session then - return - end - - -- Send disconnect directly to avoid interactive prompts in headless mode. - pcall(function() - session:disconnect({ terminateDebuggee = true }) - end) - M.wait_for_terminated(5000) - - -- Force-close if disconnect didn't work. - if dap_mod.session() then - pcall(function() - session:close() - end) - vim.wait(1000) - end -end - -return M diff --git a/basilisk.nvim/tests/dap/lsp_commands_spec.lua b/basilisk.nvim/tests/dap/lsp_commands_spec.lua deleted file mode 100644 index a10ad45a9..000000000 --- a/basilisk.nvim/tests/dap/lsp_commands_spec.lua +++ /dev/null @@ -1,283 +0,0 @@ ---- DAP LSP command tests — startDebugSession, stopDebugSession, error handling. ---- ---- Matches VS Code debug-integration.test.ts tests 1-5, 21-22: ---- LSP command validation, session lifecycle, invalid IDs, bad Python path, ---- multiple simultaneous sessions. - -local lsp_helpers = require("tests.lsp.helpers") -local dap_helpers = require("tests.dap.helpers") - -local binary = lsp_helpers.find_binary() -if not binary then - describe("DAP LSP commands (SKIPPED — no binary)", function() - it("skipped", function() - pending("basilisk binary not found") - end) - end) - return -end - -if not dap_helpers.is_debugpy_installed() then - describe("DAP LSP commands (SKIPPED — no debugpy)", function() - it("skipped", function() - pending("debugpy not installed") - end) - end) - return -end - -vim.ui.select = function(items, _, on_choice) - on_choice(items[1], 1) -end - -local tmpdir - -describe("DAP LSP commands", function() - before_each(function() - tmpdir = lsp_helpers.create_tmpdir() - local fh = io.open(tmpdir .. "/pyproject.toml", "w") - assert(fh) - fh:write('[project]\nname = "test"\nversion = "0.1.0"\n') - fh:close() - local fh2 = io.open(tmpdir .. "/hello.py", "w") - assert(fh2) - fh2:write('def main() -> None:\n print("hello")\n') - fh2:close() - vim.lsp.config("basilisk", { - cmd = { binary, "lsp" }, - filetypes = { "python" }, - root_markers = { "pyproject.toml", ".git" }, - }) - vim.lsp.enable("basilisk") - end) - - after_each(function() - lsp_helpers.stop_clients() - lsp_helpers.close_all_buffers() - lsp_helpers.cleanup_tmpdir(tmpdir) - end) - - -- ── startDebugSession returns host, port, sessionId ───────────────── - - it("startDebugSession returns host, port, sessionId", function() - vim.cmd("edit " .. vim.fn.fnameescape(tmpdir .. "/hello.py")) - local buf = vim.api.nvim_get_current_buf() - local ready = lsp_helpers.wait_for_server_ready(buf) - assert.is_true(ready, "LSP server did not become ready") - - local client = lsp_helpers.wait_for_client(buf) - assert.is_not_nil(client) - - local err, result = lsp_helpers.lsp_request( - client, - "workspace/executeCommand", - { command = "basilisk.startDebugSession", arguments = { {} } }, - buf, - 15000 - ) - - assert.is_nil(err, "startDebugSession should not return an error") - assert.is_not_nil(result, "startDebugSession should return a result") - assert.is_not_nil(result.host, "result should have host") - assert.is_true(result.port > 0, "port should be positive") - assert.is_not_nil(result.sessionId, "result should have sessionId") - assert.is_truthy( - result.sessionId:find("^dbg%-"), - "sessionId should start with dbg-" - ) - - -- Clean up: stop the session. - lsp_helpers.lsp_request( - client, - "workspace/executeCommand", - { - command = "basilisk.stopDebugSession", - arguments = { { sessionId = result.sessionId } }, - }, - buf, - 5000 - ) - end) - - -- ── stopDebugSession kills the debugpy process ────────────────────── - - it("stopDebugSession stops the session", function() - vim.cmd("edit " .. vim.fn.fnameescape(tmpdir .. "/hello.py")) - local buf = vim.api.nvim_get_current_buf() - lsp_helpers.wait_for_server_ready(buf) - local client = lsp_helpers.wait_for_client(buf) - assert.is_not_nil(client) - - -- Start a session. - local _, start_result = lsp_helpers.lsp_request( - client, - "workspace/executeCommand", - { command = "basilisk.startDebugSession", arguments = { {} } }, - buf, - 15000 - ) - assert.is_not_nil(start_result) - - -- Stop it. - local stop_err, stop_result = lsp_helpers.lsp_request( - client, - "workspace/executeCommand", - { - command = "basilisk.stopDebugSession", - arguments = { { sessionId = start_result.sessionId } }, - }, - buf, - 5000 - ) - - assert.is_nil(stop_err) - assert.is_not_nil(stop_result) - assert.are.equal(true, stop_result.stopped) - end) - - -- ── stopDebugSession with invalid sessionId ───────────────────────── - - it("stopDebugSession with invalid sessionId returns stopped: false", function() - vim.cmd("edit " .. vim.fn.fnameescape(tmpdir .. "/hello.py")) - local buf = vim.api.nvim_get_current_buf() - lsp_helpers.wait_for_server_ready(buf) - local client = lsp_helpers.wait_for_client(buf) - assert.is_not_nil(client) - - local err, result = lsp_helpers.lsp_request( - client, - "workspace/executeCommand", - { - command = "basilisk.stopDebugSession", - arguments = { { sessionId = "nonexistent-session-id" } }, - }, - buf, - 5000 - ) - - assert.is_nil(err) - assert.is_not_nil(result) - assert.are.equal(false, result.stopped) - end) - - -- ── Multiple simultaneous sessions ────────────────────────────────── - - it("can start multiple debug sessions on different ports", function() - vim.cmd("edit " .. vim.fn.fnameescape(tmpdir .. "/hello.py")) - local buf = vim.api.nvim_get_current_buf() - lsp_helpers.wait_for_server_ready(buf) - local client = lsp_helpers.wait_for_client(buf) - assert.is_not_nil(client) - - local _, session1 = lsp_helpers.lsp_request( - client, - "workspace/executeCommand", - { command = "basilisk.startDebugSession", arguments = { {} } }, - buf, - 15000 - ) - assert.is_not_nil(session1) - - local _, session2 = lsp_helpers.lsp_request( - client, - "workspace/executeCommand", - { command = "basilisk.startDebugSession", arguments = { {} } }, - buf, - 15000 - ) - assert.is_not_nil(session2) - - -- Different ports and session IDs. - assert.are_not.equal(session1.port, session2.port) - assert.are_not.equal(session1.sessionId, session2.sessionId) - - -- Clean up both. - lsp_helpers.lsp_request( - client, - "workspace/executeCommand", - { - command = "basilisk.stopDebugSession", - arguments = { { sessionId = session1.sessionId } }, - }, - buf, - 5000 - ) - lsp_helpers.lsp_request( - client, - "workspace/executeCommand", - { - command = "basilisk.stopDebugSession", - arguments = { { sessionId = session2.sessionId } }, - }, - buf, - 5000 - ) - end) - - -- ── Bad Python path returns error ─────────────────────────────────── - - it("startDebugSession with bad Python path returns error", function() - vim.cmd("edit " .. vim.fn.fnameescape(tmpdir .. "/hello.py")) - local buf = vim.api.nvim_get_current_buf() - lsp_helpers.wait_for_server_ready(buf) - local client = lsp_helpers.wait_for_client(buf) - assert.is_not_nil(client) - - local err, result = lsp_helpers.lsp_request( - client, - "workspace/executeCommand", - { - command = "basilisk.startDebugSession", - arguments = { { python = "/nonexistent/python3.99" } }, - }, - buf, - 15000 - ) - - -- Should return an error (debugpy check fails with bad python). - assert.is_not_nil(err, "expected an error for bad Python path") - assert.is_nil(result, "should not return a result for bad Python path") - end) - - -- ── DAP adapter registration ──────────────────────────────────────── - - it("DAP adapter is registered after setup", function() - local dap_ok, dap = pcall(require, "dap") - if not dap_ok then - pending("nvim-dap not available") - return - end - - require("basilisk.dap").setup({ debugger = { enabled = true }, python = "python3" }) - assert.is_not_nil(dap.adapters.basilisk, "basilisk adapter should be registered") - assert.is_function(dap.adapters.basilisk, "adapter should be a function") - end) - - -- ── Default configurations registered ─────────────────────────────── - - it("default launch and attach configurations registered", function() - local dap_ok, dap = pcall(require, "dap") - if not dap_ok then - pending("nvim-dap not available") - return - end - - -- Clear existing configs to test fresh registration. - dap.configurations.python = {} - require("basilisk.dap").setup({ debugger = { enabled = true }, python = "python3" }) - - assert.is_true(#dap.configurations.python >= 2, "should have at least 2 configs") - local has_launch = false - local has_attach = false - for _, conf in ipairs(dap.configurations.python) do - if conf.type == "basilisk" and conf.request == "launch" then - has_launch = true - end - if conf.type == "basilisk" and conf.request == "attach" then - has_attach = true - end - end - assert.is_true(has_launch, "should have basilisk launch config") - assert.is_true(has_attach, "should have basilisk attach config") - end) -end) diff --git a/basilisk.nvim/tests/lsp/activity_panel_spec.lua b/basilisk.nvim/tests/lsp/activity_panel_spec.lua deleted file mode 100644 index e8eb14823..000000000 --- a/basilisk.nvim/tests/lsp/activity_panel_spec.lua +++ /dev/null @@ -1,221 +0,0 @@ ---- Activity panel integration tests with the real LSP server. ---- ---- Tests :BasiliskModules and :BasiliskHealth commands render correct output. ---- Uses the REAL basilisk binary — no mocking. - -local helpers = require("tests.lsp.helpers") - -local binary = helpers.find_binary() -if not binary then - describe("activity panel (SKIPPED — no binary)", function() - it("skipped", function() - pending("basilisk binary not found") - end) - end) - return -end - -local tmpdir - -describe("activity panel with real LSP", function() - before_each(function() - tmpdir = helpers.create_tmpdir() - local fh = io.open(tmpdir .. "/pyproject.toml", "w") - fh:write('[project]\nname = "test"\nversion = "0.1.0"\n') - fh:close() - - -- Create multiple Python files so the module tree is non-trivial. - local files = { - { name = "alpha.py", content = "def greet(name: str) -> str:\n return name\n\nx: int = 1\n" }, - { name = "beta.py", content = "class Widget:\n value: int = 42\n\ny = 'hello'\n" }, - { name = "gamma.py", content = "def add(a: int, b: int) -> int:\n return a + b\n" }, - } - for _, file in ipairs(files) do - local path = tmpdir .. "/" .. file.name - local f = io.open(path, "w") - f:write(file.content) - f:close() - end - - vim.lsp.config("basilisk", { - cmd = { binary, "lsp" }, - filetypes = { "python" }, - root_markers = { "pyproject.toml", ".git" }, - settings = { basilisk = { analysisMode = "wholeModule" } }, - }) - vim.lsp.enable("basilisk") - end) - - after_each(function() - helpers.stop_clients() - helpers.close_all_buffers() - helpers.cleanup_tmpdir(tmpdir) - end) - - -- ── :BasiliskModules ───────────────────────────────────────────────────── - - it(":BasiliskModules renders correct tree for test workspace", function() - local buf = helpers.open_python_file(tmpdir, "alpha.py", "def greet(name: str) -> str:\n return name\n\nx: int = 1\n") - local client = helpers.wait_for_client(buf) - assert.is_not_nil(client, "LSP client should attach") - helpers.wait_for_server_ready(buf) - - -- Register commands. - local basilisk = require("basilisk") - basilisk.config = require("basilisk.config").resolve({ binary_path = binary }) - require("basilisk.commands").register(basilisk.config) - - -- Execute workspaceModules via LSP to verify data. - local err, result = helpers.lsp_request(client, "workspace/executeCommand", { - command = "basilisk.workspaceModules", - arguments = { {} }, - }, buf, 10000) - - assert.is_nil(err, "workspaceModules should not error") - assert.is_not_nil(result, "workspaceModules should return data") - assert.is_not_nil(result.modules, "result should contain modules array") - - local module_names = {} - for _, mod in ipairs(result.modules) do - module_names[mod.name] = true - end - - assert.is_true(module_names["alpha"] ~= nil, "should contain alpha module") - assert.is_true(module_names["beta"] ~= nil, "should contain beta module") - assert.is_true(module_names["gamma"] ~= nil, "should contain gamma module") - - -- Verify symbols are present in each module. - for _, mod in ipairs(result.modules) do - assert.is_not_nil(mod.symbols, mod.name .. " should have symbols") - assert.is_true(#mod.symbols > 0, mod.name .. " should have at least one symbol") - assert.is_not_nil(mod.path, mod.name .. " should have a file path") - assert.is_not_nil(mod.kind, mod.name .. " should have a kind") - end - - -- Verify the render_tree function produces correct output. - local modules_mod = require("basilisk.modules") - - -- Open the module explorer panel. - vim.cmd("BasiliskModules") - vim.wait(2000) - - -- Find the modules buffer. - local modules_buf = nil - for _, b in ipairs(vim.api.nvim_list_bufs()) do - if vim.bo[b].filetype == "basilisk-modules" then - modules_buf = b - break - end - end - - assert.is_not_nil(modules_buf, "should create buffer with basilisk-modules filetype") - - -- Wait for content to render. - local has_content = helpers.poll_until(function() - local lines = vim.api.nvim_buf_get_lines(modules_buf, 0, -1, false) - return #lines > 1 or (lines[1] and lines[1] ~= "" and lines[1] ~= " (no modules found)") - end, 5000, "module tree content") - - if has_content then - local lines = vim.api.nvim_buf_get_lines(modules_buf, 0, -1, false) - local text = table.concat(lines, "\n") - - -- Module names should appear in the rendered tree. - assert.truthy(text:find("alpha"), "rendered tree should contain 'alpha'") - assert.truthy(text:find("beta"), "rendered tree should contain 'beta'") - assert.truthy(text:find("gamma"), "rendered tree should contain 'gamma'") - - -- Kind labels should appear. - assert.truthy(text:find("%[mod%]"), "rendered tree should show [mod] labels") - end - - -- Close the panel. - modules_mod.close() - end) - - -- ── :BasiliskHealth ────────────────────────────────────────────────────── - - it(":BasiliskHealth renders correct coverage stats", function() - local buf = helpers.open_python_file(tmpdir, "alpha.py", "def greet(name: str) -> str:\n return name\n\nx: int = 1\n") - local client = helpers.wait_for_client(buf) - assert.is_not_nil(client, "LSP client should attach") - helpers.wait_for_server_ready(buf) - - -- Register commands. - local basilisk = require("basilisk") - basilisk.config = require("basilisk.config").resolve({ binary_path = binary }) - require("basilisk.commands").register(basilisk.config) - - -- Execute typeHealth via LSP to verify data. - local err, result = helpers.lsp_request(client, "workspace/executeCommand", { - command = "basilisk.typeHealth", - arguments = { {} }, - }, buf, 10000) - - assert.is_nil(err, "typeHealth should not error") - assert.is_not_nil(result, "typeHealth should return data") - assert.is_not_nil(result.workspace, "result should contain workspace stats") - assert.is_not_nil(result.modules, "result should contain modules array") - - -- Workspace stats should be present and sane. - local ws = result.workspace - assert.is_not_nil(ws.totalSymbols, "should have totalSymbols") - assert.is_not_nil(ws.annotatedSymbols, "should have annotatedSymbols") - assert.is_not_nil(ws.coveragePercent, "should have coveragePercent") - assert.is_true(ws.coveragePercent >= 0 and ws.coveragePercent <= 100, "coverage should be 0-100") - assert.is_true(ws.totalSymbols >= 3, "should have at least 3 symbols across files") - - -- Module entries should be present. - assert.is_true(#result.modules >= 3, "should have at least 3 module health entries") - for _, mod in ipairs(result.modules) do - assert.is_not_nil(mod.name, "module should have name") - assert.is_not_nil(mod.coveragePercent, "module should have coveragePercent") - assert.is_true(mod.coveragePercent >= 0 and mod.coveragePercent <= 100, - mod.name .. " coverage should be 0-100") - end - - -- Open the type health panel. - local type_health = require("basilisk.type_health") - vim.cmd("BasiliskHealth") - vim.wait(2000) - - -- Find the health buffer. - local health_buf = nil - for _, b in ipairs(vim.api.nvim_list_bufs()) do - if vim.bo[b].filetype == "basilisk-health" then - health_buf = b - break - end - end - - assert.is_not_nil(health_buf, "should create buffer with basilisk-health filetype") - - -- Wait for content to render. - local has_content = helpers.poll_until(function() - local lines = vim.api.nvim_buf_get_lines(health_buf, 0, -1, false) - return #lines > 2 - end, 5000, "health panel content") - - if has_content then - local lines = vim.api.nvim_buf_get_lines(health_buf, 0, -1, false) - local text = table.concat(lines, "\n") - - -- Header should be present. - assert.truthy(text:find("Type Health"), "rendered health should contain header") - - -- Coverage information should appear. - assert.truthy(text:find("Coverage"), "rendered health should show 'Coverage'") - assert.truthy(text:find("%%"), "rendered health should show percentage") - - -- Symbols count should appear. - assert.truthy(text:find("Symbols"), "rendered health should show 'Symbols'") - assert.truthy(text:find("annotated"), "rendered health should show 'annotated'") - - -- Per-module breakdown header should appear. - assert.truthy(text:find("Per%-Module"), "rendered health should show per-module section") - end - - -- Close the panel. - type_health.close() - end) -end) diff --git a/basilisk.nvim/tests/lsp/analysis_mode_spec.lua b/basilisk.nvim/tests/lsp/analysis_mode_spec.lua deleted file mode 100644 index edaa13d4a..000000000 --- a/basilisk.nvim/tests/lsp/analysis_mode_spec.lua +++ /dev/null @@ -1,178 +0,0 @@ ---- Analysis mode e2e tests — real LSP, no mocking. ---- ---- Tests wholeModule vs openFilesOnly behavior with the real basilisk server. - -local helpers = require("tests.lsp.helpers") - -local binary = helpers.find_binary() -if not binary then - describe("analysis mode (SKIPPED — no binary)", function() - it("skipped", function() - pending("basilisk binary not found") - end) - end) - return -end - -describe("analysis mode", function() - local tmpdir - - before_each(function() - tmpdir = helpers.create_tmpdir() - -- [tool.basilisk.rules] opts into the annotation house rules (off by - -- default) so untyped-parameter diagnostics fire — mirrors the Rust LSP - -- harness fixture (ws_test_common.rs). - local fh = io.open(tmpdir .. "/pyproject.toml", "w") - fh:write('[project]\nname = "test"\nversion = "0.1.0"\n') - fh:write('\n[tool.basilisk.rules]\n"BSK-0001" = "error"\n"BSK-0002" = "error"\n') - fh:close() - end) - - after_each(function() - helpers.stop_clients() - helpers.close_all_buffers() - helpers.cleanup_tmpdir(tmpdir) - end) - - -- wholeModule: diagnostics for open file - - it("wholeModule: open file gets diagnostics", function() - vim.lsp.config("basilisk", { - cmd = { binary, "lsp" }, - filetypes = { "python" }, - root_markers = { "pyproject.toml" }, - settings = { basilisk = { analysisMode = "wholeModule" } }, - }) - vim.lsp.enable("basilisk") - - local buf = helpers.open_python_file(tmpdir, "test_wm.py", "def greet(name):\n return name\n") - helpers.wait_for_server_ready(buf) - - local diags = helpers.wait_for_diagnostics(buf) - assert.is_true(#diags > 0, "wholeModule should produce diagnostics for untyped param") - end) - - -- wholeModule: diagnostics persist after buffer is hidden - - it("wholeModule: diagnostics persist when buffer is hidden", function() - vim.lsp.config("basilisk", { - cmd = { binary, "lsp" }, - filetypes = { "python" }, - root_markers = { "pyproject.toml" }, - settings = { basilisk = { analysisMode = "wholeModule" } }, - }) - vim.lsp.enable("basilisk") - - local buf = helpers.open_python_file(tmpdir, "test_persist.py", "def greet(name):\n return name\n") - helpers.wait_for_server_ready(buf) - helpers.wait_for_diagnostics(buf) - - -- Open a new buffer (hides the first). - vim.cmd("enew") - vim.wait(1000) - - -- Diagnostics should still exist for the hidden buffer. - local diags = vim.diagnostic.get(buf) - assert.is_true(#diags > 0, "wholeModule should preserve diagnostics for hidden buffers") - end) - - -- openFilesOnly: open file gets diagnostics - - it("openFilesOnly: open file gets diagnostics", function() - vim.lsp.config("basilisk", { - cmd = { binary, "lsp" }, - filetypes = { "python" }, - root_markers = { "pyproject.toml" }, - settings = { basilisk = { analysisMode = "openFilesOnly" } }, - }) - vim.lsp.enable("basilisk") - - local buf = helpers.open_python_file(tmpdir, "test_ofo.py", "def greet(name):\n return name\n") - helpers.wait_for_server_ready(buf) - - local diags = helpers.wait_for_diagnostics(buf) - assert.is_true(#diags > 0, "openFilesOnly should produce diagnostics for open file") - end) - - -- Configuration is passed to initializationOptions - - it("analysis mode setting is wired into LSP config", function() - vim.lsp.config("basilisk", { - cmd = { binary, "lsp" }, - filetypes = { "python" }, - root_markers = { "pyproject.toml" }, - settings = { basilisk = { analysisMode = "openFilesOnly" } }, - init_options = { analysisMode = "openFilesOnly" }, - }) - vim.lsp.enable("basilisk") - - local buf = helpers.open_python_file(tmpdir, "test_config.py", "x: int = 1\n") - local client = helpers.wait_for_client(buf) - assert.is_not_nil(client) - - -- Verify the client was configured. - assert.is_not_nil(client.config) - end) - - -- Tab tracking: closing a buffer in openFilesOnly mode - - it("openFilesOnly: closing buffer clears diagnostics", function() - vim.lsp.config("basilisk", { - cmd = { binary, "lsp" }, - filetypes = { "python" }, - root_markers = { "pyproject.toml" }, - settings = { basilisk = { analysisMode = "openFilesOnly" } }, - }) - vim.lsp.enable("basilisk") - - local buf = helpers.open_python_file(tmpdir, "test_close.py", "def greet(name):\n return name\n") - helpers.wait_for_server_ready(buf) - helpers.wait_for_diagnostics(buf) - - -- Verify we have diagnostics. - local diags_before = vim.diagnostic.get(buf) - assert.is_true(#diags_before > 0, "should have diagnostics before close") - - -- Close the buffer (wipeout). - vim.cmd("bwipeout! " .. buf) - vim.wait(2000) - - -- Buffer is invalid after wipeout — diagnostics are gone by definition. - assert.is_false(vim.api.nvim_buf_is_valid(buf), "buffer should be invalid after wipeout") - - -- Verify no diagnostics remain for any buffer from this namespace. - local all_diags = vim.diagnostic.get() - local remaining = 0 - for _, diag in ipairs(all_diags) do - if diag.source and diag.source:find("[Bb]asilisk") then - remaining = remaining + 1 - end - end - assert.are.equal(0, remaining, "no basilisk diagnostics should remain after buffer wipeout") - end) - - -- Tab tracking: reopening a file re-triggers diagnostics - - it("openFilesOnly: reopening file re-triggers diagnostics", function() - vim.lsp.config("basilisk", { - cmd = { binary, "lsp" }, - filetypes = { "python" }, - root_markers = { "pyproject.toml" }, - settings = { basilisk = { analysisMode = "openFilesOnly" } }, - }) - vim.lsp.enable("basilisk") - - -- Open, get diagnostics, close. - local buf1 = helpers.open_python_file(tmpdir, "test_reopen.py", "def greet(name):\n return name\n") - helpers.wait_for_server_ready(buf1) - helpers.wait_for_diagnostics(buf1) - vim.cmd("bwipeout! " .. buf1) - vim.wait(1000) - - -- Reopen the same file. - local buf2 = helpers.open_python_file(tmpdir, "test_reopen.py", "def greet(name):\n return name\n") - helpers.wait_for_server_ready(buf2) - local diags = helpers.wait_for_diagnostics(buf2) - assert.is_true(#diags > 0, "reopened file should get diagnostics again") - end) -end) diff --git a/basilisk.nvim/tests/lsp/client_modules_spec.lua b/basilisk.nvim/tests/lsp/client_modules_spec.lua deleted file mode 100644 index 269038a88..000000000 --- a/basilisk.nvim/tests/lsp/client_modules_spec.lua +++ /dev/null @@ -1,122 +0,0 @@ ---- Client-local module tests, run inside the LSP e2e gate so their lines ---- count toward the enforced Lua coverage threshold (scripts/test-nvim.sh). ---- ---- Tests [NVIM-NEOVIM-ONLY-CONFIGURATION] (config validation/resolution), ---- [ANALYSIS-OPEN] (tab tracking setup for openFilesOnly), and the Code Lens ---- row of [NVIM-LSP-CLIENT-CONFIGURATION-API-MAPPINGS] (version-gated ---- activation). No server needed: everything here is client-side behaviour, ---- so no binary guard — these run on every matrix leg. - -local codelens = require("basilisk.codelens") -local config = require("basilisk.config") -local tab_tracking = require("basilisk.tab_tracking") - -local function messages_of(errors) - return table.concat(errors, "\n") -end - -describe("config.validate [NVIM-NEOVIM-ONLY-CONFIGURATION]", function() - it("accepts the shipped defaults", function() - assert.same({}, config.validate(config.defaults)) - end) - - it("names every invalid enum value", function() - local bad = vim.tbl_deep_extend("force", {}, config.defaults, { - analysis_mode = "everything", - test_explorer = { framework = "nose", position = "top" }, - log_level = "loud", - }) - local errors = config.validate(bad) - assert.equals(4, #errors) - local all = messages_of(errors) - assert.truthy(all:find("invalid analysis_mode: everything", 1, true)) - assert.truthy(all:find("invalid test_explorer.framework: nose", 1, true)) - assert.truthy(all:find("invalid test_explorer.position: top", 1, true)) - assert.truthy(all:find("invalid log_level: loud", 1, true)) - end) -end) - -describe("config.resolve [NVIM-NEOVIM-ONLY-CONFIGURATION]", function() - it("returns the defaults when called with no opts", function() - assert.same(config.defaults, config.resolve()) - end) - - it("deep-merges user opts over the defaults", function() - local resolved = config.resolve({ - analysis_mode = "openFilesOnly", - keymaps = { prefix = "x" }, - }) - assert.equals("openFilesOnly", resolved.analysis_mode) - assert.equals("x", resolved.keymaps.prefix) - -- Sibling keys of a partially-overridden table keep their defaults. - assert.is_true(resolved.keymaps.enabled) - assert.equals("ruff", resolved.formatter) - end) - - it("logs but does not reject an invalid value", function() - -- Validation errors are reported through the log ([NVIM-HEALTH-CHECK] - -- surfaces them); resolve still returns the merged config unchanged. - local resolved = config.resolve({ log_level = "shout" }) - assert.equals("shout", resolved.log_level) - end) -end) - -describe("tab_tracking.setup [ANALYSIS-OPEN]", function() - local function tracking_autocmds() - local ok, autocmds = pcall(vim.api.nvim_get_autocmds, { group = "BasiliskTabTracking" }) - if not ok then - return nil - end - return autocmds - end - - it("is inert outside openFilesOnly mode", function() - tab_tracking.setup(config.resolve({ analysis_mode = "wholeModule" })) - assert.is_nil(tracking_autocmds()) - end) - - it("registers hidden-buffer tracking in openFilesOnly mode", function() - -- A visible python buffer seeds the known-open set via the same - -- collect path the autocmd uses. - vim.cmd.edit("tracked_visible.py") - vim.bo.filetype = "python" - - tab_tracking.setup(config.resolve({ analysis_mode = "openFilesOnly" })) - - local autocmds = tracking_autocmds() - assert.is_table(autocmds) - local events = {} - for _, autocmd in ipairs(autocmds or {}) do - events[autocmd.event] = true - assert.equals("*.py", autocmd.pattern) - end - assert.is_true(events.BufHidden) - assert.is_true(events.WinClosed) - assert.is_true(events.BufDelete) - - vim.api.nvim_del_augroup_by_name("BasiliskTabTracking") - vim.cmd.bwipeout({ bang = true }) - end) -end) - -describe("codelens.activate [NVIM-LSP-CLIENT-CONFIGURATION-API-MAPPINGS]", function() - it("activates through the API the runtime exposes", function() - local buf = vim.api.nvim_create_buf(false, true) - codelens.activate(buf) - if vim.lsp.codelens.enable then - -- 0.12+ path: enable() owns refresh; report its own view when the - -- runtime can be asked. - if vim.lsp.codelens.is_enabled then - assert.is_true(vim.lsp.codelens.is_enabled({ bufnr = buf })) - end - else - -- 0.10/0.11 fallback: a manual refresh loop is installed. - local autocmds = vim.api.nvim_get_autocmds({ - event = { "BufEnter", "InsertLeave" }, - buffer = buf, - }) - assert.is_true(#autocmds >= 2) - end - vim.api.nvim_buf_delete(buf, { force = true }) - end) -end) diff --git a/basilisk.nvim/tests/lsp/codelens_spec.lua b/basilisk.nvim/tests/lsp/codelens_spec.lua deleted file mode 100644 index e2bcf7a5c..000000000 --- a/basilisk.nvim/tests/lsp/codelens_spec.lua +++ /dev/null @@ -1,133 +0,0 @@ ---- Code lens activation tests for ftplugin/python.lua. ---- ---- Tests [NVIM-LSP-CLIENT-CONFIGURATION-API-MAPPINGS] (Code Lens row). ---- ---- Regression coverage for the deprecated `vim.lsp.codelens.refresh()` call, ---- which spams a warning on Neovim 0.12 and stops working on 0.13. The ---- ftplugin must prefer `vim.lsp.codelens.enable()` when the running Neovim ---- exposes it, and only fall back to `refresh()` on older versions. ---- ---- See https://github.com/Nimblesite/Basilisk/issues/66. ---- ---- These tests mock the LSP client and the codelens API, so they run without ---- the real basilisk binary and on any Neovim version. - -local function make_python_buffer() - -- Open a Python buffer so ftplugin/python.lua is sourced and registers its - -- buffer-local LspAttach handler. The buffer must be current when the - -- filetype is set, because the ftplugin scopes its autocmd to buffer 0. - local buf = vim.api.nvim_create_buf(true, false) - vim.api.nvim_buf_set_name(buf, vim.fn.tempname() .. ".py") - vim.api.nvim_set_current_buf(buf) - vim.bo[buf].filetype = "python" - return buf -end - -describe("basilisk ftplugin code lens activation", function() - local orig_enable - local orig_refresh - local orig_get_client_by_id - - before_each(function() - -- The ftplugin guard requires basilisk.config to be present. - local basilisk = require("basilisk") - basilisk.config = require("basilisk.config").resolve({ keymaps = { enabled = false } }) - - orig_enable = vim.lsp.codelens.enable - orig_refresh = vim.lsp.codelens.refresh - orig_get_client_by_id = vim.lsp.get_client_by_id - - -- Mock a basilisk client that advertises code lens support. - vim.lsp.get_client_by_id = function(_) - return { - name = "basilisk", - supports_method = function(_, method) - return method == "textDocument/codeLens" - end, - } - end - end) - - after_each(function() - vim.lsp.codelens.enable = orig_enable - vim.lsp.codelens.refresh = orig_refresh - vim.lsp.get_client_by_id = orig_get_client_by_id - - for _, buf in ipairs(vim.api.nvim_list_bufs()) do - if vim.api.nvim_buf_is_valid(buf) then - pcall(vim.api.nvim_buf_delete, buf, { force = true }) - end - end - end) - - it("prefers vim.lsp.codelens.enable over deprecated refresh on Neovim 0.12+", function() - -- Simulate an nvim 0.12+ runtime where enable() exists. - local enable_calls = {} - local refresh_calls = {} - vim.lsp.codelens.enable = function(on, opts) - table.insert(enable_calls, { on = on, opts = opts }) - end - vim.lsp.codelens.refresh = function(opts) - table.insert(refresh_calls, { opts = opts }) - end - - local buf = make_python_buffer() - - vim.api.nvim_exec_autocmds("LspAttach", { - buffer = buf, - data = { client_id = 1 }, - }) - - assert.are.equal(1, #enable_calls, "ftplugin should call vim.lsp.codelens.enable exactly once on attach") - assert.is_true(enable_calls[1].on, "enable should be called with true") - assert.are.equal(buf, enable_calls[1].opts.bufnr, "enable should target the attached buffer") - assert.are.equal( - 0, - #refresh_calls, - "ftplugin must not call deprecated vim.lsp.codelens.refresh when enable exists" - ) - end) - - it("registers its LspAttach handler only once when the filetype is set repeatedly", function() - -- Neovim re-sources ftplugins on every FileType event and unlets the - -- builtin `b:did_ftplugin` guard each time, so without a plugin-owned guard - -- the handler (and thus code lens activation) would be registered twice. - local enable_calls = {} - vim.lsp.codelens.enable = function(on, opts) - table.insert(enable_calls, { on = on, opts = opts }) - end - - local buf = make_python_buffer() - -- Force a second FileType event for the same buffer. - vim.bo[buf].filetype = "python" - - local handlers = vim.api.nvim_get_autocmds({ event = "LspAttach", buffer = buf }) - assert.are.equal(1, #handlers, "ftplugin should register exactly one LspAttach handler per buffer") - - vim.api.nvim_exec_autocmds("LspAttach", { - buffer = buf, - data = { client_id = 1 }, - }) - - assert.are.equal(1, #enable_calls, "code lens should be activated exactly once despite repeated FileType events") - end) - - it("falls back to vim.lsp.codelens.refresh on Neovim 0.10/0.11 where enable is absent", function() - -- Simulate an older runtime where enable() does not exist. - local refresh_calls = {} - vim.lsp.codelens.enable = nil - vim.lsp.codelens.refresh = function(opts) - table.insert(refresh_calls, { opts = opts }) - end - - local buf = make_python_buffer() - - vim.api.nvim_exec_autocmds("LspAttach", { - buffer = buf, - data = { client_id = 1 }, - }) - - assert.is_true(#refresh_calls >= 1, "ftplugin should fall back to refresh when enable is absent") - assert.are.equal(buf, refresh_calls[1].opts.bufnr, "refresh should target the attached buffer") - end) -end) diff --git a/basilisk.nvim/tests/lsp/commands_spec.lua b/basilisk.nvim/tests/lsp/commands_spec.lua deleted file mode 100644 index fbbad663e..000000000 --- a/basilisk.nvim/tests/lsp/commands_spec.lua +++ /dev/null @@ -1,374 +0,0 @@ ---- Real command integration tests with actual LSP and UI interactions. ---- ---- Tests [NVIM-USER-COMMANDS] (and [NVIM-LSP-CLIENT-CONFIGURATION-CUSTOM-COMMANDS]). ---- ---- Tests all :Basilisk* commands with the REAL LSP server. ---- Verifies floating windows open, buffers change, keymaps fire, etc. - -local helpers = require("tests.lsp.helpers") - -local binary = helpers.find_binary() -if not binary then - describe("basilisk commands (SKIPPED — no binary)", function() - it("skipped", function() - pending("basilisk binary not found") - end) - end) - return -end - -local tmpdir - -describe("basilisk commands with real LSP", function() - before_each(function() - tmpdir = helpers.create_tmpdir() - local fh = io.open(tmpdir .. "/pyproject.toml", "w") - fh:write('[project]\nname = "test"\nversion = "0.1.0"\n') - fh:close() - - vim.lsp.config("basilisk", { - cmd = { binary, "lsp" }, - filetypes = { "python" }, - root_markers = { "pyproject.toml", ".git" }, - settings = { basilisk = { analysisMode = "wholeModule" } }, - }) - vim.lsp.enable("basilisk") - end) - - after_each(function() - helpers.stop_clients() - helpers.close_all_buffers() - helpers.cleanup_tmpdir(tmpdir) - end) - - -- :BasiliskInfo — floating window - - it(":BasiliskInfo opens a floating window", function() - local buf = helpers.open_python_file(tmpdir, "test_info.py", "x: int = 1\n") - helpers.wait_for_server_ready(buf) - - -- Register commands manually (normally done by setup()). - local basilisk = require("basilisk") - basilisk.config = require("basilisk.config").resolve({ binary_path = binary }) - require("basilisk.commands").register(basilisk.config) - - vim.cmd("BasiliskInfo") - vim.wait(500) - - -- Find the floating window. - local float_win = nil - for _, win in ipairs(vim.api.nvim_list_wins()) do - local config = vim.api.nvim_win_get_config(win) - if config.relative and config.relative ~= "" then - float_win = win - break - end - end - - assert.is_not_nil(float_win, ":BasiliskInfo should open a floating window") - - -- Check content. - local float_buf = vim.api.nvim_win_get_buf(float_win) - local lines = vim.api.nvim_buf_get_lines(float_buf, 0, -1, false) - local text = table.concat(lines, "\n") - assert.truthy(text:find("Basilisk"), "float should contain 'Basilisk'") - assert.truthy(text:find("Status"), "float should contain 'Status'") - - -- Close with q. - vim.api.nvim_set_current_win(float_win) - vim.api.nvim_feedkeys("q", "x", false) - vim.wait(200) - - -- Verify closed. - assert.is_false(vim.api.nvim_win_is_valid(float_win), "float should close on 'q'") - end) - - -- :BasiliskOrganizeImports — real LSP command - - it(":BasiliskOrganizeImports sends LSP command", function() - local buf = helpers.open_python_file(tmpdir, "test_organize.py", "import os\nimport sys\n\nprint(sys.path)\nprint(os.getcwd())\n") - helpers.wait_for_server_ready(buf) - - -- Register commands. - local basilisk = require("basilisk") - basilisk.config = require("basilisk.config").resolve({ binary_path = binary }) - require("basilisk.commands").register(basilisk.config) - - -- Execute — should not error. - local ok = pcall(vim.cmd, "BasiliskOrganizeImports") - assert.is_true(ok, ":BasiliskOrganizeImports should not error") - end) - - -- :BasiliskFixFile — real LSP command - - it(":BasiliskFixFile sends LSP command", function() - local buf = helpers.open_python_file(tmpdir, "test_fixfile.py", "def greet(name):\n return name\n") - helpers.wait_for_server_ready(buf) - - local basilisk = require("basilisk") - basilisk.config = require("basilisk.config").resolve({ binary_path = binary }) - require("basilisk.commands").register(basilisk.config) - - local ok = pcall(vim.cmd, "BasiliskFixFile") - assert.is_true(ok, ":BasiliskFixFile should not error") - end) - - -- :BasiliskAdoptFile — real LSP command - - it(":BasiliskAdoptFile sends LSP command", function() - local buf = helpers.open_python_file(tmpdir, "test_adopt.py", "x = 1\n") - helpers.wait_for_server_ready(buf) - - local basilisk = require("basilisk") - basilisk.config = require("basilisk.config").resolve({ binary_path = binary }) - require("basilisk.commands").register(basilisk.config) - - local ok = pcall(vim.cmd, "BasiliskAdoptFile") - assert.is_true(ok, ":BasiliskAdoptFile should not error") - end) - - -- :BasiliskTestToggle — UI panel - - it(":BasiliskTestToggle opens/closes test panel", function() - local buf = helpers.open_python_file(tmpdir, "test_panel.py", "x: int = 1\n") - helpers.wait_for_server_ready(buf) - - local basilisk = require("basilisk") - basilisk.config = require("basilisk.config").resolve({ binary_path = binary }) - require("basilisk.commands").register(basilisk.config) - - local win_count_before = #vim.api.nvim_tabpage_list_wins(0) - - vim.cmd("BasiliskTestToggle") - vim.wait(200) - - local win_count_after = #vim.api.nvim_tabpage_list_wins(0) - assert.is_true(win_count_after > win_count_before, "should open a new panel window") - - -- Find the test panel buffer. - local found_test_buf = false - for _, b in ipairs(vim.api.nvim_list_bufs()) do - if vim.bo[b].filetype == "basilisk-tests" then - found_test_buf = true - break - end - end - assert.is_true(found_test_buf, "should create buffer with basilisk-tests filetype") - - -- Toggle off. - vim.cmd("BasiliskTestToggle") - vim.wait(200) - local win_count_closed = #vim.api.nvim_tabpage_list_wins(0) - assert.are.equal(win_count_before, win_count_closed, "toggle should close the panel") - end) - - -- :BasiliskDisableRule — sends basilisk.disableRule to real LSP - - it(":BasiliskDisableRule sends LSP command", function() - local buf = helpers.open_python_file(tmpdir, "test_disable.py", "def greet(name):\n return name\n") - helpers.wait_for_server_ready(buf) - - local basilisk = require("basilisk") - basilisk.config = require("basilisk.config").resolve({ binary_path = binary }) - require("basilisk.commands").register(basilisk.config) - - local ok = pcall(vim.cmd, "BasiliskDisableRule BSK-0001") - assert.is_true(ok, ":BasiliskDisableRule should not error") - - -- Verify pyproject.toml was written. Writing the config is the command's - -- whole job, so the file MUST exist — never treat its absence as a pass. - vim.wait(1000) - local fh = io.open(tmpdir .. "/pyproject.toml", "r") - assert.truthy(fh, "BasiliskDisableRule must write pyproject.toml") - local content = fh:read("*a") - fh:close() - -- Codes are letterless post-config-refactor: disabling BSK-0001 writes - -- exactly `BSK-0001` (never the pre-refactor `BSK-E0001`). - assert.truthy(content:find("BSK%-0001"), "pyproject.toml should contain the disabled rule BSK-0001") - end) - - -- :BasiliskFixWorkspace — sends basilisk.fixWorkspace to real LSP - - it(":BasiliskFixWorkspace sends LSP command", function() - local buf = helpers.open_python_file(tmpdir, "test_fixws.py", "def greet(name):\n return name\n") - helpers.wait_for_server_ready(buf) - - local basilisk = require("basilisk") - basilisk.config = require("basilisk.config").resolve({ binary_path = binary }) - require("basilisk.commands").register(basilisk.config) - - local ok = pcall(vim.cmd, "BasiliskFixWorkspace") - assert.is_true(ok, ":BasiliskFixWorkspace should not error") - end) - - -- :BasiliskAdoptWorkspace — sends basilisk.adoptWorkspace to real LSP - - it(":BasiliskAdoptWorkspace sends LSP command", function() - local buf = helpers.open_python_file(tmpdir, "test_adoptws.py", "x = 1\n") - helpers.wait_for_server_ready(buf) - - local basilisk = require("basilisk") - basilisk.config = require("basilisk.config").resolve({ binary_path = binary }) - require("basilisk.commands").register(basilisk.config) - - local ok = pcall(vim.cmd, "BasiliskAdoptWorkspace") - assert.is_true(ok, ":BasiliskAdoptWorkspace should not error") - end) - - -- :BasiliskUnadoptFile — sends basilisk.unadoptFile to real LSP - - it(":BasiliskUnadoptFile sends LSP command", function() - local buf = helpers.open_python_file(tmpdir, "test_unadopt.py", "x = 1\n") - helpers.wait_for_server_ready(buf) - - local basilisk = require("basilisk") - basilisk.config = require("basilisk.config").resolve({ binary_path = binary }) - require("basilisk.commands").register(basilisk.config) - - local ok = pcall(vim.cmd, "BasiliskUnadoptFile") - assert.is_true(ok, ":BasiliskUnadoptFile should not error") - end) - - -- :BasiliskShowOutput — opens the LSP log file - - it(":BasiliskShowOutput opens log buffer", function() - local buf = helpers.open_python_file(tmpdir, "test_output.py", "x: int = 1\n") - helpers.wait_for_server_ready(buf) - - local basilisk = require("basilisk") - basilisk.config = require("basilisk.config").resolve({ binary_path = binary }) - require("basilisk.commands").register(basilisk.config) - - local buf_count_before = #vim.api.nvim_list_bufs() - local ok = pcall(vim.cmd, "BasiliskShowOutput") - assert.is_true(ok, ":BasiliskShowOutput should not error") - end) - - -- ── Profiling commands with real LSP ───────────────────────────────────── - - it(":BasiliskProfile sends profiler/start to real LSP", function() - local buf = helpers.open_python_file(tmpdir, "test_profile.py", "x: int = 1\n") - helpers.wait_for_server_ready(buf) - - local basilisk = require("basilisk") - basilisk.config = require("basilisk.config").resolve({ binary_path = binary }) - require("basilisk.commands").register(basilisk.config) - - -- Should not crash even if server doesn't handle profiler commands yet. - local ok = pcall(vim.cmd, "BasiliskProfile") - assert.is_true(ok, ":BasiliskProfile should not error") - end) - - it(":BasiliskProfileStop sends profiler/stop to real LSP", function() - local buf = helpers.open_python_file(tmpdir, "test_profstop.py", "x: int = 1\n") - helpers.wait_for_server_ready(buf) - - local basilisk = require("basilisk") - basilisk.config = require("basilisk.config").resolve({ binary_path = binary }) - require("basilisk.commands").register(basilisk.config) - - local ok = pcall(vim.cmd, "BasiliskProfileStop") - assert.is_true(ok, ":BasiliskProfileStop should not error") - end) - - it(":BasiliskProfileSnapshot sends profiler/snapshot to real LSP", function() - local buf = helpers.open_python_file(tmpdir, "test_profsnap.py", "x: int = 1\n") - helpers.wait_for_server_ready(buf) - - local basilisk = require("basilisk") - basilisk.config = require("basilisk.config").resolve({ binary_path = binary }) - require("basilisk.commands").register(basilisk.config) - - local ok = pcall(vim.cmd, "BasiliskProfileSnapshot") - assert.is_true(ok, ":BasiliskProfileSnapshot should not error") - end) - - -- ── Memory commands with real LSP ──────────────────────────────────────── - - it(":BasiliskMemLeak sends memory/start to real LSP", function() - local buf = helpers.open_python_file(tmpdir, "test_memleak.py", "x: int = 1\n") - helpers.wait_for_server_ready(buf) - - local basilisk = require("basilisk") - basilisk.config = require("basilisk.config").resolve({ binary_path = binary }) - require("basilisk.commands").register(basilisk.config) - - local ok = pcall(vim.cmd, "BasiliskMemLeak") - assert.is_true(ok, ":BasiliskMemLeak should not error") - end) - - it(":BasiliskMemStop sends memory/stop to real LSP", function() - local buf = helpers.open_python_file(tmpdir, "test_memstop.py", "x: int = 1\n") - helpers.wait_for_server_ready(buf) - - local basilisk = require("basilisk") - basilisk.config = require("basilisk.config").resolve({ binary_path = binary }) - require("basilisk.commands").register(basilisk.config) - - local ok = pcall(vim.cmd, "BasiliskMemStop") - assert.is_true(ok, ":BasiliskMemStop should not error") - end) - - it(":BasiliskMemRefs sends memory/refs to real LSP", function() - local buf = helpers.open_python_file(tmpdir, "test_memrefs.py", "x: int = 1\n") - helpers.wait_for_server_ready(buf) - - local basilisk = require("basilisk") - basilisk.config = require("basilisk.config").resolve({ binary_path = binary }) - require("basilisk.commands").register(basilisk.config) - - local ok = pcall(vim.cmd, "BasiliskMemRefs dict") - assert.is_true(ok, ":BasiliskMemRefs should not error") - end) - - -- ── Refactoring commands with real LSP ─────────────────────────────────── - - it(":BasiliskExtractVariable triggers code action", function() - local buf = helpers.open_python_file(tmpdir, "test_extract.py", "def calc() -> int:\n return 1 + 2 + 3\n") - helpers.wait_for_server_ready(buf) - - local basilisk = require("basilisk") - basilisk.config = require("basilisk.config").resolve({ binary_path = binary }) - require("basilisk.commands").register(basilisk.config) - - -- Select range in visual mode then run command — should not crash. - local ok = pcall(vim.cmd, "BasiliskExtractVariable") - assert.is_true(ok, ":BasiliskExtractVariable should not error") - end) - - it(":BasiliskExtractConstant triggers code action", function() - local buf = helpers.open_python_file(tmpdir, "test_const.py", "def calc() -> int:\n return 42\n") - helpers.wait_for_server_ready(buf) - - local basilisk = require("basilisk") - basilisk.config = require("basilisk.config").resolve({ binary_path = binary }) - require("basilisk.commands").register(basilisk.config) - - local ok = pcall(vim.cmd, "BasiliskExtractConstant") - assert.is_true(ok, ":BasiliskExtractConstant should not error") - end) - - it(":BasiliskConvertUnion triggers code action", function() - local buf = helpers.open_python_file(tmpdir, "test_union.py", "from typing import Optional\n\ndef greet(name: Optional[str]) -> str:\n return name or 'world'\n") - helpers.wait_for_server_ready(buf) - - local basilisk = require("basilisk") - basilisk.config = require("basilisk.config").resolve({ binary_path = binary }) - require("basilisk.commands").register(basilisk.config) - - local ok = pcall(vim.cmd, "BasiliskConvertUnion") - assert.is_true(ok, ":BasiliskConvertUnion should not error") - end) - - it(":BasiliskImplementMethods triggers code action", function() - local buf = helpers.open_python_file(tmpdir, "test_impl.py", "from abc import ABC, abstractmethod\n\nclass Base(ABC):\n @abstractmethod\n def run(self) -> None: ...\n\nclass Child(Base):\n pass\n") - helpers.wait_for_server_ready(buf) - - local basilisk = require("basilisk") - basilisk.config = require("basilisk.config").resolve({ binary_path = binary }) - require("basilisk.commands").register(basilisk.config) - - local ok = pcall(vim.cmd, "BasiliskImplementMethods") - assert.is_true(ok, ":BasiliskImplementMethods should not error") - end) -end) diff --git a/basilisk.nvim/tests/lsp/coverage_boost_spec.lua b/basilisk.nvim/tests/lsp/coverage_boost_spec.lua deleted file mode 100644 index 65cfcefa0..000000000 --- a/basilisk.nvim/tests/lsp/coverage_boost_spec.lua +++ /dev/null @@ -1,587 +0,0 @@ ---- E2e coverage boost tests exercising uncovered code paths. ---- ---- Exercises memory, profiling, testing, binary, log, lsp, init, ui, ---- commands, statusline, tab_tracking, config, and health modules ---- through REAL interactions — no mocking. - -local helpers = require("tests.lsp.helpers") - -local binary = helpers.find_binary() -if not binary then - describe("coverage boost (SKIPPED — no binary)", function() - it("skipped", function() - pending("basilisk binary not found") - end) - end) - return -end - --- ── Helpers ────────────────────────────────────────────────────────────────── - -local function close_floats() - for _, w in ipairs(vim.api.nvim_list_wins()) do - local cfg = vim.api.nvim_win_get_config(w) - if cfg.relative and cfg.relative ~= "" then - pcall(vim.api.nvim_win_close, w, true) - end - end -end - -local tmpdir - --- ── Tests ──────────────────────────────────────────────────────────────────── - -describe("coverage boost e2e", function() - before_each(function() - tmpdir = helpers.create_tmpdir() - local fh = io.open(tmpdir .. "/pyproject.toml", "w") - fh:write('[project]\nname = "test"\nversion = "0.1.0"\n') - fh:close() - - vim.lsp.config("basilisk", { - cmd = { binary, "lsp" }, - filetypes = { "python" }, - root_markers = { "pyproject.toml", ".git" }, - settings = { basilisk = { analysisMode = "wholeModule" } }, - }) - vim.lsp.enable("basilisk") - end) - - after_each(function() - close_floats() - pcall(function() require("basilisk.testing").close() end) - helpers.stop_clients() - helpers.close_all_buffers() - helpers.cleanup_tmpdir(tmpdir) - end) - - -- ── ui.lua ────────────────────────────────────────────────────────────── - - it("ui.open_float creates float with q keymap", function() - local ui = require("basilisk.ui") - local buf, win = ui.open_float("Test Title", { "line 1", "line 2" }) - - assert.is_true(vim.api.nvim_win_is_valid(win)) - assert.is_true(vim.api.nvim_buf_is_valid(buf)) - - local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) - assert.are.equal("line 1", lines[1]) - assert.are.equal("line 2", lines[2]) - assert.is_false(vim.bo[buf].modifiable) - - vim.api.nvim_set_current_win(win) - vim.api.nvim_feedkeys("q", "x", false) - vim.wait(200) - assert.is_false(vim.api.nvim_win_is_valid(win)) - end) - - it("ui.get_client returns nil when no LSP", function() - helpers.stop_clients() - local ui = require("basilisk.ui") - assert.is_nil(ui.get_client()) - end) - - it("ui.get_client returns client when LSP active", function() - local buf = helpers.open_python_file(tmpdir, "test_ui_client.py", "x: int = 1\n") - helpers.wait_for_server_ready(buf) - local ui = require("basilisk.ui") - assert.is_not_nil(ui.get_client()) - end) - - -- ── log.lua ───────────────────────────────────────────────────────────── - - it("log set_level + all log levels + file logging", function() - local log = require("basilisk.log") - - for _, lvl in ipairs({ "trace", "debug", "info", "warn", "error" }) do - log.set_level(lvl) - log.trace("t %s", "a") - log.debug("d %d", 1) - log.info("i") - log.warn("w") - log.error("e") - end - - log.set_level("invalid") - - local tmplog = vim.fn.tempname() .. ".log" - log.enable_file(tmplog) - log.set_level("info") - log.info("file test") - log.close_file() - log.close_file() - - local fh = io.open(tmplog, "r") - assert.is_not_nil(fh) - local content = fh:read("*a") - fh:close() - assert.truthy(content:find("file test")) - os.remove(tmplog) - - log.set_level("info") - end) - - -- ── binary.lua ────────────────────────────────────────────────────────── - - it("binary resolve cascade", function() - local bin_mod = require("basilisk.binary") - - bin_mod.resolve(nil) - bin_mod.resolve("") - bin_mod.resolve("/nonexistent/basilisk") - - local ls_path = vim.fn.exepath("ls") - if ls_path ~= "" then - assert.is_not_nil(bin_mod.resolve(ls_path)) - end - - local orig = vim.env.BASILISK_PATH - vim.env.BASILISK_PATH = nil - bin_mod.resolve() - vim.env.BASILISK_PATH = "" - bin_mod.resolve() - vim.env.BASILISK_PATH = "/nonexistent" - bin_mod.resolve() - if ls_path ~= "" then - vim.env.BASILISK_PATH = ls_path - assert.is_not_nil(bin_mod.resolve()) - end - vim.env.BASILISK_PATH = orig - - assert.is_nil(bin_mod.version("/nonexistent")) - if ls_path ~= "" then - bin_mod.version(ls_path) - end - end) - - -- ── config.lua ────────────────────────────────────────────────────────── - - it("config resolve and validate", function() - local config_mod = require("basilisk.config") - - local d = config_mod.defaults - assert.are.equal("wholeModule", d.analysis_mode) - assert.is_true(d.enabled) - - config_mod.resolve() - config_mod.resolve({}) - config_mod.resolve({ analysis_mode = "openFilesOnly" }) - config_mod.resolve({ formatter = "none" }) - config_mod.resolve({ inlay_hints = { parameter_names = false } }) - config_mod.resolve({ debugger = { type_checking = true } }) - config_mod.resolve({ uv = { auto_sync = true } }) - config_mod.resolve({ test_explorer = { position = "left", width = 30 } }) - config_mod.resolve({ test_explorer = { position = "bottom" } }) - - assert.are.equal(0, #config_mod.validate(config_mod.resolve())) - assert.are.equal(1, #config_mod.validate(config_mod.resolve({ analysis_mode = "bad" }))) - assert.are.equal(1, #config_mod.validate(config_mod.resolve({ test_explorer = { framework = "bad" } }))) - assert.are.equal(1, #config_mod.validate(config_mod.resolve({ test_explorer = { position = "top" } }))) - assert.are.equal(1, #config_mod.validate(config_mod.resolve({ log_level = "verbose" }))) - end) - - -- ── statusline.lua ────────────────────────────────────────────────────── - - it("statusline all states + diagnostics", function() - local sl = require("basilisk.statusline") - - for _, state in ipairs({ "stopped", "starting", "error", "ready" }) do - sl.set_state(state) - assert.truthy(sl.get():find("Basilisk")) - sl.get_color() - end - - sl.set_state("stopped") - assert.are.equal("Comment", sl.get_color()) - sl.set_state("starting") - assert.are.equal("DiagnosticWarn", sl.get_color()) - sl.set_state("error") - assert.are.equal("DiagnosticError", sl.get_color()) - - sl.set_state("ready") - sl.update() - sl.get() - sl.get_color() - - assert.are.equal("string", type(sl.lualine_component[1]())) - sl.lualine_component.color() - end) - - -- ── memory.lua ────────────────────────────────────────────────────────── - - it("memory display_leak_report + display_retention_paths + complete_refs", function() - local mem = require("basilisk.memory") - - mem.display_leak_report(nil); close_floats() - mem.display_leak_report({ leaks = {} }); close_floats() - mem.display_leak_report({ - leaks = { - { typeName = "DataFrame", count = 15, totalSize = "1.2MB", - location = { file = "/tmp/t.py", line = 42 } }, - { typeName = "dict", count = 100, totalSize = "500KB" }, - }, - }); close_floats() - - mem.display_retention_paths("dict", nil); close_floats() - mem.display_retention_paths("DataFrame", { retentionPaths = {} }); close_floats() - mem.display_retention_paths("DataFrame", { - retentionPaths = { { - confidence = 0.85, - steps = { - { name = "cache", kind = "variable" }, - { name = "__dict__", kind = "attribute" }, - }, - } }, - }); close_floats() - - assert.is_true(#mem.complete_refs("") > 0) - assert.are.equal("DataFrame", mem.complete_refs("Data")[1]) - assert.are.equal(0, #mem.complete_refs("nonexistent_xyz")) - assert.is_true(#mem.complete_refs("tensor") > 0) - end) - - it("memory start/stop/refs with real LSP", function() - local buf = helpers.open_python_file(tmpdir, "test_mem.py", "x: int = 1\n") - helpers.wait_for_server_ready(buf) - - local mem = require("basilisk.memory") - mem.start() - vim.wait(500) - mem.stop() - vim.wait(500) - mem.refs("dict") - vim.wait(500) - close_floats() - end) - - it("memory start/stop/refs without client", function() - helpers.stop_clients() - local mem = require("basilisk.memory") - mem.start() - mem.stop() - mem.refs("dict") - end) - - -- ── profiling.lua ─────────────────────────────────────────────────────── - - it("profiling display + heat map + flamegraph", function() - local prof = require("basilisk.profiling") - - prof.display_results(nil); close_floats() - prof.display_results({ hotFunctions = {} }); close_floats() - prof.display_results({ - hotFunctions = { - { name = "hot", file = "/tmp/test.py", line = 10, percentage = 55 }, - { name = "warm", file = "/tmp/test.py", line = 25, percentage = 25 }, - { name = "cool", file = "/tmp/test.py", line = 40, percentage = 5 }, - }, - }); close_floats() - - prof.apply_heat_map(nil) - prof.apply_heat_map({}) - prof.apply_heat_map({ { name = "x", file = "/nonexistent.py", line = 1, percentage = 60 } }) - - prof.export_flamegraph(nil) - prof.export_flamegraph({}) - prof.export_flamegraph({ speedscopeJson = '{"version":"0.0.1"}' }) - end) - - it("profiling start/stop/snapshot with real LSP", function() - local buf = helpers.open_python_file(tmpdir, "test_prof.py", "x: int = 1\n") - helpers.wait_for_server_ready(buf) - - local prof = require("basilisk.profiling") - prof.start() - vim.wait(500) - prof.start(1234) - vim.wait(500) - prof.stop() - vim.wait(500) - prof.snapshot() - vim.wait(500) - close_floats() - end) - - it("profiling without client", function() - helpers.stop_clients() - local prof = require("basilisk.profiling") - prof.start() - prof.stop() - prof.snapshot() - end) - - -- ── lsp.lua ───────────────────────────────────────────────────────────── - - it("lsp start + restart + backoff", function() - local lsp_mod = require("basilisk.lsp") - local config_mod = require("basilisk.config") - - assert.is_true(lsp_mod.get_restart_count() >= 0) - lsp_mod.reset_restart_count() - assert.are.equal(0, lsp_mod.get_restart_count()) - - local no_bin_config = config_mod.resolve({ binary_path = "/nonexistent" }) - assert.is_false(lsp_mod.start(no_bin_config)) - - local real_config = config_mod.resolve({ binary_path = binary }) - assert.is_true(lsp_mod.start(real_config)) - - lsp_mod.reset_restart_count() - lsp_mod.restart(real_config, false) - vim.wait(200) - - lsp_mod.restart(real_config, true) - vim.wait(200) - - lsp_mod.reset_restart_count() - for _ = 1, 4 do - lsp_mod.restart(real_config, false) - end - lsp_mod.restart(real_config, true) - end) - - -- ── testing.lua ───────────────────────────────────────────────────────── - - it("testing parse + set_status + update_diagnostics + refresh", function() - local testing = require("basilisk.testing") - - testing.parse_pytest_output("") - testing.parse_pytest_output("no tests ran\n") - testing.parse_pytest_output("===== 5 items =====\n") - testing.parse_pytest_output("test_a.py::test_one\ntest_a.py::test_two\n") - testing.parse_pytest_output("test_a.py::TestClass::test_method\n") - testing.parse_pytest_output("test_a.py::TestClass::test_m1\ntest_a.py::TestClass::test_m2\n") - - local tree = testing.parse_pytest_output("test_a.py::test_one\ntest_b.py::test_two\n") - assert.are.equal(2, #tree) - - testing.set_status("test_a.py::test_one", "passed") - testing.set_status("test_a.py::test_one", "failed") - testing.set_status("nonexistent", "passed") - testing.set_status(nil, "passed") - - testing.parse_test_results("") - testing.parse_test_results("test_a.py::test_one PASSED\ntest_a.py::test_two FAILED\n") - testing.update_diagnostics() - testing.refresh_display() - end) - - it("testing open/close/toggle for every position", function() - local testing = require("basilisk.testing") - local config_mod = require("basilisk.config") - - for _, pos in ipairs({ "right", "left", "bottom" }) do - testing.open(config_mod.resolve({ test_explorer = { position = pos, width = 30 } })) - testing.refresh_display() - testing.close() - end - - testing.toggle(config_mod.resolve()) - testing.toggle(config_mod.resolve()) - end) - - it("testing setup_auto_discover", function() - local testing = require("basilisk.testing") - local config_mod = require("basilisk.config") - - testing.setup_auto_discover(config_mod.resolve({ test_explorer = { auto_discover_on_save = false } })) - testing.setup_auto_discover(config_mod.resolve({ test_explorer = { auto_discover_on_save = true } })) - end) - - it("testing discover + run with real pytest", function() - local testing = require("basilisk.testing") - local config_mod = require("basilisk.config") - - local test_file = tmpdir .. "/test_example.py" - local tfh = io.open(test_file, "w") - tfh:write("def test_pass():\n assert 1 + 1 == 2\n\ndef test_fail():\n assert 1 == 2\n") - tfh:close() - - local cfg = config_mod.resolve() - - testing.open(cfg) - - testing.discover(cfg) - vim.wait(5000, function() return false end, 100) - testing.refresh_display() - - testing.run(cfg, test_file .. "::test_pass") - vim.wait(5000, function() return false end, 100) - testing.refresh_display() - testing.update_diagnostics() - - testing.run(cfg) - vim.wait(3000, function() return false end, 100) - - testing.set_status(test_file .. "::test_pass", "passed") - testing.set_status(test_file .. "::test_fail", "failed") - testing.refresh_display() - testing.update_diagnostics() - - pcall(testing.debug, cfg, test_file .. "::test_pass") - - testing.close() - end) - - it("testing apply_coverage real XML", function() - local testing = require("basilisk.testing") - - local cov_xml = tmpdir .. "/coverage.xml" - local cxfh = io.open(cov_xml, "w") - cxfh:write([[ - - - -]]) - cxfh:close() - - testing.apply_coverage(cov_xml) - testing.apply_coverage("/nonexistent/coverage.xml") - end) - - -- ── init.lua ──────────────────────────────────────────────────────────── - - it("init.setup full lifecycle", function() - package.loaded["basilisk"] = nil - package.loaded["basilisk.init"] = nil - - local init_mod = require("basilisk") - init_mod.setup({ binary_path = binary }) - assert.is_not_nil(init_mod.config) - - init_mod.setup({}) - end) - - -- ── commands.lua ──────────────────────────────────────────────────────── - - it(":BasiliskRestart force restarts", function() - local buf = helpers.open_python_file(tmpdir, "test_restart.py", "x: int = 1\n") - helpers.wait_for_server_ready(buf) - - package.loaded["basilisk"] = nil - package.loaded["basilisk.init"] = nil - local basilisk = require("basilisk") - basilisk.config = require("basilisk.config").resolve({ binary_path = binary }) - require("basilisk.commands").register(basilisk.config) - - local ok = pcall(vim.cmd, "BasiliskRestart") - assert.is_true(ok) - end) - - it("commands with callbacks wait for response", function() - local buf = helpers.open_python_file(tmpdir, "test_cb.py", "def greet(name):\n return name\n") - helpers.wait_for_server_ready(buf) - - local basilisk = require("basilisk") - basilisk.config = require("basilisk.config").resolve({ binary_path = binary }) - require("basilisk.commands").register(basilisk.config) - - pcall(vim.cmd, "BasiliskFixFile") - vim.wait(1000) - pcall(vim.cmd, "BasiliskFixWorkspace") - vim.wait(1000) - pcall(vim.cmd, "BasiliskAdoptFile") - vim.wait(1000) - pcall(vim.cmd, "BasiliskAdoptWorkspace") - vim.wait(1000) - pcall(vim.cmd, "BasiliskUnadoptFile") - vim.wait(1000) - pcall(vim.cmd, "BasiliskShowOutput") - vim.wait(500) - pcall(vim.cmd, "BasiliskTestDiscover") - vim.wait(2000) - close_floats() - end) - - it("uv commands send to real LSP", function() - local buf = helpers.open_python_file(tmpdir, "test_uv.py", "x: int = 1\n") - helpers.wait_for_server_ready(buf) - - local basilisk = require("basilisk") - basilisk.config = require("basilisk.config").resolve({ binary_path = binary }) - require("basilisk.commands").register(basilisk.config) - - for _, cmd in ipairs({ "BasiliskUvSync", "BasiliskUvLock" }) do - pcall(vim.cmd, cmd) - vim.wait(1000) - end - - pcall(vim.cmd, "BasiliskUvAdd requests") - vim.wait(1000) - pcall(vim.cmd, "BasiliskUvAddDev pytest") - vim.wait(1000) - pcall(vim.cmd, "BasiliskUvRemove requests") - vim.wait(1000) - pcall(vim.cmd, "BasiliskUvCreateEnv 3.12") - vim.wait(1000) - end) - - it("test and debug commands", function() - local buf = helpers.open_python_file(tmpdir, "test_cmds.py", "def test_x():\n pass\n") - helpers.wait_for_server_ready(buf) - - local basilisk = require("basilisk") - basilisk.config = require("basilisk.config").resolve({ binary_path = binary }) - require("basilisk.commands").register(basilisk.config) - - pcall(vim.cmd, "BasiliskTestRun") - vim.wait(200) - pcall(vim.cmd, "BasiliskTestDebug test_foo.py::test_bar") - vim.wait(200) - pcall(vim.cmd, "BasiliskDebugFile") - vim.wait(200) - end) - - it("commands without LSP client", function() - helpers.stop_clients() - vim.wait(500) - - local basilisk = require("basilisk") - basilisk.config = require("basilisk.config").resolve({ binary_path = binary }) - require("basilisk.commands").register(basilisk.config) - - pcall(vim.cmd, "BasiliskOrganizeImports") - pcall(vim.cmd, "BasiliskFixFile") - pcall(vim.cmd, "BasiliskInfo") - vim.wait(200) - close_floats() - end) - - -- ── tab_tracking.lua ──────────────────────────────────────────────────── - - it("tab tracking all modes + buffer lifecycle", function() - local tt = require("basilisk.tab_tracking") - local config_mod = require("basilisk.config") - - tt.setup(config_mod.resolve({ analysis_mode = "wholeModule" })) - tt.setup(config_mod.resolve({ analysis_mode = "crossModule" })) - tt.setup(config_mod.resolve({ analysis_mode = "openFilesOnly" })) - - local tmppy = vim.fn.tempname() .. ".py" - local f1 = io.open(tmppy, "w") - if f1 then f1:write("x = 1\n"); f1:close() end - vim.cmd("edit " .. vim.fn.fnameescape(tmppy)) - vim.wait(200) - pcall(vim.cmd, "enew") - vim.wait(200) - os.remove(tmppy) - end) - - -- ── health.lua ────────────────────────────────────────────────────────── - - it("health check runs", function() - local health = require("basilisk.health") - health.check() - end) - - -- ── dap.lua ───────────────────────────────────────────────────────────── - - it("dap setup + stop_session", function() - local dap_mod = require("basilisk.dap") - local config_mod = require("basilisk.config") - - dap_mod.setup(config_mod.resolve({ debugger = { enabled = false } })) - dap_mod.setup(config_mod.resolve({ debugger = { enabled = true } })) - dap_mod.stop_session() - end) -end) diff --git a/basilisk.nvim/tests/lsp/debug_spec.lua b/basilisk.nvim/tests/lsp/debug_spec.lua deleted file mode 100644 index 688127ab6..000000000 --- a/basilisk.nvim/tests/lsp/debug_spec.lua +++ /dev/null @@ -1,153 +0,0 @@ ---- Debug integration e2e tests — real LSP, real nvim-dap. ---- ---- Tests the full debug lifecycle: startDebugSession via LSP, ---- DAP adapter registration, and session management. - -local helpers = require("tests.lsp.helpers") - -local binary = helpers.find_binary() -if not binary then - describe("debug integration (SKIPPED — no binary)", function() - it("skipped", function() - pending("basilisk binary not found") - end) - end) - return -end - -local dap_ok, dap = pcall(require, "dap") -if not dap_ok then - describe("debug integration (SKIPPED — no nvim-dap)", function() - it("skipped", function() - pending("nvim-dap not installed") - end) - end) - return -end - -local tmpdir - -describe("debug integration with real LSP", function() - before_each(function() - tmpdir = helpers.create_tmpdir() - local fh = io.open(tmpdir .. "/pyproject.toml", "w") - fh:write('[project]\nname = "test"\nversion = "0.1.0"\n') - fh:close() - - vim.lsp.config("basilisk", { - cmd = { binary, "lsp" }, - filetypes = { "python" }, - root_markers = { "pyproject.toml" }, - settings = { basilisk = { analysisMode = "wholeModule" } }, - }) - vim.lsp.enable("basilisk") - end) - - after_each(function() - helpers.stop_clients() - helpers.close_all_buffers() - helpers.cleanup_tmpdir(tmpdir) - end) - - it("basilisk DAP adapter is registered after setup", function() - local dap_mod = require("basilisk.dap") - local cfg = require("basilisk.config").resolve({ binary_path = binary }) - dap_mod.setup(cfg) - - assert.is_not_nil(dap.adapters.basilisk, "basilisk adapter should be registered") - assert.is_function(dap.adapters.basilisk, "adapter should be a function") - end) - - it("default launch configuration is registered", function() - local dap_mod = require("basilisk.dap") - local cfg = require("basilisk.config").resolve({ binary_path = binary }) - dap_mod.setup(cfg) - - assert.is_not_nil(dap.configurations.python, "python configurations should exist") - local found_launch = false - for _, conf in ipairs(dap.configurations.python) do - if conf.type == "basilisk" and conf.request == "launch" then - found_launch = true - assert.are.equal("${file}", conf.program) - assert.is_true(conf.justMyCode) - end - end - assert.is_true(found_launch, "should have basilisk launch configuration") - end) - - it("default attach configuration is registered", function() - local dap_mod = require("basilisk.dap") - local cfg = require("basilisk.config").resolve({ binary_path = binary }) - dap_mod.setup(cfg) - - local found_attach = false - for _, conf in ipairs(dap.configurations.python) do - if conf.type == "basilisk" and conf.request == "attach" then - found_attach = true - assert.are.equal("127.0.0.1", conf.connect.host) - assert.are.equal(5678, conf.connect.port) - end - end - assert.is_true(found_attach, "should have basilisk attach configuration") - end) - - it("startDebugSession sends LSP command", function() - local buf = helpers.open_python_file(tmpdir, "test_debug.py", - "def main() -> None:\n x: int = 42\n print(x)\n\nmain()\n") - local client = helpers.wait_for_client(buf) - assert.is_not_nil(client) - helpers.wait_for_server_ready(buf) - - local err, result = helpers.lsp_request(client, "workspace/executeCommand", { - command = "basilisk.startDebugSession", - arguments = { { uri = vim.uri_from_bufnr(buf), pythonPath = "python3" } }, - }, buf, 10000) - - -- Server may or may not support this yet — the important thing is - -- we exercise the full request path without crashing. - if result and result.port then - assert.is_number(result.port) - assert.truthy(result.host) - -- Clean up: stop the debug session. - if result.sessionId then - helpers.lsp_request(client, "workspace/executeCommand", { - command = "basilisk.stopDebugSession", - arguments = { { sessionId = result.sessionId } }, - }, buf) - end - end - end) - - it("stop_session without active session does not error", function() - local dap_mod = require("basilisk.dap") - assert.has_no.errors(function() - dap_mod.stop_session() - end) - end) - - it("setup with debugger disabled skips registration", function() - -- Clear existing adapter. - dap.adapters.basilisk = nil - - local dap_mod = require("basilisk.dap") - local cfg = require("basilisk.config").resolve({ debugger = { enabled = false } }) - dap_mod.setup(cfg) - - -- Adapter should NOT be registered when disabled. - -- (It might still be there from a previous setup call, but the function should not error.) - end) - - it("DapTcpProxy listens on a port", function() - local dap_mod = require("basilisk.dap") - local proxy_port = nil - - -- Create proxy pointing at a non-existent server (won't connect, but will listen). - dap_mod.create_proxy("127.0.0.1", 59999, function(port) - proxy_port = port - end) - - vim.wait(1000) - assert.is_not_nil(proxy_port, "proxy should allocate a port") - assert.is_true(proxy_port > 0, "proxy port should be positive") - end) -end) diff --git a/basilisk.nvim/tests/lsp/health_spec.lua b/basilisk.nvim/tests/lsp/health_spec.lua deleted file mode 100644 index 44681a258..000000000 --- a/basilisk.nvim/tests/lsp/health_spec.lua +++ /dev/null @@ -1,96 +0,0 @@ ---- Health check tests for :checkhealth basilisk. ---- ---- Tests [NVIM-HEALTH-CHECK]. ---- ---- Regression coverage for issue #67: `:checkhealth basilisk` called ---- `binary.resolve()` without the user-configured `binary_path`, so a binary ---- reachable only via `setup({ binary_path = ... })` was falsely reported as ---- "basilisk binary not found" even though the LSP used it all session. ---- ---- See https://github.com/Nimblesite/Basilisk/issues/67. ---- ---- These tests stub the binary resolver and vim.health, so they run without the ---- real basilisk binary. - -local binary = require("basilisk.binary") -local config = require("basilisk.config") -local health = require("basilisk.health") - --- A path that is resolvable ONLY when supplied as the configured path, exactly --- the scenario from the issue (binary not on PATH or any well-known location). -local CONFIGURED = "/configured/only/path/to/basilisk" - -describe("basilisk :checkhealth binary resolution [issue #67]", function() - local orig_resolve - local orig_version - local orig_health - local orig_config - - before_each(function() - orig_resolve = binary.resolve - orig_version = binary.version - orig_health = vim.health - orig_config = require("basilisk").config - - -- Simulate a binary reachable only via the configured path: resolve() - -- with no/other argument finds nothing. - binary.resolve = function(configured_path) - if configured_path == CONFIGURED then - return CONFIGURED - end - return nil - end - binary.version = function(_) - return "0.1.0-test" - end - - -- Configure binary_path like setup({ binary_path = ... }). - require("basilisk").config = config.resolve({ binary_path = CONFIGURED }) - end) - - after_each(function() - binary.resolve = orig_resolve - binary.version = orig_version - vim.health = orig_health - require("basilisk").config = orig_config - end) - - --- Run health.check() with vim.health stubbed; return captured messages. - local function run_health() - local messages = { ok = {}, error = {}, warn = {}, info = {}, start = {} } - vim.health = { - start = function(s) table.insert(messages.start, s) end, - ok = function(s) table.insert(messages.ok, s) end, - error = function(s) table.insert(messages.error, s) end, - warn = function(s) table.insert(messages.warn, s) end, - info = function(s) table.insert(messages.info, s) end, - } - health.check() - return messages - end - - local function any_match(list, needle) - for _, msg in ipairs(list) do - if type(msg) == "string" and msg:find(needle, 1, true) then - return true - end - end - return false - end - - it("does not report 'binary not found' when binary_path is configured", function() - local messages = run_health() - assert.is_false( - any_match(messages.error, "basilisk binary not found"), - "health must not report 'binary not found' when a valid binary_path is configured" - ) - end) - - it("reports the configured binary as found", function() - local messages = run_health() - assert.is_true( - any_match(messages.ok, "basilisk binary found: " .. CONFIGURED), - "health should report the configured binary as found" - ) - end) -end) diff --git a/basilisk.nvim/tests/lsp/helpers.lua b/basilisk.nvim/tests/lsp/helpers.lua deleted file mode 100644 index aa584fc0d..000000000 --- a/basilisk.nvim/tests/lsp/helpers.lua +++ /dev/null @@ -1,267 +0,0 @@ ---- Real LSP integration test helpers. ---- ---- Starts the REAL basilisk LSP server, creates temp Python files, ---- and provides polling utilities for diagnostics, hover, etc. ---- NO MOCKING. Uses the actual basilisk binary. - -local M = {} - ---- Timeout constants (matching VS Code test-helpers.ts). -M.DIAGNOSTIC_TIMEOUT_MS = 15000 -M.NO_DIAGNOSTIC_WAIT_MS = 5000 -M.SERVER_START_WAIT_MS = 10000 ---- Budget for the semantic model to finish indexing after the client attaches. ---- Distinct from SERVER_START_WAIT_MS (client-attach): the ci-profile binary on ---- a slow runner needs more headroom to build the symbol table than to accept ---- the connection, so semantic readiness gets its own, larger budget. -M.MODEL_READY_WAIT_MS = 20000 - ---- Resolve the basilisk binary path. ----@return string? path -function M.find_binary() - -- 1. BASILISK_EXECUTABLE_PATH env var (for CI). - local env = vim.env.BASILISK_EXECUTABLE_PATH - if env and env ~= "" and vim.fn.executable(env) == 1 then - return env - end - - -- 2. target/debug/basilisk (repo build). - local repo_root = vim.fn.fnamemodify( - debug.getinfo(1, "S").source:sub(2), - ":h:h:h" - ) - local candidates = { - repo_root .. "/target/debug/basilisk", - repo_root .. "/target/release/basilisk", - vim.fn.expand("~/.cargo/bin/basilisk"), - } - for _, path in ipairs(candidates) do - if vim.fn.executable(path) == 1 then - return path - end - end - - -- 3. PATH. - local on_path = vim.fn.exepath("basilisk") - if on_path ~= "" then - return on_path - end - - return nil -end - ---- Create a temporary directory for test fixtures. ----@return string path -function M.create_tmpdir() - local tmpdir = vim.fn.tempname() .. "-basilisk-test" - vim.fn.mkdir(tmpdir, "p") - return tmpdir -end - ---- Write a Python file to the temp directory and open it in a buffer. ----@param tmpdir string ----@param filename string ----@param content string ----@return integer buf, string uri, string filepath -function M.open_python_file(tmpdir, filename, content) - local filepath = tmpdir .. "/" .. filename - local fh = io.open(filepath, "w") - assert(fh, "failed to create test file: " .. filepath) - fh:write(content) - fh:close() - - vim.cmd("edit " .. vim.fn.fnameescape(filepath)) - local buf = vim.api.nvim_get_current_buf() - -- Use uri_from_bufnr to get the canonical URI (handles macOS /var → /private/var symlinks). - local uri = vim.uri_from_bufnr(buf) - return buf, uri, filepath -end - ---- Replace the content of a buffer. ----@param buf integer ----@param content string -function M.replace_content(buf, content) - local lines = vim.split(content, "\n", { plain = true }) - vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines) - -- Trigger didChange. - vim.cmd("doautocmd TextChanged") -end - ---- Poll until a condition is met, or timeout. ----@param condition fun(remaining_ms: integer): boolean ----@param timeout_ms integer ----@param desc? string Description for error message. ----@return boolean success -function M.poll_until(condition, timeout_ms, desc) - local interval = 100 - local deadline = vim.uv.hrtime() + timeout_ms * 1000000 - - while vim.uv.hrtime() < deadline do - local remaining_ms = math.max(1, math.ceil((deadline - vim.uv.hrtime()) / 1000000)) - if condition(remaining_ms) then - return true - end - - local remaining_ns = deadline - vim.uv.hrtime() - if remaining_ns <= 0 then - break - end - vim.wait(math.min(interval, math.ceil(remaining_ns / 1000000))) - end - return false -end - ---- Wait for diagnostics to appear on a buffer. ----@param buf integer ----@param timeout_ms? integer Default DIAGNOSTIC_TIMEOUT_MS. ----@return vim.Diagnostic[] -function M.wait_for_diagnostics(buf, timeout_ms) - timeout_ms = timeout_ms or M.DIAGNOSTIC_TIMEOUT_MS - local diags = {} - M.poll_until(function() - diags = vim.diagnostic.get(buf) - return #diags > 0 - end, timeout_ms, "diagnostics") - return diags -end - ---- Wait for diagnostics to clear on a buffer. ----@param buf integer ----@param timeout_ms? integer Default NO_DIAGNOSTIC_WAIT_MS. ----@return boolean cleared -function M.wait_for_diagnostics_cleared(buf, timeout_ms) - timeout_ms = timeout_ms or M.NO_DIAGNOSTIC_WAIT_MS - return M.poll_until(function() - return #vim.diagnostic.get(buf) == 0 - end, timeout_ms, "diagnostics cleared") -end - ---- Wait for the basilisk LSP client to attach to a buffer. ----@param buf integer ----@param timeout_ms? integer Default SERVER_START_WAIT_MS. ----@return vim.lsp.Client? -function M.wait_for_client(buf, timeout_ms) - timeout_ms = timeout_ms or M.SERVER_START_WAIT_MS - local client = nil - M.poll_until(function() - local clients = vim.lsp.get_clients({ name = "basilisk", bufnr = buf }) - if #clients > 0 then - client = clients[1] - return true - end - return false - end, timeout_ms, "LSP client attach") - return client -end - ---- Wait for the LSP server to be fully ready to answer semantic queries. ---- ---- Readiness means the semantic model is INDEXED, not merely that the client ---- connection accepts requests. documentSymbol answers with an empty list while ---- the model is still building, and during that window every semantic feature ---- (rename / hover / definition / codeAction) returns null. Gating on a bare ---- response therefore lets callers fire real queries too early — a race that is ---- invisible on a fast release binary but fails reliably on the slower ---- ci-profile binary and older Neovim (0.11). Require an ACTUAL non-empty ---- symbol result so callers only proceed once the model can answer. ----@param buf integer ----@param timeout_ms? integer Budget for the model to finish indexing. ----@return boolean ready -function M.wait_for_server_ready(buf, timeout_ms) - timeout_ms = timeout_ms or M.MODEL_READY_WAIT_MS - local client = M.wait_for_client(buf, M.SERVER_START_WAIT_MS) - if not client then - return false - end - - local ready = false - M.poll_until(function(remaining_ms) - local result = nil - local done = false - client:request("textDocument/documentSymbol", { - textDocument = { uri = vim.uri_from_bufnr(buf) }, - }, function(err, res) - result = res - done = true - end, buf) - -- Wait for the response. - vim.wait(math.min(3000, remaining_ms), function() - return done - end) - -- Only a non-empty symbol list proves the model is indexed and semantic - -- features will resolve; an empty/absent result means indexing is still in - -- flight, so keep polling. - if done and type(result) == "table" and not vim.tbl_isempty(result) then - ready = true - return true - end - return false - end, timeout_ms, "server ready") - return ready -end - ---- Send an LSP request and wait for the response. ----@param client vim.lsp.Client ----@param method string ----@param params table ----@param buf integer ----@param timeout_ms? integer ----@return any? err, any? result -function M.lsp_request(client, method, params, buf, timeout_ms) - timeout_ms = timeout_ms or 5000 - local err_result = nil - local ok_result = nil - local done = false - - client:request(method, params, function(err, result) - err_result = err - ok_result = result - done = true - end, buf) - - vim.wait(timeout_ms, function() - return done - end) - - if not done then - return { message = "request timed out: " .. method }, nil - end - return err_result, ok_result -end - ---- Close all open buffers (cleanup between tests). -function M.close_all_buffers() - for _, win in ipairs(vim.api.nvim_list_wins()) do - local ok, config = pcall(vim.api.nvim_win_get_config, win) - if ok and config.relative ~= "" then - pcall(vim.api.nvim_win_close, win, true) - end - end - - for _, buf in ipairs(vim.api.nvim_list_bufs()) do - if vim.api.nvim_buf_is_valid(buf) and vim.api.nvim_buf_is_loaded(buf) then - pcall(vim.api.nvim_buf_delete, buf, { force = true }) - end - end -end - ---- Stop all basilisk LSP clients. -function M.stop_clients() - for _, client in ipairs(vim.lsp.get_clients({ name = "basilisk" })) do - client:stop(true) - end - -- Wait for clients to stop. - vim.wait(2000, function() - return #vim.lsp.get_clients({ name = "basilisk" }) == 0 - end) -end - ---- Clean up a temp directory. ----@param tmpdir string -function M.cleanup_tmpdir(tmpdir) - if tmpdir and vim.fn.isdirectory(tmpdir) == 1 then - vim.fn.delete(tmpdir, "rf") - end -end - -return M diff --git a/basilisk.nvim/tests/lsp/helpers_spec.lua b/basilisk.nvim/tests/lsp/helpers_spec.lua deleted file mode 100644 index 40f9d8636..000000000 --- a/basilisk.nvim/tests/lsp/helpers_spec.lua +++ /dev/null @@ -1,20 +0,0 @@ -local helpers = require("tests.lsp.helpers") - -describe("LSP test helpers", function() - it("poll_until charges condition execution time against its deadline", function() - local started = vim.uv.hrtime() - - local result = helpers.poll_until(function() - -- Simulate a request predicate that itself blocks. The old elapsed-time - -- counter ignored this work and then slept for another full interval, - -- allowing a nominal 30 ms deadline to take 140+ ms (and real LSP waits - -- to overrun the per-spec timeout by minutes). - vim.wait(40) - return false - end, 30, "blocking predicate") - - local elapsed_ms = (vim.uv.hrtime() - started) / 1e6 - assert.is_false(result) - assert.is_true(elapsed_ms < 100, ("deadline overran: %.1f ms"):format(elapsed_ms)) - end) -end) diff --git a/basilisk.nvim/tests/lsp/hover_spec.lua b/basilisk.nvim/tests/lsp/hover_spec.lua deleted file mode 100644 index 3d8414ecc..000000000 --- a/basilisk.nvim/tests/lsp/hover_spec.lua +++ /dev/null @@ -1,140 +0,0 @@ ---- Hover e2e tests — real LSP, no mocking. ---- ---- Tests hover content including type signatures and docstrings. - -local helpers = require("tests.lsp.helpers") - -local binary = helpers.find_binary() -if not binary then - describe("hover (SKIPPED — no binary)", function() - it("skipped", function() - pending("basilisk binary not found") - end) - end) - return -end - -local tmpdir - -describe("hover with real LSP", function() - before_each(function() - tmpdir = helpers.create_tmpdir() - local fh = io.open(tmpdir .. "/pyproject.toml", "w") - fh:write('[project]\nname = "test"\nversion = "0.1.0"\n') - fh:close() - - vim.lsp.config("basilisk", { - cmd = { binary, "lsp" }, - filetypes = { "python" }, - root_markers = { "pyproject.toml" }, - settings = { basilisk = { analysisMode = "wholeModule" } }, - }) - vim.lsp.enable("basilisk") - end) - - after_each(function() - helpers.stop_clients() - helpers.close_all_buffers() - helpers.cleanup_tmpdir(tmpdir) - end) - - it("hover shows type signature for a function", function() - local buf = helpers.open_python_file(tmpdir, "test_hover_sig.py", - "def helper(x: int, y: str) -> bool:\n return len(y) > x\n\nresult = helper(3, 'hello')\n") - local client = helpers.wait_for_client(buf) - assert.is_not_nil(client) - helpers.wait_for_server_ready(buf) - - local err, result = helpers.lsp_request(client, "textDocument/hover", { - textDocument = { uri = vim.uri_from_bufnr(buf) }, - position = { line = 0, character = 4 }, - }, buf) - - assert.is_nil(err) - assert.is_not_nil(result) - assert.is_not_nil(result.contents) - end) - - it("hover shows type for a variable", function() - local buf = helpers.open_python_file(tmpdir, "test_hover_var.py", - "x: int = 42\ny: str = 'hello'\n") - local client = helpers.wait_for_client(buf) - assert.is_not_nil(client) - helpers.wait_for_server_ready(buf) - - local err, result = helpers.lsp_request(client, "textDocument/hover", { - textDocument = { uri = vim.uri_from_bufnr(buf) }, - position = { line = 0, character = 0 }, - }, buf) - - assert.is_nil(err) - assert.is_not_nil(result) - end) - - it("hover shows docstring for function", function() - local buf = helpers.open_python_file(tmpdir, "test_hover_doc.py", table.concat({ - 'def greet(name: str) -> str:', - ' """Return a greeting for the given name."""', - ' return f"Hello, {name}!"', - '', - 'result = greet("world")', - '', - }, "\n")) - local client = helpers.wait_for_client(buf) - assert.is_not_nil(client) - helpers.wait_for_server_ready(buf) - - local err, result = helpers.lsp_request(client, "textDocument/hover", { - textDocument = { uri = vim.uri_from_bufnr(buf) }, - position = { line = 4, character = 9 }, - }, buf) - - assert.is_nil(err) - assert.is_not_nil(result) - if result and result.contents then - local text = type(result.contents) == "string" and result.contents - or result.contents.value or vim.inspect(result.contents) - -- The hover should contain the docstring or the function signature. - assert.truthy(text:find("greet") or text:find("str"), "hover should show function info") - end - end) - - it("hover on class shows class info", function() - local buf = helpers.open_python_file(tmpdir, "test_hover_class.py", table.concat({ - 'class Point:', - ' """A 2D point."""', - ' def __init__(self, x: int, y: int) -> None:', - ' self.x = x', - ' self.y = y', - '', - 'p = Point(1, 2)', - '', - }, "\n")) - local client = helpers.wait_for_client(buf) - assert.is_not_nil(client) - helpers.wait_for_server_ready(buf) - - local err, result = helpers.lsp_request(client, "textDocument/hover", { - textDocument = { uri = vim.uri_from_bufnr(buf) }, - position = { line = 6, character = 4 }, - }, buf) - - assert.is_nil(err) - assert.is_not_nil(result) - end) - - it("hover returns nil for whitespace", function() - local buf = helpers.open_python_file(tmpdir, "test_hover_empty.py", "\n\n\nx: int = 1\n") - local client = helpers.wait_for_client(buf) - assert.is_not_nil(client) - helpers.wait_for_server_ready(buf) - - local err, result = helpers.lsp_request(client, "textDocument/hover", { - textDocument = { uri = vim.uri_from_bufnr(buf) }, - position = { line = 0, character = 0 }, - }, buf) - - assert.is_nil(err) - -- Result should be nil for empty space. - end) -end) diff --git a/basilisk.nvim/tests/lsp/integration_spec.lua b/basilisk.nvim/tests/lsp/integration_spec.lua deleted file mode 100644 index 2a81baf35..000000000 --- a/basilisk.nvim/tests/lsp/integration_spec.lua +++ /dev/null @@ -1,450 +0,0 @@ ---- Real LSP integration tests for basilisk.nvim. ---- ---- These tests use the REAL basilisk LSP server. No mocking. ---- Requires the basilisk binary to be available (target/debug/basilisk ---- or on PATH). - -local helpers = require("tests.lsp.helpers") - --- Skip entire suite if no binary available. -local binary = helpers.find_binary() -if not binary then - describe("basilisk LSP integration (SKIPPED — no binary)", function() - it("skipped: basilisk binary not found", function() - pending("basilisk binary not found — build with `cargo build --bin basilisk`") - end) - end) - return -end - --- Configure basilisk to use the found binary. -local tmpdir - -describe("basilisk LSP integration", function() - before_each(function() - tmpdir = helpers.create_tmpdir() - - -- Write a pyproject.toml so basilisk finds a project root. - -- [tool.basilisk.rules] opts into the annotation house rules (off by - -- default) so untyped-parameter diagnostics fire — mirrors the Rust LSP - -- harness fixture (ws_test_common.rs). - local fh = io.open(tmpdir .. "/pyproject.toml", "w") - fh:write('[project]\nname = "test"\nversion = "0.1.0"\n') - fh:write('\n[tool.basilisk.rules]\n"BSK-0001" = "error"\n"BSK-0002" = "error"\n') - fh:close() - - -- Configure and start the LSP client directly (not via setup()). - vim.lsp.config("basilisk", { - cmd = { binary, "lsp" }, - filetypes = { "python" }, - root_markers = { "pyproject.toml", ".git" }, - settings = { - basilisk = { - analysisMode = "wholeModule", - }, - }, - }) - vim.lsp.enable("basilisk") - end) - - after_each(function() - helpers.stop_clients() - helpers.close_all_buffers() - helpers.cleanup_tmpdir(tmpdir) - end) - - -- Core LSP: Diagnostics - - it("produces diagnostics for untyped parameters", function() - local buf = helpers.open_python_file(tmpdir, "test_untyped.py", "def greet(name):\n return name\n") - local ready = helpers.wait_for_server_ready(buf) - assert.is_true(ready, "LSP server did not become ready") - - local diags = helpers.wait_for_diagnostics(buf) - assert.is_true(#diags > 0, "expected diagnostics for untyped parameter") - end) - - it("clears diagnostics when errors are fixed", function() - local buf = helpers.open_python_file(tmpdir, "test_fix.py", "def greet(name):\n return name\n") - helpers.wait_for_server_ready(buf) - helpers.wait_for_diagnostics(buf) - - -- Fix the code by adding types. - helpers.replace_content(buf, "def greet(name: str) -> str:\n return name\n") - vim.cmd("write") - - local cleared = helpers.wait_for_diagnostics_cleared(buf) - assert.is_true(cleared, "diagnostics should clear after fix") - end) - - it("shows no diagnostics for fully typed code", function() - local buf = helpers.open_python_file(tmpdir, "test_typed.py", "def greet(name: str) -> str:\n return name\n") - helpers.wait_for_server_ready(buf) - - -- Wait a bit and verify no diagnostics appear. - vim.wait(3000) - local diags = vim.diagnostic.get(buf) - assert.are.equal(0, #diags, "fully typed code should have no diagnostics") - end) - - it("updates diagnostics on file change", function() - local buf = helpers.open_python_file(tmpdir, "test_change.py", "def greet(name: str) -> str:\n return name\n") - helpers.wait_for_server_ready(buf) - vim.wait(2000) - assert.are.equal(0, #vim.diagnostic.get(buf)) - - -- Introduce an error. - helpers.replace_content(buf, "def greet(name):\n return name\n") - vim.cmd("write") - - local diags = helpers.wait_for_diagnostics(buf) - assert.is_true(#diags > 0, "expected diagnostics after introducing untyped param") - end) - - -- Core LSP: Hover - - it("hover provides type information", function() - local buf = helpers.open_python_file(tmpdir, "test_hover.py", "def helper(x: int) -> int:\n return x + 1\n\nresult = helper(42)\n") - local client = helpers.wait_for_client(buf) - assert.is_not_nil(client) - helpers.wait_for_server_ready(buf) - - local err, result = helpers.lsp_request(client, "textDocument/hover", { - textDocument = { uri = vim.uri_from_bufnr(buf) }, - position = { line = 0, character = 4 }, - }, buf) - - assert.is_nil(err, "hover request should not error") - assert.is_not_nil(result, "hover should return a result") - if result then - assert.is_not_nil(result.contents, "hover should have contents") - end - end) - - -- Core LSP: Go to Definition - - it("go-to-definition works", function() - local buf = helpers.open_python_file(tmpdir, "test_def.py", "def helper(x: int) -> int:\n return x + 1\n\nresult = helper(42)\n") - local client = helpers.wait_for_client(buf) - assert.is_not_nil(client) - helpers.wait_for_server_ready(buf) - - local err, result = helpers.lsp_request(client, "textDocument/definition", { - textDocument = { uri = vim.uri_from_bufnr(buf) }, - position = { line = 3, character = 9 }, - }, buf) - - assert.is_nil(err, "definition request should not error") - assert.is_not_nil(result, "should find definition") - end) - - -- Core LSP: Completions - - it("completions include local symbols", function() - local buf = helpers.open_python_file(tmpdir, "test_comp.py", "def my_helper_function(x: int) -> int:\n return x\n\nmy_\n") - local client = helpers.wait_for_client(buf) - assert.is_not_nil(client) - helpers.wait_for_server_ready(buf) - - local err, result = helpers.lsp_request(client, "textDocument/completion", { - textDocument = { uri = vim.uri_from_bufnr(buf) }, - position = { line = 3, character = 3 }, - }, buf) - - assert.is_nil(err, "completion request should not error") - if result then - local items = result.items or result - assert.is_true(#items > 0, "should have completion items") - end - end) - - -- Core LSP: Document Symbols - - it("document symbols include classes and functions", function() - local buf = helpers.open_python_file(tmpdir, "test_symbols.py", "class MyClass:\n def method(self) -> None:\n pass\n\ndef standalone() -> None:\n pass\n") - local client = helpers.wait_for_client(buf) - assert.is_not_nil(client) - helpers.wait_for_server_ready(buf) - - local err, result = helpers.lsp_request(client, "textDocument/documentSymbol", { - textDocument = { uri = vim.uri_from_bufnr(buf) }, - }, buf) - - assert.is_nil(err, "documentSymbol request should not error") - assert.is_not_nil(result, "should return symbols") - if result then - assert.is_true(#result >= 2, "should have at least class + function symbols") - end - end) - - -- Core LSP: Signature Help - - it("signature help works", function() - local buf = helpers.open_python_file(tmpdir, "test_sig.py", "def helper(x: int, y: str) -> int:\n return x\n\nhelper(\n") - local client = helpers.wait_for_client(buf) - assert.is_not_nil(client) - helpers.wait_for_server_ready(buf) - - local err, result = helpers.lsp_request(client, "textDocument/signatureHelp", { - textDocument = { uri = vim.uri_from_bufnr(buf) }, - position = { line = 3, character = 7 }, - }, buf) - - assert.is_nil(err, "signatureHelp request should not error") - -- Result may be nil if server doesn't support it yet — that's ok. - end) - - -- Core LSP: Find References - - it("find references works", function() - local buf = helpers.open_python_file(tmpdir, "test_refs.py", "def helper(x: int) -> int:\n return x + 1\n\na = helper(1)\nb = helper(2)\n") - local client = helpers.wait_for_client(buf) - assert.is_not_nil(client) - helpers.wait_for_server_ready(buf) - - local err, result = helpers.lsp_request(client, "textDocument/references", { - textDocument = { uri = vim.uri_from_bufnr(buf) }, - position = { line = 0, character = 4 }, - context = { includeDeclaration = true }, - }, buf) - - assert.is_nil(err, "references request should not error") - if result then - assert.is_true(#result >= 2, "should find definition + call sites") - end - end) - - -- Core LSP: Rename - - it("rename symbol works", function() - local buf = helpers.open_python_file(tmpdir, "test_rename.py", "def helper(x: int) -> int:\n return x + 1\n\nresult = helper(42)\n") - local client = helpers.wait_for_client(buf) - assert.is_not_nil(client) - helpers.wait_for_server_ready(buf) - - local err, result = helpers.lsp_request(client, "textDocument/rename", { - textDocument = { uri = vim.uri_from_bufnr(buf) }, - position = { line = 0, character = 4 }, - newName = "my_helper", - }, buf) - - assert.is_nil(err, "rename request should not error") - if result then - assert.is_not_nil(result.changes or result.documentChanges, "rename should produce workspace edit") - end - end) - - -- Core LSP: Code Actions - - it("code actions provided for diagnostics", function() - local buf = helpers.open_python_file(tmpdir, "test_actions.py", "def greet(name):\n return name\n") - local client = helpers.wait_for_client(buf) - assert.is_not_nil(client) - helpers.wait_for_server_ready(buf) - helpers.wait_for_diagnostics(buf) - - local diags = vim.diagnostic.get(buf) - local err, result = helpers.lsp_request(client, "textDocument/codeAction", { - textDocument = { uri = vim.uri_from_bufnr(buf) }, - range = { - start = { line = 0, character = 0 }, - ["end"] = { line = 0, character = 20 }, - }, - context = { - -- vim.lsp.diagnostic.get_line_diagnostics was removed in Neovim nightly - -- (deprecated in 0.11/0.12). Use the stable vim.diagnostic.get API and - -- recover each diagnostic's original LSP shape from user_data.lsp, which - -- is what a codeAction context expects. Works on 0.11 and nightly alike. - diagnostics = vim.tbl_map(function(diagnostic) - return (diagnostic.user_data and diagnostic.user_data.lsp) or diagnostic - end, vim.diagnostic.get(buf, { lnum = 0 })), - }, - }, buf) - - assert.is_nil(err, "codeAction request should not error") - -- Code actions may or may not be available depending on server capability. - end) - - -- Core LSP: Formatting - - it("format document works", function() - local buf = helpers.open_python_file(tmpdir, "test_fmt.py", "def greet( name: str )->str:\n return name\n") - local client = helpers.wait_for_client(buf) - assert.is_not_nil(client) - helpers.wait_for_server_ready(buf) - - local err, result = helpers.lsp_request(client, "textDocument/formatting", { - textDocument = { uri = vim.uri_from_bufnr(buf) }, - options = { tabSize = 4, insertSpaces = true }, - }, buf) - - assert.is_nil(err, "formatting request should not error") - -- The Ruff formatter is embedded in the binary ([LSPFMT-ENGINE]) — badly - -- formatted code must yield edits, never a silent nil (#254). - assert.is_not_nil(result, "embedded formatter must return edits") - end) - - -- Core LSP: Inlay Hints - - it("inlay hints appear for unannotated variables", function() - local buf = helpers.open_python_file(tmpdir, "test_inlay.py", "x = 42\ny = 'hello'\nz = [1, 2, 3]\n") - local client = helpers.wait_for_client(buf) - assert.is_not_nil(client) - helpers.wait_for_server_ready(buf) - - local err, result = helpers.lsp_request(client, "textDocument/inlayHint", { - textDocument = { uri = vim.uri_from_bufnr(buf) }, - range = { - start = { line = 0, character = 0 }, - ["end"] = { line = 3, character = 0 }, - }, - }, buf) - - assert.is_nil(err, "inlayHint request should not error") - -- Hints may or may not be returned depending on server capability. - end) - - -- Core LSP: Document Highlight - - it("document highlight works", function() - local buf = helpers.open_python_file(tmpdir, "test_highlight.py", "def helper(x: int) -> int:\n return x + 1\n\nresult = helper(42)\n") - local client = helpers.wait_for_client(buf) - assert.is_not_nil(client) - helpers.wait_for_server_ready(buf) - - local err, result = helpers.lsp_request(client, "textDocument/documentHighlight", { - textDocument = { uri = vim.uri_from_bufnr(buf) }, - position = { line = 0, character = 4 }, - }, buf) - - assert.is_nil(err, "documentHighlight request should not error") - end) - - -- Core LSP: Folding Ranges - - it("folding ranges work", function() - local buf = helpers.open_python_file(tmpdir, "test_fold.py", "class MyClass:\n def method(self) -> None:\n pass\n\ndef standalone() -> None:\n pass\n") - local client = helpers.wait_for_client(buf) - assert.is_not_nil(client) - helpers.wait_for_server_ready(buf) - - local err, result = helpers.lsp_request(client, "textDocument/foldingRange", { - textDocument = { uri = vim.uri_from_bufnr(buf) }, - }, buf) - - assert.is_nil(err, "foldingRange request should not error") - end) - - -- Core LSP: Selection Range - - it("selection range works", function() - local buf = helpers.open_python_file(tmpdir, "test_sel.py", "def helper(x: int) -> int:\n return x + 1\n") - local client = helpers.wait_for_client(buf) - assert.is_not_nil(client) - helpers.wait_for_server_ready(buf) - - local err, result = helpers.lsp_request(client, "textDocument/selectionRange", { - textDocument = { uri = vim.uri_from_bufnr(buf) }, - positions = { { line = 1, character = 4 } }, - }, buf) - - assert.is_nil(err, "selectionRange request should not error") - end) - - -- Core LSP: Semantic Tokens - - it("semantic tokens work", function() - local buf = helpers.open_python_file(tmpdir, "test_tokens.py", "class Point:\n x: int\n y: int\n\np = Point()\n") - local client = helpers.wait_for_client(buf) - assert.is_not_nil(client) - helpers.wait_for_server_ready(buf) - - local err, result = helpers.lsp_request(client, "textDocument/semanticTokens/full", { - textDocument = { uri = vim.uri_from_bufnr(buf) }, - }, buf) - - assert.is_nil(err, "semanticTokens request should not error") - end) - - -- Core LSP: Code Lens - - it("code lens works", function() - local buf = helpers.open_python_file(tmpdir, "test_lens.py", "def helper(x: int) -> int:\n return x + 1\n\nresult = helper(42)\n") - local client = helpers.wait_for_client(buf) - assert.is_not_nil(client) - helpers.wait_for_server_ready(buf) - - local err, result = helpers.lsp_request(client, "textDocument/codeLens", { - textDocument = { uri = vim.uri_from_bufnr(buf) }, - }, buf) - - assert.is_nil(err, "codeLens request should not error") - end) - - -- Core LSP: Call Hierarchy - - it("call hierarchy works", function() - local buf = helpers.open_python_file(tmpdir, "test_call.py", "def helper(x: int) -> int:\n return x + 1\n\ndef caller() -> int:\n return helper(42)\n") - local client = helpers.wait_for_client(buf) - assert.is_not_nil(client) - helpers.wait_for_server_ready(buf) - - local err, result = helpers.lsp_request(client, "textDocument/prepareCallHierarchy", { - textDocument = { uri = vim.uri_from_bufnr(buf) }, - position = { line = 0, character = 4 }, - }, buf) - - assert.is_nil(err, "prepareCallHierarchy request should not error") - end) - - -- Core LSP: Type Hierarchy - - it("type hierarchy works", function() - local buf = helpers.open_python_file(tmpdir, "test_type.py", "class Base:\n pass\n\nclass Child(Base):\n pass\n") - local client = helpers.wait_for_client(buf) - assert.is_not_nil(client) - helpers.wait_for_server_ready(buf) - - local err, result = helpers.lsp_request(client, "textDocument/prepareTypeHierarchy", { - textDocument = { uri = vim.uri_from_bufnr(buf) }, - position = { line = 3, character = 6 }, - }, buf) - - assert.is_nil(err, "prepareTypeHierarchy request should not error") - end) - - -- Multiple Files - - it("multiple files get independent diagnostics", function() - local buf1 = helpers.open_python_file(tmpdir, "file_a.py", "def func_a(x):\n return x\n") - helpers.wait_for_server_ready(buf1) - local diags1 = helpers.wait_for_diagnostics(buf1) - assert.is_true(#diags1 > 0, "file_a should have diagnostics") - - local buf2 = helpers.open_python_file(tmpdir, "file_b.py", "def func_b(x: int) -> int:\n return x\n") - vim.wait(3000) - local diags2 = vim.diagnostic.get(buf2) - assert.are.equal(0, #diags2, "file_b should have no diagnostics") - - -- file_a should still have its diagnostics. - assert.is_true(#vim.diagnostic.get(buf1) > 0, "file_a diagnostics should persist") - end) - - -- Server Lifecycle - - it("server restarts and remains functional", function() - local buf = helpers.open_python_file(tmpdir, "test_restart.py", "def greet(name: str) -> str:\n return name\n") - helpers.wait_for_server_ready(buf) - - -- Stop and restart. - helpers.stop_clients() - vim.lsp.enable("basilisk") - - -- Reopen file to trigger re-attach. - vim.cmd("edit " .. vim.fn.fnameescape(tmpdir .. "/test_restart.py")) - buf = vim.api.nvim_get_current_buf() - - local ready = helpers.wait_for_server_ready(buf) - assert.is_true(ready, "server should be functional after restart") - end) -end) diff --git a/basilisk.nvim/tests/lsp/memory_profiler_spec.lua b/basilisk.nvim/tests/lsp/memory_profiler_spec.lua deleted file mode 100644 index 5b3ec2bf7..000000000 --- a/basilisk.nvim/tests/lsp/memory_profiler_spec.lua +++ /dev/null @@ -1,722 +0,0 @@ ---- Memory Profiler E2E tests for the Basilisk Neovim extension. ---- ---- Tests [NVIM-USER-COMMANDS-MEMORY-UI] (leak report, retention paths, ---- :BasiliskMemRefs completion). ---- ---- Full parity with vscode-extension/src/test/suite/profiler.test.ts ---- (Memory Profiler sections). ---- Validates the complete memory profiling workflow: ---- - Memory profiler commands are registered and callable ---- - Memory start/snapshot/stop/refs lifecycle ---- - Leak report display in floating windows ---- - Retention path visualization ---- - Memory type structures (MemoryAllocation, MemorySnapshotResult, etc.) ---- - Leak confidence levels and severity ordering ---- - SuspectedLeak and MemoryDiffResult types ---- - Memory decorations apply and clear without throwing ---- - Type completion for :BasiliskMemRefs ---- ---- These tests require the Basilisk LSP server binary to be built. ---- They exercise the real LSP protocol, not mocks. - -local helpers = require("tests.lsp.helpers") - -local binary = helpers.find_binary() -if not binary then - describe("memory profiler e2e (SKIPPED -- no binary)", function() - it("skipped", function() - pending("basilisk binary not found") - end) - end) - return -end - ---- Close all floating windows. -local function close_floats() - for _, win in ipairs(vim.api.nvim_list_wins()) do - local config = vim.api.nvim_win_get_config(win) - if config.relative and config.relative ~= "" then - pcall(vim.api.nvim_win_close, win, true) - end - end -end - ---- Send an LSP executeCommand and return err, result synchronously. ----@param client vim.lsp.Client ----@param command string ----@param arguments? table ----@param buf integer ----@return any? err, any? result -local function execute_lsp_command(client, command, arguments, buf) - return helpers.lsp_request(client, "workspace/executeCommand", { - command = command, - arguments = arguments or {}, - }, buf, 5000) -end - -local tmpdir - --- ============================================================================ --- Memory Command Registration --- ============================================================================ - -describe("memory profiler -- command registration", function() - before_each(function() - tmpdir = helpers.create_tmpdir() - local fh = io.open(tmpdir .. "/pyproject.toml", "w") - fh:write('[project]\nname = "test"\nversion = "0.1.0"\n') - fh:close() - - vim.lsp.config("basilisk", { - cmd = { binary, "lsp" }, - filetypes = { "python" }, - root_markers = { "pyproject.toml", ".git" }, - settings = { basilisk = { analysisMode = "wholeModule" } }, - }) - vim.lsp.enable("basilisk") - end) - - after_each(function() - close_floats() - helpers.stop_clients() - helpers.close_all_buffers() - helpers.cleanup_tmpdir(tmpdir) - end) - - it("all memory user commands are registered", function() - local buf = helpers.open_python_file(tmpdir, "test_mem_reg.py", "x: int = 1\n") - helpers.wait_for_server_ready(buf) - - local basilisk = require("basilisk") - basilisk.config = require("basilisk.config").resolve({ binary_path = binary }) - require("basilisk.commands").register(basilisk.config) - - local memory_commands = { "BasiliskMemLeak", "BasiliskMemStop", "BasiliskMemRefs" } - for _, cmd in ipairs(memory_commands) do - local exists = pcall(function() - vim.api.nvim_parse_cmd(cmd .. " dict", {}) - end) - -- BasiliskMemRefs needs an arg, but parse_cmd should still recognize it. - -- For MemLeak/MemStop, parse_cmd without args is fine. - if cmd ~= "BasiliskMemRefs" then - exists = pcall(function() - vim.api.nvim_parse_cmd(cmd, {}) - end) - end - assert.is_true(exists, "command " .. cmd .. " should be registered") - end - end) - - it("memory client commands do not crash when called", function() - local buf = helpers.open_python_file(tmpdir, "test_mem_nocrash.py", "x: int = 1\n") - helpers.wait_for_server_ready(buf) - - local basilisk = require("basilisk") - basilisk.config = require("basilisk.config").resolve({ binary_path = binary }) - require("basilisk.commands").register(basilisk.config) - - assert.has_no.errors(function() - pcall(vim.cmd, "BasiliskMemLeak") - end) - assert.has_no.errors(function() - pcall(vim.cmd, "BasiliskMemStop") - end) - assert.has_no.errors(function() - pcall(vim.cmd, "BasiliskMemRefs dict") - end) - end) - - it("memory and profiler user commands are all distinct", function() - local all_commands = { - "BasiliskProfile", - "BasiliskProfileStop", - "BasiliskProfileSnapshot", - "BasiliskMemLeak", - "BasiliskMemStop", - "BasiliskMemRefs", - } - - local seen = {} - for _, cmd in ipairs(all_commands) do - assert.is_nil(seen[cmd], "command should be unique: " .. cmd) - seen[cmd] = true - end - assert.are.equal(6, vim.tbl_count(seen), "should have 6 unique commands") - end) -end) - --- ============================================================================ --- Memory Profiler Lifecycle (with real LSP) --- ============================================================================ - -describe("memory profiler -- lifecycle", function() - before_each(function() - tmpdir = helpers.create_tmpdir() - local fh = io.open(tmpdir .. "/pyproject.toml", "w") - fh:write('[project]\nname = "test"\nversion = "0.1.0"\n') - fh:close() - - vim.lsp.config("basilisk", { - cmd = { binary, "lsp" }, - filetypes = { "python" }, - root_markers = { "pyproject.toml", ".git" }, - settings = { basilisk = { analysisMode = "wholeModule" } }, - }) - vim.lsp.enable("basilisk") - end) - - after_each(function() - close_floats() - helpers.stop_clients() - helpers.close_all_buffers() - helpers.cleanup_tmpdir(tmpdir) - end) - - it("memoryStart is callable and returns without crash", function() - local buf = helpers.open_python_file(tmpdir, "test_mem_start.py", "x: int = 1\n") - helpers.wait_for_server_ready(buf) - - local basilisk = require("basilisk") - basilisk.config = require("basilisk.config").resolve({ binary_path = binary }) - require("basilisk.commands").register(basilisk.config) - - -- Should not crash even if memory tracking is not available. - assert.has_no.errors(function() - pcall(vim.cmd, "BasiliskMemLeak") - end) - vim.wait(500) - end) - - it("memorySnapshot without active session warns gracefully", function() - local buf = helpers.open_python_file(tmpdir, "test_mem_snap.py", "x: int = 1\n") - helpers.wait_for_server_ready(buf) - - local memory = require("basilisk.memory") - -- stop() without active session should not crash. - assert.has_no.errors(function() - memory.stop() - end) - vim.wait(500) - end) - - it("memoryRefs is callable", function() - local buf = helpers.open_python_file(tmpdir, "test_mem_refs.py", "x: int = 1\n") - helpers.wait_for_server_ready(buf) - - local memory = require("basilisk.memory") - -- refs() with a type name should not crash. - assert.has_no.errors(function() - memory.refs("dict") - end) - vim.wait(500) - end) -end) - --- ============================================================================ --- Memory Type Structures --- ============================================================================ - -describe("memory profiler -- type structures", function() - after_each(function() - close_floats() - end) - - it("MemoryAllocation type has required fields", function() - local alloc = { - file = "/src/data.py", - line = 100, - size = 10485760, - count = 5000, - } - - assert.are.equal("/src/data.py", alloc.file) - assert.are.equal(100, alloc.line) - assert.are.equal(10485760, alloc.size) - assert.are.equal(5000, alloc.count) - end) - - it("MemoryAllocation type enforces required fields", function() - local alloc = { - file = "/src/allocator.py", - line = 55, - size = 52428800, - count = 10000, - } - - assert.are.equal("/src/allocator.py", alloc.file) - assert.are.equal(55, alloc.line) - assert.are.equal(52428800, alloc.size) - assert.are.equal(10000, alloc.count) - end) - - it("MemorySnapshotResult type has required fields", function() - local snapshot = { - memorySessionId = "mem-session-001", - snapshotId = "snap-001", - currentMemory = 50000000, - peakMemory = 75000000, - topAllocations = {}, - } - - assert.are.equal("mem-session-001", snapshot.memorySessionId) - assert.are.equal("snap-001", snapshot.snapshotId) - assert.are.equal(50000000, snapshot.currentMemory) - assert.are.equal(75000000, snapshot.peakMemory) - assert.is_table(snapshot.topAllocations, "topAllocations should be a table") - end) - - it("SuspectedLeak type has all required fields", function() - local leak = { - file = "/src/leaky.py", - line = 42, - sizeGrowth = 1048576, - countGrowth = 500, - currentSize = 5242880, - confidence = "HIGH", - reason = "Monotonic growth detected across 10 snapshots", - } - - assert.are.equal("/src/leaky.py", leak.file) - assert.are.equal(42, leak.line) - assert.are.equal(1048576, leak.sizeGrowth) - assert.are.equal(500, leak.countGrowth) - assert.are.equal(5242880, leak.currentSize) - assert.are.equal("HIGH", leak.confidence) - assert.is_true(#leak.reason > 0, "leak reason should be non-empty") - end) - - it("MemoryDiffResult type has all required fields", function() - local diff = { - totalGrowth = 10485760, - totalFreed = 2097152, - netGrowth = 8388608, - suspectedLeaks = { - { - file = "/src/data.py", - line = 10, - sizeGrowth = 5242880, - countGrowth = 200, - currentSize = 10485760, - confidence = "DEFINITE", - reason = "Allocation grows every snapshot with zero frees", - }, - }, - } - - assert.are.equal(10485760, diff.totalGrowth) - assert.are.equal(2097152, diff.totalFreed) - assert.are.equal(8388608, diff.netGrowth) - assert.are.equal(1, #diff.suspectedLeaks) - assert.are.equal("DEFINITE", diff.suspectedLeaks[1].confidence) - end) - - it("leak confidence levels map to correct severity ordering", function() - local confidences = { "LOW", "MEDIUM", "HIGH", "DEFINITE" } - local severity_order = { - LOW = 0, - MEDIUM = 1, - HIGH = 2, - DEFINITE = 3, - } - - assert.are.equal(4, #confidences, "should be exactly 4 confidence levels") - - for idx = 1, #confidences - 1 do - local current = severity_order[confidences[idx]] - local next_val = severity_order[confidences[idx + 1]] - assert.is_not_nil(current, "severity for " .. confidences[idx] .. " must be defined") - assert.is_not_nil(next_val, "severity for " .. confidences[idx + 1] .. " must be defined") - assert.is_true( - current < next_val, - confidences[idx] .. " should have lower severity than " .. confidences[idx + 1] - ) - end - end) - - it("MemorySnapshotResult with populated allocations validates structure", function() - local snapshot = { - memorySessionId = "mem-populated", - snapshotId = "snap-pop-001", - currentMemory = 104857600, - peakMemory = 209715200, - topAllocations = { - { file = "/nonexistent/a.py", line = 1, size = 52428800, count = 5000 }, - { file = "/nonexistent/b.py", line = 15, size = 10485760, count = 1000 }, - { file = "/nonexistent/c.py", line = 30, size = 1048576, count = 100 }, - }, - } - - assert.are.equal(3, #snapshot.topAllocations, "should have 3 allocations") - assert.is_true( - snapshot.currentMemory <= snapshot.peakMemory, - "currentMemory should not exceed peakMemory" - ) - end) - - it("MemoryDiffResult validates net growth calculation", function() - local diff = { - totalGrowth = 5242880, - totalFreed = 524288, - netGrowth = 4718592, - suspectedLeaks = { - { - file = "/nonexistent/leaky.py", - line = 3, - sizeGrowth = 2097152, - countGrowth = 300, - currentSize = 8388608, - confidence = "HIGH", - reason = "Monotonic growth pattern", - }, - }, - } - - assert.is_true(diff.netGrowth > 0, "net growth should be positive for a leak") - assert.is_true( - diff.totalGrowth > diff.totalFreed, - "totalGrowth should exceed totalFreed when there is a net leak" - ) - assert.are.equal( - diff.totalGrowth - diff.totalFreed, - diff.netGrowth, - "netGrowth should equal totalGrowth - totalFreed" - ) - end) -end) - --- ============================================================================ --- Memory Display --- ============================================================================ - -describe("memory profiler -- display", function() - after_each(function() - close_floats() - end) - - it("display_leak_report handles nil gracefully", function() - local memory = require("basilisk.memory") - assert.has_no.errors(function() - memory.display_leak_report(nil) - end) - close_floats() - end) - - it("display_leak_report shows leaks in floating window", function() - local memory = require("basilisk.memory") - local result = { - leaks = { - { typeName = "DataFrame", count = 15, totalSize = "1.2MB", location = { file = "/tmp/test.py", line = 42 } }, - { typeName = "dict", count = 100, totalSize = "500KB" }, - }, - } - - assert.has_no.errors(function() - memory.display_leak_report(result) - end) - - local found = false - for _, win in ipairs(vim.api.nvim_list_wins()) do - local config = vim.api.nvim_win_get_config(win) - if config.relative and config.relative ~= "" then - local buf = vim.api.nvim_win_get_buf(win) - local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) - local text = table.concat(lines, "\n") - assert.truthy(text:find("DataFrame"), "should contain 'DataFrame'") - assert.truthy(text:find("dict"), "should contain 'dict'") - assert.truthy(text:find("15 objects"), "should contain '15 objects'") - found = true - vim.api.nvim_win_close(win, true) - end - end - assert.is_true(found, "should open floating window with leak report") - end) - - it("display_retention_paths handles nil gracefully", function() - local memory = require("basilisk.memory") - assert.has_no.errors(function() - memory.display_retention_paths("dict", nil) - end) - close_floats() - end) - - it("display_retention_paths shows paths in floating window", function() - local memory = require("basilisk.memory") - local result = { - retentionPaths = { - { - confidence = 0.85, - steps = { - { name = "global_cache", kind = "variable" }, - { name = "__dict__", kind = "attribute" }, - }, - }, - }, - } - - assert.has_no.errors(function() - memory.display_retention_paths("DataFrame", result) - end) - - local found = false - for _, win in ipairs(vim.api.nvim_list_wins()) do - local config = vim.api.nvim_win_get_config(win) - if config.relative and config.relative ~= "" then - local buf = vim.api.nvim_win_get_buf(win) - local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) - local text = table.concat(lines, "\n") - assert.truthy(text:find("DataFrame"), "should contain 'DataFrame'") - assert.truthy(text:find("global_cache"), "should contain 'global_cache'") - assert.truthy(text:find("85%%"), "should contain confidence percentage") - found = true - vim.api.nvim_win_close(win, true) - end - end - assert.is_true(found, "should open floating window with retention paths") - end) - - it("display_leak_report with no leaks shows empty message", function() - local memory = require("basilisk.memory") - assert.has_no.errors(function() - memory.display_leak_report({ leaks = {} }) - end) - - local found = false - for _, win in ipairs(vim.api.nvim_list_wins()) do - local config = vim.api.nvim_win_get_config(win) - if config.relative and config.relative ~= "" then - local buf = vim.api.nvim_win_get_buf(win) - local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) - local text = table.concat(lines, "\n") - assert.truthy(text:find("No leaks"), "should say no leaks detected") - found = true - vim.api.nvim_win_close(win, true) - end - end - assert.is_true(found, "should open floating window with empty message") - end) - - it("display_retention_paths with no paths shows empty message", function() - local memory = require("basilisk.memory") - assert.has_no.errors(function() - memory.display_retention_paths("dict", { retentionPaths = {} }) - end) - - local found = false - for _, win in ipairs(vim.api.nvim_list_wins()) do - local config = vim.api.nvim_win_get_config(win) - if config.relative and config.relative ~= "" then - local buf = vim.api.nvim_win_get_buf(win) - local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) - local text = table.concat(lines, "\n") - assert.truthy(text:find("No retention"), "should say no retention paths") - found = true - vim.api.nvim_win_close(win, true) - end - end - assert.is_true(found, "should open floating window with empty message") - end) -end) - --- ============================================================================ --- Type Completion for :BasiliskMemRefs --- ============================================================================ - -describe("memory profiler -- type completion", function() - it("returns DataFrame for 'Data' input", function() - local memory = require("basilisk.memory") - local matches = memory.complete_refs("Data") - assert.are.equal("DataFrame", matches[1]) - end) - - it("returns all types for empty input", function() - local memory = require("basilisk.memory") - local matches = memory.complete_refs("") - assert.is_true(#matches >= 10, "should return many type suggestions") - end) - - it("returns dict for 'dic' input", function() - local memory = require("basilisk.memory") - local matches = memory.complete_refs("dic") - local found = false - for _, m in ipairs(matches) do - if m == "dict" then - found = true - end - end - assert.is_true(found, "should find 'dict' in matches") - end) - - it("is case-insensitive", function() - local memory = require("basilisk.memory") - local matches = memory.complete_refs("tensor") - local found = false - for _, m in ipairs(matches) do - if m == "Tensor" then - found = true - end - end - assert.is_true(found, "should find 'Tensor' when searching for 'tensor'") - end) - - it("returns ndarray for 'nd' input", function() - local memory = require("basilisk.memory") - local matches = memory.complete_refs("nd") - local found = false - for _, m in ipairs(matches) do - if m == "ndarray" then - found = true - end - end - assert.is_true(found, "should find 'ndarray' for 'nd' prefix") - end) - - it("returns Series for 'Ser' input", function() - local memory = require("basilisk.memory") - local matches = memory.complete_refs("Ser") - local found = false - for _, m in ipairs(matches) do - if m == "Series" then - found = true - end - end - assert.is_true(found, "should find 'Series' for 'Ser' prefix") - end) - - it("returns empty for non-matching input", function() - local memory = require("basilisk.memory") - local matches = memory.complete_refs("zzz_no_match_ever") - assert.are.equal(0, #matches, "should return empty for non-matching input") - end) -end) - --- ============================================================================ --- Cross-Feature: Memory + Profiler Coexistence --- ============================================================================ - -describe("memory profiler -- cross-feature", function() - before_each(function() - tmpdir = helpers.create_tmpdir() - local fh = io.open(tmpdir .. "/pyproject.toml", "w") - fh:write('[project]\nname = "test"\nversion = "0.1.0"\n') - fh:close() - - vim.lsp.config("basilisk", { - cmd = { binary, "lsp" }, - filetypes = { "python" }, - root_markers = { "pyproject.toml", ".git" }, - settings = { basilisk = { analysisMode = "wholeModule" } }, - }) - vim.lsp.enable("basilisk") - end) - - after_each(function() - close_floats() - helpers.stop_clients() - helpers.close_all_buffers() - helpers.cleanup_tmpdir(tmpdir) - end) - - it("memory commands do not interfere with document symbols", function() - local buf = helpers.open_python_file( - tmpdir, - "mem_symbols.py", - "def hello():\n pass\n\nclass Foo:\n pass\n" - ) - local client = helpers.wait_for_client(buf) - assert.is_not_nil(client, "LSP client should attach") - helpers.wait_for_server_ready(buf) - - -- Call memory start (async, may error, that's fine). - local memory = require("basilisk.memory") - assert.has_no.errors(function() - memory.start() - end) - vim.wait(500) - - -- Symbols should still work. - local sym_err, symbols = helpers.lsp_request(client, "textDocument/documentSymbol", { - textDocument = { uri = vim.uri_from_bufnr(buf) }, - }, buf, 5000) - assert.is_nil(sym_err, "document symbols should work after memory commands") - assert.is_not_nil(symbols, "should get symbols back") - assert.is_true(#symbols >= 1, "should find at least one symbol") - end) - - it("memory and profiler display functions can coexist", function() - local profiling = require("basilisk.profiling") - local memory = require("basilisk.memory") - - -- Display profiler results. - assert.has_no.errors(function() - profiling.display_results({ - hotFunctions = { - { name = "hot", file = "/tmp/a.py", line = 1, percentage = 50 }, - }, - }) - end) - - -- Display memory results alongside. - assert.has_no.errors(function() - memory.display_leak_report({ - leaks = { - { typeName = "dict", count = 10, totalSize = "100KB" }, - }, - }) - end) - - -- Close all floats. - close_floats() - end) - - it("rapid memory start/stop does not crash LSP", function() - local buf = helpers.open_python_file(tmpdir, "mem_rapid.py", "x: int = 1\n") - local client = helpers.wait_for_client(buf) - assert.is_not_nil(client, "LSP client should attach") - helpers.wait_for_server_ready(buf) - - local memory = require("basilisk.memory") - - -- Rapid start/stop cycles. - for _ = 1, 3 do - assert.has_no.errors(function() - memory.start() - end) - vim.wait(200) - assert.has_no.errors(function() - memory.stop() - end) - vim.wait(200) - end - - close_floats() - - -- LSP should still be responsive. - local sym_err = helpers.lsp_request(client, "textDocument/documentSymbol", { - textDocument = { uri = vim.uri_from_bufnr(buf) }, - }, buf, 5000) - assert.is_nil(sym_err, "LSP should still respond after rapid memory cycles") - end) - - it("dispose functions are idempotent and safe", function() - local profiling = require("basilisk.profiling") - local memory = require("basilisk.memory") - - -- Clear heat map multiple times. - assert.has_no.errors(function() - profiling.apply_heat_map({}) - end) - assert.has_no.errors(function() - profiling.apply_heat_map({}) - end) - - -- Display and close memory reports multiple times. - assert.has_no.errors(function() - memory.display_leak_report(nil) - end) - close_floats() - assert.has_no.errors(function() - memory.display_leak_report(nil) - end) - close_floats() - end) -end) diff --git a/basilisk.nvim/tests/lsp/profiler_spec.lua b/basilisk.nvim/tests/lsp/profiler_spec.lua deleted file mode 100644 index d58a824e2..000000000 --- a/basilisk.nvim/tests/lsp/profiler_spec.lua +++ /dev/null @@ -1,969 +0,0 @@ ---- Profiler E2E tests for the Basilisk Neovim extension. ---- ---- Tests [NVIM-USER-COMMANDS-PROFILING-UI] (heat map, hot-function list, ---- flamegraph export). ---- ---- Full parity with vscode-extension/src/test/suite/profiler.test.ts. ---- Validates the complete CPU profiling workflow: ---- - Profiler commands are registered and callable ---- - Profiler server commands are advertised by LSP ---- - Profiler settings have correct defaults in config ---- - Profile start/stop lifecycle works end-to-end ---- - Heat level classification works correctly ---- - Profiling display and heat map modules work ---- - Profiler decorations (extmarks) apply and clear correctly ---- - Error handling for invalid PIDs, unknown sessions, etc. ---- - Cross-feature integration (profiler + symbols, rapid cycles) ---- ---- These tests require the Basilisk LSP server binary to be built. ---- They exercise the real LSP protocol, not mocks. - -local helpers = require("tests.lsp.helpers") - -local binary = helpers.find_binary() -if not binary then - describe("profiler e2e (SKIPPED -- no binary)", function() - it("skipped", function() - pending("basilisk binary not found") - end) - end) - return -end - ---- Close all floating windows. -local function close_floats() - for _, win in ipairs(vim.api.nvim_list_wins()) do - local config = vim.api.nvim_win_get_config(win) - if config.relative and config.relative ~= "" then - pcall(vim.api.nvim_win_close, win, true) - end - end -end - ---- Send an LSP executeCommand and return err, result synchronously. ----@param client vim.lsp.Client ----@param command string ----@param arguments? table ----@param buf integer ----@return any? err, any? result -local function execute_lsp_command(client, command, arguments, buf) - return helpers.lsp_request(client, "workspace/executeCommand", { - command = command, - arguments = arguments or {}, - }, buf, 5000) -end - -local tmpdir - --- ============================================================================ --- Command Registration --- ============================================================================ - -describe("profiler -- command registration", function() - before_each(function() - tmpdir = helpers.create_tmpdir() - local fh = io.open(tmpdir .. "/pyproject.toml", "w") - fh:write('[project]\nname = "test"\nversion = "0.1.0"\n') - fh:close() - - vim.lsp.config("basilisk", { - cmd = { binary, "lsp" }, - filetypes = { "python" }, - root_markers = { "pyproject.toml", ".git" }, - settings = { basilisk = { analysisMode = "wholeModule" } }, - }) - vim.lsp.enable("basilisk") - end) - - after_each(function() - close_floats() - helpers.stop_clients() - helpers.close_all_buffers() - helpers.cleanup_tmpdir(tmpdir) - end) - - it("all profiler user commands are registered", function() - local buf = helpers.open_python_file(tmpdir, "test_cmd_reg.py", "x: int = 1\n") - helpers.wait_for_server_ready(buf) - - local basilisk = require("basilisk") - basilisk.config = require("basilisk.config").resolve({ binary_path = binary }) - require("basilisk.commands").register(basilisk.config) - - local profiler_commands = { "BasiliskProfile", "BasiliskProfileStop", "BasiliskProfileSnapshot" } - for _, cmd in ipairs(profiler_commands) do - -- Verify the command exists by checking it parses without "not found" error. - local exists = pcall(function() - vim.api.nvim_parse_cmd(cmd, {}) - end) - assert.is_true(exists, "command " .. cmd .. " should be registered") - end - end) - - it("profiler server commands are advertised by LSP via executeCommand", function() - local buf = helpers.open_python_file(tmpdir, "test_srv_cmd.py", "x: int = 1\n") - local client = helpers.wait_for_client(buf) - assert.is_not_nil(client, "LSP client should attach") - helpers.wait_for_server_ready(buf) - - -- profiler.list should be callable (proves server advertises it). - local err, result = execute_lsp_command(client, "basilisk.profiler.list", {}, buf) - assert.is_nil(err, "profiler.list should not error: " .. tostring(err and err.message)) - assert.is_not_nil(result, "profiler.list should return a result") - end) - - it("profiler.list returns empty sessions initially", function() - local buf = helpers.open_python_file(tmpdir, "test_list_empty.py", "x: int = 1\n") - local client = helpers.wait_for_client(buf) - assert.is_not_nil(client, "LSP client should attach") - helpers.wait_for_server_ready(buf) - - local err, result = execute_lsp_command(client, "basilisk.profiler.list", {}, buf) - assert.is_nil(err, "profiler.list should not error") - assert.is_not_nil(result, "profiler.list should return a result") - - local sessions = result.sessions - assert.is_table(sessions, "sessions should be a table") - assert.are.equal(0, #sessions, "no sessions should be active initially") - end) - - it("profiler.list result has correct shape", function() - local buf = helpers.open_python_file(tmpdir, "test_list_shape.py", "x: int = 1\n") - local client = helpers.wait_for_client(buf) - assert.is_not_nil(client, "LSP client should attach") - helpers.wait_for_server_ready(buf) - - local err, result = execute_lsp_command(client, "basilisk.profiler.list", {}, buf) - assert.is_nil(err, "profiler.list should not error") - assert.is_not_nil(result, "profiler.list must return a value") - assert.is_table(result.sessions, "result must have sessions key as array") - end) - - it("profiler client commands do not crash when called", function() - local buf = helpers.open_python_file(tmpdir, "test_no_crash.py", "x: int = 1\n") - helpers.wait_for_server_ready(buf) - - local basilisk = require("basilisk") - basilisk.config = require("basilisk.config").resolve({ binary_path = binary }) - require("basilisk.commands").register(basilisk.config) - - -- These commands use async callbacks; verify they don't throw synchronously. - assert.has_no.errors(function() - pcall(vim.cmd, "BasiliskProfile") - end) - assert.has_no.errors(function() - pcall(vim.cmd, "BasiliskProfileStop") - end) - assert.has_no.errors(function() - pcall(vim.cmd, "BasiliskProfileSnapshot") - end) - end) -end) - --- ============================================================================ --- Configuration --- ============================================================================ - -describe("profiler -- configuration", function() - it("profiler config defaults exist in basilisk config", function() - local config = require("basilisk.config") - local defaults = config.defaults - assert.is_not_nil(defaults, "config defaults should exist") - -- Basilisk Neovim config doesn't have profiler-specific keys in the same way, - -- but the profiling module and commands must be loadable. - assert.has_no.errors(function() - require("basilisk.profiling") - end) - end) - - it("profiling module exports all required functions", function() - local profiling = require("basilisk.profiling") - assert.is_function(profiling.start, "start should be a function") - assert.is_function(profiling.stop, "stop should be a function") - assert.is_function(profiling.snapshot, "snapshot should be a function") - assert.is_function(profiling.display_results, "display_results should be a function") - assert.is_function(profiling.apply_heat_map, "apply_heat_map should be a function") - assert.is_function(profiling.export_flamegraph, "export_flamegraph should be a function") - end) - - it("memory module exports all required functions", function() - local memory = require("basilisk.memory") - assert.is_function(memory.start, "start should be a function") - assert.is_function(memory.stop, "stop should be a function") - assert.is_function(memory.refs, "refs should be a function") - assert.is_function(memory.display_leak_report, "display_leak_report should be a function") - assert.is_function(memory.display_retention_paths, "display_retention_paths should be a function") - assert.is_function(memory.complete_refs, "complete_refs should be a function") - end) -end) - --- ============================================================================ --- Start/Stop Lifecycle --- ============================================================================ - -describe("profiler -- start/stop lifecycle", function() - before_each(function() - tmpdir = helpers.create_tmpdir() - local fh = io.open(tmpdir .. "/pyproject.toml", "w") - fh:write('[project]\nname = "test"\nversion = "0.1.0"\n') - fh:close() - - vim.lsp.config("basilisk", { - cmd = { binary, "lsp" }, - filetypes = { "python" }, - root_markers = { "pyproject.toml", ".git" }, - settings = { basilisk = { analysisMode = "wholeModule" } }, - }) - vim.lsp.enable("basilisk") - end) - - after_each(function() - close_floats() - helpers.stop_clients() - helpers.close_all_buffers() - helpers.cleanup_tmpdir(tmpdir) - end) - - it("profiler.start rejects invalid PID (0)", function() - local buf = helpers.open_python_file(tmpdir, "test_pid0.py", "x: int = 1\n") - local client = helpers.wait_for_client(buf) - assert.is_not_nil(client, "LSP client should attach") - helpers.wait_for_server_ready(buf) - - local err = execute_lsp_command(client, "basilisk.profiler.start", { { pid = 0 } }, buf) - assert.is_not_nil(err, "profiler.start with PID 0 should return an error") - assert.is_string(err.message, "error should have a message string") - assert.is_true(#err.message > 0, "error message should not be empty") - end) - - it("profiler.start rejects negative PID", function() - local buf = helpers.open_python_file(tmpdir, "test_neg_pid.py", "x: int = 1\n") - local client = helpers.wait_for_client(buf) - assert.is_not_nil(client, "LSP client should attach") - helpers.wait_for_server_ready(buf) - - local err = execute_lsp_command(client, "basilisk.profiler.start", { { pid = -1 } }, buf) - assert.is_not_nil(err, "profiler.start with negative PID should return an error") - assert.is_string(err.message, "error should have a message") - assert.is_true(#err.message > 0, "error message should not be empty") - end) - - it("profiler.start rejects extremely large PID", function() - local buf = helpers.open_python_file(tmpdir, "test_large_pid.py", "x: int = 1\n") - local client = helpers.wait_for_client(buf) - assert.is_not_nil(client, "LSP client should attach") - helpers.wait_for_server_ready(buf) - - local err = execute_lsp_command(client, "basilisk.profiler.start", { { pid = 999999999 } }, buf) - assert.is_not_nil(err, "profiler.start with nonexistent PID should return an error") - assert.is_string(err.message, "error should have a message") - local msg = err.message - assert.is_true( - msg:find("not found") ~= nil - or msg:find("Process") ~= nil - or msg:find("failed") ~= nil - or msg:find("error") ~= nil - or msg:find("denied") ~= nil - or msg:find("attach") ~= nil, - "error should indicate process issue, got: " .. msg - ) - end) - - it("profiler.stop rejects unknown session ID", function() - local buf = helpers.open_python_file(tmpdir, "test_unknown_stop.py", "x: int = 1\n") - local client = helpers.wait_for_client(buf) - assert.is_not_nil(client, "LSP client should attach") - helpers.wait_for_server_ready(buf) - - local err = execute_lsp_command( - client, - "basilisk.profiler.stop", - { { sessionId = "nonexistent-session-id" } }, - buf - ) - assert.is_not_nil(err, "profiler.stop with unknown session should return an error") - local msg = err.message or "" - assert.is_true( - msg:find("session") ~= nil or msg:find("not found") ~= nil or msg:find("No active") ~= nil, - "error should mention session, got: " .. msg - ) - end) - - it("profiler.snapshot rejects unknown session ID", function() - local buf = helpers.open_python_file(tmpdir, "test_unknown_snap.py", "x: int = 1\n") - local client = helpers.wait_for_client(buf) - assert.is_not_nil(client, "LSP client should attach") - helpers.wait_for_server_ready(buf) - - local err = execute_lsp_command( - client, - "basilisk.profiler.snapshot", - { { sessionId = "nonexistent-session-id" } }, - buf - ) - assert.is_not_nil(err, "profiler.snapshot with unknown session should return an error") - local msg = err.message or "" - assert.is_true( - msg:find("session") ~= nil or msg:find("not found") ~= nil or msg:find("No active") ~= nil, - "error should reference session state, got: " .. msg - ) - end) - - it("profiler.start with no PID gives clear error", function() - local buf = helpers.open_python_file(tmpdir, "test_no_pid.py", "x: int = 1\n") - local client = helpers.wait_for_client(buf) - assert.is_not_nil(client, "LSP client should attach") - helpers.wait_for_server_ready(buf) - - local err = execute_lsp_command(client, "basilisk.profiler.start", {}, buf) - assert.is_not_nil(err, "profiler.start with no PID should return an error") - assert.is_true(#(err.message or "") > 0, "error message should not be empty") - end) - - it("profiler.stop with missing sessionId gives clear error", function() - local buf = helpers.open_python_file(tmpdir, "test_no_sessid.py", "x: int = 1\n") - local client = helpers.wait_for_client(buf) - assert.is_not_nil(client, "LSP client should attach") - helpers.wait_for_server_ready(buf) - - local err = execute_lsp_command(client, "basilisk.profiler.stop", {}, buf) - assert.is_not_nil(err, "profiler.stop with missing sessionId should return an error") - local msg = err.message or "" - assert.is_true(#msg > 0, "error message should not be empty") - end) - - it("consecutive profiler.list calls return consistent empty results", function() - local buf = helpers.open_python_file(tmpdir, "test_consec_list.py", "x: int = 1\n") - local client = helpers.wait_for_client(buf) - assert.is_not_nil(client, "LSP client should attach") - helpers.wait_for_server_ready(buf) - - local _, result1 = execute_lsp_command(client, "basilisk.profiler.list", {}, buf) - local _, result2 = execute_lsp_command(client, "basilisk.profiler.list", {}, buf) - - assert.is_table(result1.sessions, "first call sessions should be array") - assert.is_table(result2.sessions, "second call sessions should be array") - assert.are.equal(#result1.sessions, #result2.sessions, "consecutive calls should return same count") - end) - - it("profiler.list returns array structure", function() - local buf = helpers.open_python_file(tmpdir, "test_list_arr.py", "x: int = 1\n") - local client = helpers.wait_for_client(buf) - assert.is_not_nil(client, "LSP client should attach") - helpers.wait_for_server_ready(buf) - - local err, result = execute_lsp_command(client, "basilisk.profiler.list", {}, buf) - assert.is_nil(err, "should not error") - assert.is_table(result.sessions, "result should have sessions array") - end) -end) - --- ============================================================================ --- Heat Level Classification --- ============================================================================ - -describe("profiler -- heat level classification", function() - it("critical heat level (>= 20%)", function() - assert.is_true(25.0 >= 20, "25% should fall in critical range") - assert.is_true(20.0 >= 20, "exactly 20% should fall in critical range") - end) - - it("hot heat level (10-20%)", function() - assert.is_true(15.0 >= 10 and 15.0 < 20, "15% should fall in hot range") - assert.is_true(10.0 >= 10 and 10.0 < 20, "exactly 10% should fall in hot range") - end) - - it("warm heat level (5-10%)", function() - assert.is_true(7.0 >= 5 and 7.0 < 10, "7% should fall in warm range") - assert.is_true(5.0 >= 5 and 5.0 < 10, "exactly 5% should fall in warm range") - end) - - it("cool heat level (1-5%)", function() - assert.is_true(3.0 >= 1 and 3.0 < 5, "3% should fall in cool range") - assert.is_true(1.0 >= 1 and 1.0 < 5, "exactly 1% should fall in cool range") - end) - - it("below threshold (< 1%) is not classified", function() - assert.is_true(0.5 < 1, "0.5% should not be classified") - end) - - it("heat level boundaries are mutually exclusive", function() - local test_cases = { - { pct = 25.0, expected = "critical" }, - { pct = 20.0, expected = "critical" }, - { pct = 19.9, expected = "hot" }, - { pct = 10.0, expected = "hot" }, - { pct = 9.9, expected = "warm" }, - { pct = 5.0, expected = "warm" }, - { pct = 4.9, expected = "cool" }, - { pct = 1.0, expected = "cool" }, - { pct = 0.9, expected = "none" }, - } - - for _, tc in ipairs(test_cases) do - local level - if tc.pct >= 20 then - level = "critical" - elseif tc.pct >= 10 then - level = "hot" - elseif tc.pct >= 5 then - level = "warm" - elseif tc.pct >= 1 then - level = "cool" - else - level = "none" - end - assert.are.equal( - tc.expected, - level, - string.format("%.1f%% should be classified as %q, got %q", tc.pct, tc.expected, level) - ) - end - end) - - it("heat level boundary at exactly 1%", function() - assert.is_true(1.0 >= 1, "1.0% should be classified (cool)") - assert.is_true(0.99 < 1, "0.99% should not be classified") - assert.is_true(1.0 < 5, "1.0% should not be warm") - end) - - it("heat level boundary at exactly 5%", function() - assert.is_true(5.0 >= 5, "5.0% should be classified as warm") - assert.is_true(4.99 < 5, "4.99% should still be cool") - assert.is_true(5.0 < 10, "5.0% should not be hot") - end) - - it("heat level boundary at exactly 10%", function() - assert.is_true(10.0 >= 10, "10.0% should be classified as hot") - assert.is_true(9.99 < 10, "9.99% should still be warm") - assert.is_true(10.0 < 20, "10.0% should not be critical") - end) - - it("heat level boundary at exactly 20%", function() - assert.is_true(20.0 >= 20, "20.0% should be classified as critical") - assert.is_true(19.99 < 20, "19.99% should still be hot") - assert.is_true(19.99 >= 10, "19.99% must be at least hot-level") - end) -end) - --- ============================================================================ --- Profiling Display and Heat Map --- ============================================================================ - -describe("profiler -- display and heat map", function() - after_each(function() - close_floats() - end) - - it("display_results handles nil gracefully", function() - local profiling = require("basilisk.profiling") - assert.has_no.errors(function() - profiling.display_results(nil) - end) - close_floats() - end) - - it("display_results shows hot functions in floating window", function() - local profiling = require("basilisk.profiling") - local result = { - hotFunctions = { - { name = "process", file = "/tmp/test.py", line = 10, percentage = 45.2 }, - { name = "calculate", file = "/tmp/test.py", line = 25, percentage = 30.1 }, - }, - } - - assert.has_no.errors(function() - profiling.display_results(result) - end) - - local found = false - for _, win in ipairs(vim.api.nvim_list_wins()) do - local config = vim.api.nvim_win_get_config(win) - if config.relative and config.relative ~= "" then - local buf = vim.api.nvim_win_get_buf(win) - local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) - local text = table.concat(lines, "\n") - assert.truthy(text:find("process"), "should contain 'process'") - assert.truthy(text:find("calculate"), "should contain 'calculate'") - assert.truthy(text:find("45.2"), "should contain percentage 45.2") - found = true - vim.api.nvim_win_close(win, true) - end - end - assert.is_true(found, "should open a floating window with results") - end) - - it("display_results populates quickfix list", function() - local profiling = require("basilisk.profiling") - local result = { - hotFunctions = { - { name = "func_a", file = "/tmp/a.py", line = 5, percentage = 60 }, - }, - } - - profiling.display_results(result) - local qf = vim.fn.getqflist() - assert.is_true(#qf > 0, "quickfix should have items") - close_floats() - end) - - it("apply_heat_map handles empty hot functions", function() - local profiling = require("basilisk.profiling") - assert.has_no.errors(function() - profiling.apply_heat_map({}) - end) - end) - - it("apply_heat_map handles nil input", function() - local profiling = require("basilisk.profiling") - assert.has_no.errors(function() - profiling.apply_heat_map(nil) - end) - end) - - it("ProfileResult type has required fields", function() - local result = { - sessionId = "test-session-001", - duration = 5.2, - totalSamples = 1000, - outputFile = "/tmp/test.speedscope.json", - hotFunctions = {}, - hotLines = {}, - } - - assert.are.equal("test-session-001", result.sessionId) - assert.are.equal(5.2, result.duration) - assert.are.equal(1000, result.totalSamples) - assert.are.equal("/tmp/test.speedscope.json", result.outputFile) - assert.is_table(result.hotFunctions, "hotFunctions should be a table") - assert.is_table(result.hotLines, "hotLines should be a table") - end) - - it("ProfileHotLine type has required fields", function() - local hot_line = { - file = "/src/app.py", - line = 42, - samples = 500, - percentage = 25.0, - } - - assert.are.equal("/src/app.py", hot_line.file) - assert.are.equal(42, hot_line.line) - assert.are.equal(500, hot_line.samples) - assert.are.equal(25.0, hot_line.percentage) - end) - - it("ProfileHotFunction type has required fields", function() - local hot_func = { - name = "process_data", - file = "/src/pipeline.py", - line = 15, - samples = 800, - percentage = 40.0, - selfPercentage = 30.0, - } - - assert.are.equal("process_data", hot_func.name) - assert.are.equal("/src/pipeline.py", hot_func.file) - assert.are.equal(15, hot_func.line) - assert.are.equal(800, hot_func.samples) - assert.are.equal(40.0, hot_func.percentage) - assert.are.equal(30.0, hot_func.selfPercentage) - end) - - it("ProfileResult with populated hotFunctions validates structure", function() - local result = { - sessionId = "populated-session", - duration = 10.5, - totalSamples = 5000, - outputFile = "/tmp/profile.speedscope.json", - hotFunctions = { - { - name = "compute", - file = "/src/math.py", - line = 10, - samples = 2500, - percentage = 50.0, - selfPercentage = 35.0, - }, - { - name = "transform", - file = "/src/utils.py", - line = 88, - samples = 1000, - percentage = 20.0, - selfPercentage = 15.0, - }, - }, - hotLines = { - { file = "/src/math.py", line = 12, samples = 2000, percentage = 40.0 }, - }, - } - - assert.are.equal(2, #result.hotFunctions, "should have 2 hot functions") - assert.are.equal(1, #result.hotLines, "should have 1 hot line") - assert.are.equal("compute", result.hotFunctions[1].name) - assert.are.equal("transform", result.hotFunctions[2].name) - assert.is_true( - result.hotFunctions[1].percentage > result.hotFunctions[2].percentage, - "first function should have higher percentage" - ) - assert.is_true( - result.hotFunctions[1].selfPercentage <= result.hotFunctions[1].percentage, - "selfPercentage should not exceed percentage" - ) - end) - - it("display_results with multi-file hot functions", function() - local profiling = require("basilisk.profiling") - local result = { - hotFunctions = { - { name = "hot_func", file = "/nonexistent/a.py", line = 1, percentage = 50.0 }, - { name = "warm_func", file = "/nonexistent/b.py", line = 10, percentage = 10.0 }, - { name = "cool_func", file = "/nonexistent/c.py", line = 20, percentage = 2.0 }, - }, - } - - assert.has_no.errors(function() - profiling.display_results(result) - end) - - assert.are.equal(3, #result.hotFunctions, "should have 3 hot functions") - assert.is_true( - result.hotFunctions[1].percentage > result.hotFunctions[2].percentage, - "functions should be ordered by percentage" - ) - close_floats() - end) - - it("apply_heat_map then clear is idempotent", function() - local profiling = require("basilisk.profiling") - local ns = vim.api.nvim_create_namespace("basilisk-profiling") - - -- Apply. - assert.has_no.errors(function() - profiling.apply_heat_map({ - { file = "/tmp/test.py", line = 1, percentage = 50.0 }, - }) - end) - - -- Clear by applying empty. - assert.has_no.errors(function() - profiling.apply_heat_map({}) - end) - - -- Double clear should also be safe. - assert.has_no.errors(function() - profiling.apply_heat_map({}) - end) - end) - - it("export_flamegraph handles nil result gracefully", function() - local profiling = require("basilisk.profiling") - assert.has_no.errors(function() - profiling.export_flamegraph(nil) - end) - end) - - it("export_flamegraph handles result without speedscopeJson", function() - local profiling = require("basilisk.profiling") - assert.has_no.errors(function() - profiling.export_flamegraph({}) - end) - end) -end) - --- ============================================================================ --- Error Handling --- ============================================================================ - -describe("profiler -- error handling", function() - before_each(function() - tmpdir = helpers.create_tmpdir() - local fh = io.open(tmpdir .. "/pyproject.toml", "w") - fh:write('[project]\nname = "test"\nversion = "0.1.0"\n') - fh:close() - - vim.lsp.config("basilisk", { - cmd = { binary, "lsp" }, - filetypes = { "python" }, - root_markers = { "pyproject.toml", ".git" }, - settings = { basilisk = { analysisMode = "wholeModule" } }, - }) - vim.lsp.enable("basilisk") - end) - - after_each(function() - close_floats() - helpers.stop_clients() - helpers.close_all_buffers() - helpers.cleanup_tmpdir(tmpdir) - end) - - it("profiler.start with invalid params returns error", function() - local buf = helpers.open_python_file(tmpdir, "test_inv_params.py", "x: int = 1\n") - local client = helpers.wait_for_client(buf) - assert.is_not_nil(client, "LSP client should attach") - helpers.wait_for_server_ready(buf) - - local err = execute_lsp_command(client, "basilisk.profiler.start", { { pid = 0, sampleRate = -1 } }, buf) - assert.is_not_nil(err, "should return error for invalid params") - assert.is_true(#(err.message or "") > 0, "error message should not be empty") - end) - - it("profiler error codes are within expected LSP range", function() - -- LSP spec error codes for profiler: -32001 through -32006. - local expected_codes = { -32001, -32002, -32003, -32004, -32005, -32006 } - - for _, code in ipairs(expected_codes) do - assert.is_true(code < 0, "error code should be negative") - assert.is_true(code >= -32099, "error code should be >= -32099") - assert.is_true(code <= -32000, "error code should be <= -32000") - end - - -- All codes should be unique. - local seen = {} - for _, code in ipairs(expected_codes) do - assert.is_nil(seen[code], "error codes should be unique") - seen[code] = true - end - end) - - it("profiler.stop with empty string sessionId returns descriptive error", function() - local buf = helpers.open_python_file(tmpdir, "test_empty_sess.py", "x: int = 1\n") - local client = helpers.wait_for_client(buf) - assert.is_not_nil(client, "LSP client should attach") - helpers.wait_for_server_ready(buf) - - local err = execute_lsp_command(client, "basilisk.profiler.stop", { { sessionId = "" } }, buf) - assert.is_not_nil(err, "empty sessionId should produce an error") - local msg = err.message or "" - assert.is_true(#msg > 0, "error should have a message") - assert.is_nil(msg:find("panic"), "error should not indicate a panic") - assert.is_nil(msg:find("PANIC"), "error should not indicate a panic") - end) - - it("error messages do not contain raw stack traces", function() - local buf = helpers.open_python_file(tmpdir, "test_no_stack.py", "x: int = 1\n") - local client = helpers.wait_for_client(buf) - assert.is_not_nil(client, "LSP client should attach") - helpers.wait_for_server_ready(buf) - - local error_calls = { - { "basilisk.profiler.start", { { pid = 0 } } }, - { "basilisk.profiler.stop", { { sessionId = "fake" } } }, - { "basilisk.profiler.snapshot", { { sessionId = "fake" } } }, - } - - for _, call in ipairs(error_calls) do - local err = execute_lsp_command(client, call[1], call[2], buf) - if err then - local msg = err.message or "" - -- Stack traces typically have lines like "at Function.xxx (file:line)". - local stack_lines = 0 - for line in msg:gmatch("[^\n]+") do - if line:match("^%s*at ") then - stack_lines = stack_lines + 1 - end - end - assert.is_true( - stack_lines < 3, - "error should not contain full stack traces: " .. msg:sub(1, 200) - ) - end - end - end) - - it("error messages are user-friendly, not JSON blobs", function() - local buf = helpers.open_python_file(tmpdir, "test_ux_err.py", "x: int = 1\n") - local client = helpers.wait_for_client(buf) - assert.is_not_nil(client, "LSP client should attach") - helpers.wait_for_server_ready(buf) - - local err = execute_lsp_command( - client, - "basilisk.profiler.stop", - { { sessionId = "nonexistent-for-ux-check" } }, - buf - ) - if err then - local msg = err.message or "" - assert.is_true(#msg < 2000, "error message should not be excessively long") - assert.is_string(msg, "error must be a string") - end - end) - - it("connection errors are protocol-level, not TCP-level", function() - local buf = helpers.open_python_file(tmpdir, "test_proto_err.py", "x: int = 1\n") - local client = helpers.wait_for_client(buf) - assert.is_not_nil(client, "LSP client should attach") - helpers.wait_for_server_ready(buf) - - local err = execute_lsp_command(client, "basilisk.profiler.start", { { pid = 2147483647 } }, buf) - if err then - local msg = err.message or "" - assert.is_nil(msg:find("ECONNREFUSED"), "error should not be network-level") - assert.is_nil(msg:find("ECONNRESET"), "error should not be network-level") - assert.is_true(#msg > 0, "error message should not be empty") - assert.is_nil(msg:find("undefined"), "error should not contain 'undefined'") - end - end) -end) - --- ============================================================================ --- Cross-Feature Integration --- ============================================================================ - -describe("profiler -- cross-feature integration", function() - before_each(function() - tmpdir = helpers.create_tmpdir() - local fh = io.open(tmpdir .. "/pyproject.toml", "w") - fh:write('[project]\nname = "test"\nversion = "0.1.0"\n') - fh:close() - - vim.lsp.config("basilisk", { - cmd = { binary, "lsp" }, - filetypes = { "python" }, - root_markers = { "pyproject.toml", ".git" }, - settings = { basilisk = { analysisMode = "wholeModule" } }, - }) - vim.lsp.enable("basilisk") - end) - - after_each(function() - close_floats() - helpers.stop_clients() - helpers.close_all_buffers() - helpers.cleanup_tmpdir(tmpdir) - end) - - it("profiler commands do not interfere with document symbol provider", function() - local buf = helpers.open_python_file(tmpdir, "symbols_test.py", "def hello():\n pass\n\nclass Foo:\n pass\n") - local client = helpers.wait_for_client(buf) - assert.is_not_nil(client, "LSP client should attach") - helpers.wait_for_server_ready(buf) - - -- Run profiler.list, then verify symbols still work. - local err, list_result = execute_lsp_command(client, "basilisk.profiler.list", {}, buf) - assert.is_nil(err, "profiler.list should work") - assert.is_not_nil(list_result, "profiler.list should return a result") - - local sym_err, symbols = helpers.lsp_request(client, "textDocument/documentSymbol", { - textDocument = { uri = vim.uri_from_bufnr(buf) }, - }, buf, 5000) - assert.is_nil(sym_err, "document symbols should still work after profiler commands") - assert.is_not_nil(symbols, "should get symbols back") - assert.is_true(#symbols >= 1, "should find at least one symbol") - end) - - it("profiler.list is idempotent and does not corrupt LSP state", function() - local buf = helpers.open_python_file(tmpdir, "test_idempotent.py", "x: int = 1\n") - local client = helpers.wait_for_client(buf) - assert.is_not_nil(client, "LSP client should attach") - helpers.wait_for_server_ready(buf) - - for iteration = 1, 5 do - local err, result = execute_lsp_command(client, "basilisk.profiler.list", {}, buf) - assert.is_nil(err, "iteration " .. iteration .. " should not error") - assert.is_table(result.sessions, "iteration " .. iteration .. ": sessions should be a table") - end - - -- LSP should still be responsive. - local sym_err = helpers.lsp_request(client, "textDocument/documentSymbol", { - textDocument = { uri = vim.uri_from_bufnr(buf) }, - }, buf, 5000) - assert.is_nil(sym_err, "LSP should still respond after repeated profiler.list calls") - end) - - it("multiple quick start/stop error cycles do not crash", function() - local buf = helpers.open_python_file(tmpdir, "test_rapid_cycle.py", "x: int = 1\n") - local client = helpers.wait_for_client(buf) - assert.is_not_nil(client, "LSP client should attach") - helpers.wait_for_server_ready(buf) - - for cycle = 0, 2 do - -- Start with invalid PID -- should error. - execute_lsp_command(client, "basilisk.profiler.start", { { pid = 0 } }, buf) - -- Stop with invalid session -- should error. - execute_lsp_command( - client, - "basilisk.profiler.stop", - { { sessionId = "fake-session-cycle-" .. cycle } }, - buf - ) - end - - -- profiler.list should still return valid data. - local err, result = execute_lsp_command(client, "basilisk.profiler.list", {}, buf) - assert.is_nil(err, "profiler.list should still work after error cycles") - assert.is_table(result.sessions, "should still get sessions array") - end) - - it("profiler and memory commands are distinct", function() - local profiler_commands = { - "basilisk.profiler.start", - "basilisk.profiler.stop", - "basilisk.profiler.snapshot", - "basilisk.profiler.list", - } - local memory_commands = { - "basilisk/memory/start", - "basilisk/memory/stop", - "basilisk/memory/refs", - } - - -- All commands should be unique. - local seen = {} - for _, cmd in ipairs(profiler_commands) do - assert.is_nil(seen[cmd], "profiler command should be unique: " .. cmd) - seen[cmd] = true - end - for _, cmd in ipairs(memory_commands) do - assert.is_nil(seen[cmd], "memory command should not overlap with profiler: " .. cmd) - seen[cmd] = true - end - end) - - it("profiler and memory user commands do not overlap", function() - local profiler_user_cmds = { "BasiliskProfile", "BasiliskProfileStop", "BasiliskProfileSnapshot" } - local memory_user_cmds = { "BasiliskMemLeak", "BasiliskMemStop", "BasiliskMemRefs" } - - local all = {} - for _, cmd in ipairs(profiler_user_cmds) do - assert.is_nil(all[cmd], "command should be unique: " .. cmd) - all[cmd] = true - end - for _, cmd in ipairs(memory_user_cmds) do - assert.is_nil(all[cmd], "command should be unique: " .. cmd) - all[cmd] = true - end - end) - - it("profiling decorations and memory display can coexist", function() - local profiling = require("basilisk.profiling") - local memory = require("basilisk.memory") - - -- Apply profiler heat map. - assert.has_no.errors(function() - profiling.apply_heat_map({ - { file = "/tmp/coexist.py", line = 1, percentage = 50.0 }, - }) - end) - - -- Display memory leak report. - assert.has_no.errors(function() - memory.display_leak_report({ - leaks = { - { typeName = "dict", count = 100, totalSize = "500KB" }, - }, - }) - end) - - -- Clear profiling should not affect memory float. - assert.has_no.errors(function() - profiling.apply_heat_map({}) - end) - - close_floats() - end) -end) diff --git a/basilisk.nvim/tests/lsp/refactoring_spec.lua b/basilisk.nvim/tests/lsp/refactoring_spec.lua deleted file mode 100644 index ae0522330..000000000 --- a/basilisk.nvim/tests/lsp/refactoring_spec.lua +++ /dev/null @@ -1,286 +0,0 @@ ---- Real LSP refactoring code action tests for basilisk.nvim. ---- ---- These tests start the REAL basilisk LSP server and verify that ---- code actions are returned for various refactoring scenarios. ---- NO MOCKING. Uses the actual basilisk binary. - -local helpers = require("tests.lsp.helpers") - -describe("LSP refactoring code actions", function() - local tmpdir - local binary - - before_each(function() - binary = helpers.find_binary() - if not binary then - pending("basilisk binary not found — skipping LSP refactoring tests") - return - end - tmpdir = helpers.create_tmpdir() - - -- Write a pyproject.toml so basilisk finds a project root. - local fh = io.open(tmpdir .. "/pyproject.toml", "w") - fh:write('[project]\nname = "test"\nversion = "0.1.0"\n') - fh:close() - - vim.lsp.config("basilisk", { - cmd = { binary, "lsp" }, - filetypes = { "python" }, - root_markers = { "pyproject.toml", ".git" }, - settings = { basilisk = { analysisMode = "wholeModule" } }, - }) - vim.lsp.enable("basilisk") - end) - - after_each(function() - helpers.stop_clients() - helpers.close_all_buffers() - if tmpdir then - helpers.cleanup_tmpdir(tmpdir) - end - end) - - it("extract variable code action is offered", function() - if not binary then - return - end - - local source = "result: int = some_func(42) + other_func(7)\n" - local buf, uri = helpers.open_python_file(tmpdir, "extract_var.py", source) - assert.truthy(helpers.wait_for_server_ready(buf)) - - local client = helpers.wait_for_client(buf) - assert.truthy(client) - - local err, result = helpers.lsp_request(client, "textDocument/codeAction", { - textDocument = { uri = uri }, - range = { - start = { line = 0, character = 14 }, - ["end"] = { line = 0, character = 27 }, - }, - context = { diagnostics = {} }, - }, buf) - - assert.is_nil(err) - assert.truthy(result) - - local found = false - for _, action in ipairs(result) do - if action.title and action.title:find("Extract variable") then - found = true - break - end - end - assert.is_true(found, "should offer Extract variable code action") - end) - - it("inline variable code action is offered", function() - if not binary then - return - end - - local source = "def f() -> None:\n temp = calculate()\n result = temp + 1\n" - local buf, uri = helpers.open_python_file(tmpdir, "inline_var.py", source) - assert.truthy(helpers.wait_for_server_ready(buf)) - - local client = helpers.wait_for_client(buf) - assert.truthy(client) - - local err, result = helpers.lsp_request(client, "textDocument/codeAction", { - textDocument = { uri = uri }, - range = { - start = { line = 1, character = 4 }, - ["end"] = { line = 1, character = 4 }, - }, - context = { diagnostics = {} }, - }, buf) - - assert.is_nil(err) - assert.truthy(result) - - local found = false - for _, action in ipairs(result) do - if action.title and action.title:find("Inline variable") then - found = true - break - end - end - assert.is_true(found, "should offer Inline variable code action") - end) - - it("Union conversion code action is offered", function() - if not binary then - return - end - - local source = "from typing import Union\nx: Union[int, str] = 1\n" - local buf, uri = helpers.open_python_file(tmpdir, "union_convert.py", source) - assert.truthy(helpers.wait_for_server_ready(buf)) - - local client = helpers.wait_for_client(buf) - assert.truthy(client) - - local err, result = helpers.lsp_request(client, "textDocument/codeAction", { - textDocument = { uri = uri }, - range = { - start = { line = 1, character = 3 }, - ["end"] = { line = 1, character = 3 }, - }, - context = { diagnostics = {} }, - }, buf) - - assert.is_nil(err) - assert.truthy(result) - - local found = false - for _, action in ipairs(result) do - if action.title and action.title:find("Union") then - found = true - break - end - end - assert.is_true(found, "should offer Union conversion code action") - end) - - it("f-string conversion code action is offered", function() - if not binary then - return - end - - local source = 'name: str = "world"\nx: str = f"hello {name}"\n' - local buf, uri = helpers.open_python_file(tmpdir, "fstring_convert.py", source) - assert.truthy(helpers.wait_for_server_ready(buf)) - - local client = helpers.wait_for_client(buf) - assert.truthy(client) - - local err, result = helpers.lsp_request(client, "textDocument/codeAction", { - textDocument = { uri = uri }, - range = { - start = { line = 1, character = 9 }, - ["end"] = { line = 1, character = 9 }, - }, - context = { diagnostics = {} }, - }, buf) - - assert.is_nil(err) - assert.truthy(result) - - local found = false - for _, action in ipairs(result) do - if action.title and action.title:find(".format%(%)") then - found = true - break - end - end - assert.is_true(found, "should offer .format() conversion code action") - end) - - it("move symbol code action is offered for class", function() - if not binary then - return - end - - local source = "import os\n\nclass MyWidget:\n pass\n" - local buf, uri = helpers.open_python_file(tmpdir, "move_symbol.py", source) - assert.truthy(helpers.wait_for_server_ready(buf)) - - local client = helpers.wait_for_client(buf) - assert.truthy(client) - - local err, result = helpers.lsp_request(client, "textDocument/codeAction", { - textDocument = { uri = uri }, - range = { - start = { line = 2, character = 0 }, - ["end"] = { line = 2, character = 0 }, - }, - context = { diagnostics = {} }, - }, buf) - - assert.is_nil(err) - assert.truthy(result) - - local found_move = false - local found_new_file = false - for _, action in ipairs(result) do - if action.title then - if action.title:find("Move") then - found_move = true - end - if action.title:find("new file") then - found_new_file = true - end - end - end - assert.is_true(found_move, "should offer Move code action") - assert.is_true(found_new_file, "should offer new file code action") - end) - - it("change signature remove parameter is offered", function() - if not binary then - return - end - - local source = "def greet(name: str, greeting: str) -> str:\n return f\"{greeting}, {name}\"\n\nresult: str = greet(\"world\", \"Hello\")\n" - local buf, uri = helpers.open_python_file(tmpdir, "change_sig.py", source) - assert.truthy(helpers.wait_for_server_ready(buf)) - - local client = helpers.wait_for_client(buf) - assert.truthy(client) - - local err, result = helpers.lsp_request(client, "textDocument/codeAction", { - textDocument = { uri = uri }, - range = { - start = { line = 0, character = 21 }, - ["end"] = { line = 0, character = 28 }, - }, - context = { diagnostics = {} }, - }, buf) - - assert.is_nil(err) - assert.truthy(result) - - local found = false - for _, action in ipairs(result) do - if action.title and action.title:find("Remove parameter") then - found = true - break - end - end - assert.is_true(found, "should offer Remove parameter code action") - end) - - it("implement abstract methods is offered", function() - if not binary then - return - end - - local source = "from abc import ABC, abstractmethod\n\nclass Base(ABC):\n @abstractmethod\n def do_thing(self) -> None:\n ...\n\nclass Child(Base):\n pass\n" - local buf, uri = helpers.open_python_file(tmpdir, "impl_abstract.py", source) - assert.truthy(helpers.wait_for_server_ready(buf)) - - local client = helpers.wait_for_client(buf) - assert.truthy(client) - - local err, result = helpers.lsp_request(client, "textDocument/codeAction", { - textDocument = { uri = uri }, - range = { - start = { line = 7, character = 6 }, - ["end"] = { line = 7, character = 6 }, - }, - context = { diagnostics = {} }, - }, buf) - - assert.is_nil(err) - assert.truthy(result) - - local found = false - for _, action in ipairs(result) do - if action.title and (action.title:find("abstract") or action.title:find("Implement")) then - found = true - break - end - end - assert.is_true(found, "should offer implement abstract methods code action") - end) -end) diff --git a/basilisk.nvim/tests/lsp/rename_spec.lua b/basilisk.nvim/tests/lsp/rename_spec.lua deleted file mode 100644 index 4d7517911..000000000 --- a/basilisk.nvim/tests/lsp/rename_spec.lua +++ /dev/null @@ -1,304 +0,0 @@ ---- Real LSP rename tests for basilisk.nvim. ---- ---- These tests start the REAL basilisk LSP server and verify that ---- scope-aware rename works correctly through Neovim's LSP client. ---- NO MOCKING. Uses the actual basilisk binary. - -local helpers = require("tests.lsp.helpers") - -describe("LSP rename", function() - local tmpdir - local binary - - before_each(function() - binary = helpers.find_binary() - if not binary then - pending("basilisk binary not found — skipping LSP rename tests") - return - end - tmpdir = helpers.create_tmpdir() - - -- Write a pyproject.toml so basilisk finds a project root. - local fh = io.open(tmpdir .. "/pyproject.toml", "w") - fh:write('[project]\nname = "test"\nversion = "0.1.0"\n') - fh:close() - - vim.lsp.config("basilisk", { - cmd = { binary, "lsp" }, - filetypes = { "python" }, - root_markers = { "pyproject.toml", ".git" }, - settings = { basilisk = { analysisMode = "wholeModule" } }, - }) - vim.lsp.enable("basilisk") - end) - - after_each(function() - helpers.stop_clients() - helpers.close_all_buffers() - if tmpdir then - helpers.cleanup_tmpdir(tmpdir) - end - end) - - it("renames a function across definition and call sites", function() - if not binary then - return - end - - local source = table.concat({ - "def helper(x: int) -> int:", - " return x + 1", - "", - "a: int = helper(1)", - "b: int = helper(2)", - "", - }, "\n") - - local buf, uri = helpers.open_python_file(tmpdir, "rename_basic.py", source) - assert.truthy(helpers.wait_for_server_ready(buf)) - - local client = helpers.wait_for_client(buf) - assert.truthy(client, "LSP client must attach") - - -- Request rename at the function definition (line 0, char 4). - local err, result = helpers.lsp_request(client, "textDocument/rename", { - textDocument = { uri = uri }, - position = { line = 0, character = 4 }, - newName = "assist", - }, buf) - - assert.is_nil(err, "rename should not return an error") - assert.truthy(result, "rename should return a workspace edit") - - -- Server may return changes or documentChanges. - local file_edits = nil - if result.changes then - file_edits = result.changes[uri] - elseif result.documentChanges then - for _, change in ipairs(result.documentChanges) do - if change.textDocument and change.textDocument.uri == uri then - file_edits = change.edits - end - end - end - - assert.truthy(file_edits, "should have edits for the file") - assert(#file_edits >= 3, "expected at least 3 edits (1 def + 2 calls), got " .. #file_edits) - - for _, edit in ipairs(file_edits) do - assert.are.equal("assist", edit.newText) - end - end) - - it("scope-aware: local rename does not affect module-level", function() - if not binary then - return - end - - local source = table.concat({ - "x: int = 1", - "", - "def foo() -> int:", - " x: int = 2", - " return x", - "", - "y: int = x", - "", - }, "\n") - - local buf, uri = helpers.open_python_file(tmpdir, "rename_scope.py", source) - assert.truthy(helpers.wait_for_server_ready(buf)) - - local client = helpers.wait_for_client(buf) - assert.truthy(client) - - -- Rename `x` inside the function (line 3, char 4). - local err, result = helpers.lsp_request(client, "textDocument/rename", { - textDocument = { uri = uri }, - position = { line = 3, character = 4 }, - newName = "local_x", - }, buf) - - assert.is_nil(err) - assert.truthy(result) - assert.truthy(result.changes) - - -- Extract edits (server may use changes or documentChanges). - local edits = nil - if result.changes then - edits = result.changes[uri] - elseif result.documentChanges then - for _, change in ipairs(result.documentChanges) do - if change.textDocument and change.textDocument.uri == uri then - edits = change.edits - end - end - end - assert.truthy(edits, "should have edits") - - -- All edits must be within the function body (lines 3-4), NOT line 0 or 6. - for _, edit in ipairs(edits) do - local line = edit.range.start.line - assert( - line >= 3 and line <= 4, - "edit should only touch lines 3-4, but found edit on line " .. line - ) - assert.are.equal("local_x", edit.newText) - end - end) - - it("scope-aware: module rename skips shadowed local", function() - if not binary then - return - end - - local source = table.concat({ - "x: int = 1", - "", - "def foo() -> int:", - " x: int = 2", - " return x", - "", - "y: int = x", - "", - }, "\n") - - local buf, uri = helpers.open_python_file(tmpdir, "rename_module_scope.py", source) - assert.truthy(helpers.wait_for_server_ready(buf)) - - local client = helpers.wait_for_client(buf) - assert.truthy(client) - - -- Rename `x` at module level (line 0, char 0). - local err, result = helpers.lsp_request(client, "textDocument/rename", { - textDocument = { uri = uri }, - position = { line = 0, character = 0 }, - newName = "global_x", - }, buf) - - assert.is_nil(err) - assert.truthy(result) - - local edits = nil - if result.changes then - edits = result.changes[uri] - elseif result.documentChanges then - for _, change in ipairs(result.documentChanges) do - if change.textDocument and change.textDocument.uri == uri then - edits = change.edits - end - end - end - assert.truthy(edits) - - -- Should only touch line 0 and line 6, NOT lines 3-4 (shadowed in function). - for _, edit in ipairs(edits) do - local line = edit.range.start.line - assert( - line == 0 or line == 6, - "edit should only touch lines 0 and 6, but found edit on line " .. line - ) - assert.are.equal("global_x", edit.newText) - end - end) - - it("rejects rename to Python keyword", function() - if not binary then - return - end - - local source = "x: int = 1\n" - - local buf, uri = helpers.open_python_file(tmpdir, "rename_keyword.py", source) - assert.truthy(helpers.wait_for_server_ready(buf)) - - local client = helpers.wait_for_client(buf) - assert.truthy(client) - - -- Rename `x` to `class` (keyword). - local err, result = helpers.lsp_request(client, "textDocument/rename", { - textDocument = { uri = uri }, - position = { line = 0, character = 0 }, - newName = "class", - }, buf) - - -- Should return null/nil result (rejected). - assert.is_nil(result, "rename to keyword 'class' should return nil result") - end) - - it("nested function: outer rename skips inner shadow", function() - if not binary then - return - end - - local source = table.concat({ - "def outer() -> int:", - " x: int = 1", - " def inner() -> int:", - " x: int = 2", - " return x", - " return x", - "", - }, "\n") - - local buf, uri = helpers.open_python_file(tmpdir, "rename_nested.py", source) - assert.truthy(helpers.wait_for_server_ready(buf)) - - local client = helpers.wait_for_client(buf) - assert.truthy(client) - - -- Rename `x` in outer function (line 1, char 4). - local err, result = helpers.lsp_request(client, "textDocument/rename", { - textDocument = { uri = uri }, - position = { line = 1, character = 4 }, - newName = "outer_x", - }, buf) - - assert.is_nil(err) - assert.truthy(result) - - local edits = nil - if result.changes then - edits = result.changes[uri] - elseif result.documentChanges then - for _, change in ipairs(result.documentChanges) do - if change.textDocument and change.textDocument.uri == uri then - edits = change.edits - end - end - end - assert.truthy(edits) - - -- Should rename on lines 1 and 5 (outer), NOT lines 3-4 (inner shadow). - for _, edit in ipairs(edits) do - local line = edit.range.start.line - assert( - line == 1 or line == 5, - "edit should only touch lines 1 and 5, but found edit on line " .. line - ) - assert.are.equal("outer_x", edit.newText) - end - end) - - it("prepareRename returns range for valid position", function() - if not binary then - return - end - - local source = "def greet(name: str) -> str:\n return name\n" - - local buf, uri = helpers.open_python_file(tmpdir, "prepare_rename.py", source) - assert.truthy(helpers.wait_for_server_ready(buf)) - - local client = helpers.wait_for_client(buf) - assert.truthy(client) - - local err, result = helpers.lsp_request(client, "textDocument/prepareRename", { - textDocument = { uri = uri }, - position = { line = 0, character = 4 }, - }, buf) - - assert.is_nil(err) - assert.truthy(result, "prepareRename should return a result for a function name") - end) -end) diff --git a/basilisk.nvim/tests/lsp/test_explorer_spec.lua b/basilisk.nvim/tests/lsp/test_explorer_spec.lua deleted file mode 100644 index cc53ab276..000000000 --- a/basilisk.nvim/tests/lsp/test_explorer_spec.lua +++ /dev/null @@ -1,355 +0,0 @@ ---- Test explorer e2e tests — real pytest, real UI panels. ---- ---- Tests [NVIM-TEST-EXPLORER]. ---- ---- Tests discover, run, panel open/close, status updates with real pytest. - -local helpers = require("tests.lsp.helpers") - -local tmpdir - -describe("test explorer e2e", function() - before_each(function() - tmpdir = helpers.create_tmpdir() - -- Create actual Python test files. - local fh = io.open(tmpdir .. "/test_math.py", "w") - fh:write(table.concat({ - "def test_add():", - " assert 1 + 1 == 2", - "", - "def test_subtract():", - " assert 3 - 1 == 2", - "", - "class TestMultiply:", - " def test_positive(self):", - " assert 2 * 3 == 6", - "", - " def test_zero(self):", - " assert 0 * 100 == 0", - "", - }, "\n")) - fh:close() - - local fh2 = io.open(tmpdir .. "/test_string.py", "w") - fh2:write(table.concat({ - "def test_concat():", - " assert 'hello' + ' world' == 'hello world'", - "", - "def test_upper():", - " assert 'hello'.upper() == 'HELLO'", - "", - }, "\n")) - fh2:close() - end) - - after_each(function() - local testing = require("basilisk.testing") - testing.close() - helpers.close_all_buffers() - helpers.cleanup_tmpdir(tmpdir) - end) - - it("discovers tests from real pytest output", function() - local testing = require("basilisk.testing") - - -- Run pytest collect synchronously. - local output = vim.fn.system({ "pytest", "--collect-only", "-q", tmpdir }) - local tree = testing.parse_pytest_output(output) - - assert.is_true(#tree >= 2, "should discover at least 2 test files") - - -- Check structure: should have file > function and file > class > function. - local found_class = false - local found_function = false - for _, file_node in ipairs(tree) do - for _, child in ipairs(file_node.children) do - if child.kind == "class" then found_class = true end - if child.kind == "function" then found_function = true end - end - end - assert.is_true(found_function, "should have standalone test functions") - assert.is_true(found_class, "should have test classes") - end) - - it("parses test results from real pytest run", function() - local testing = require("basilisk.testing") - - -- Run tests synchronously. - local output = vim.fn.system({ "pytest", "-v", "--tb=short", tmpdir }) - testing.parse_test_results(output) - - -- Tests should have been tracked. - -- We can't check internal state directly, but parse_test_results - -- should not error on real output. - assert.is_true(true) - end) - - it("test panel opens with correct filetype and position", function() - local testing = require("basilisk.testing") - local config = require("basilisk.config").resolve() - - local win_count_before = #vim.api.nvim_tabpage_list_wins(0) - - testing.open(config) - - local win_count_after = #vim.api.nvim_tabpage_list_wins(0) - assert.is_true(win_count_after > win_count_before, "should open a new window") - - -- Find the test buffer. - local found = false - for _, buf in ipairs(vim.api.nvim_list_bufs()) do - if vim.bo[buf].filetype == "basilisk-tests" then - found = true - break - end - end - assert.is_true(found, "should create buffer with basilisk-tests filetype") - - testing.close() - assert.are.equal(win_count_before, #vim.api.nvim_tabpage_list_wins(0)) - end) - - it("test panel opens on the left", function() - local testing = require("basilisk.testing") - local config = require("basilisk.config").resolve({ test_explorer = { position = "left", width = 25 } }) - - testing.open(config) - -- Should not crash. - assert.is_true(#vim.api.nvim_tabpage_list_wins(0) >= 2) - testing.close() - end) - - it("test panel opens at bottom", function() - local testing = require("basilisk.testing") - local config = require("basilisk.config").resolve({ test_explorer = { position = "bottom" } }) - - testing.open(config) - assert.is_true(#vim.api.nvim_tabpage_list_wins(0) >= 2) - testing.close() - end) - - it("toggle opens and closes the panel", function() - local testing = require("basilisk.testing") - local config = require("basilisk.config").resolve() - - local before = #vim.api.nvim_tabpage_list_wins(0) - testing.toggle(config) - assert.is_true(#vim.api.nvim_tabpage_list_wins(0) > before) - testing.toggle(config) - assert.are.equal(before, #vim.api.nvim_tabpage_list_wins(0)) - end) - - it("coverage XML is parsed and applied", function() - local testing = require("basilisk.testing") - - -- Create a coverage XML file. - local cov_path = tmpdir .. "/coverage.xml" - local cfh = io.open(cov_path, "w") - cfh:write(table.concat({ - '', - '', - ' ', - ' ', - ' ', - ' ', - ' ', - ' ', - ' ', - ' ', - ' ', - '', - }, "\n")) - cfh:close() - - -- Apply coverage — should not error. - assert.has_no.errors(function() - testing.apply_coverage(cov_path) - end) - end) - - -- ── Discovery: Detailed Structure Validation ──────────────────────── - - it("discovery tree contains correct test names from real files", function() - local testing = require("basilisk.testing") - local output = vim.fn.system({ "pytest", "--collect-only", "-q", tmpdir }) - local tree = testing.parse_pytest_output(output) - - -- Collect all leaf test names. - local names = {} - local function collect(nodes) - for _, node in ipairs(nodes) do - if node.kind == "function" then - names[node.name] = true - end - collect(node.children) - end - end - collect(tree) - - -- Verify known test names from the fixture files. - assert.is_true(names["test_add"] ~= nil, "should find test_add") - assert.is_true(names["test_subtract"] ~= nil, "should find test_subtract") - assert.is_true(names["test_positive"] ~= nil, "should find test_positive (class method)") - assert.is_true(names["test_zero"] ~= nil, "should find test_zero (class method)") - assert.is_true(names["test_concat"] ~= nil, "should find test_concat from second file") - assert.is_true(names["test_upper"] ~= nil, "should find test_upper from second file") - end) - - it("discovery tree file nodes have correct kinds", function() - local testing = require("basilisk.testing") - local output = vim.fn.system({ "pytest", "--collect-only", "-q", tmpdir }) - local tree = testing.parse_pytest_output(output) - - for _, file_node in ipairs(tree) do - assert.are.equal("file", file_node.kind, "top-level nodes should be files") - assert.is_true(file_node.file ~= nil, "file nodes should have a file path") - end - end) - - it("class methods are nested under their class node", function() - local testing = require("basilisk.testing") - local output = vim.fn.system({ "pytest", "--collect-only", "-q", tmpdir }) - local tree = testing.parse_pytest_output(output) - - -- Find the file with TestMultiply. - local multiply_class = nil - for _, file_node in ipairs(tree) do - for _, child in ipairs(file_node.children) do - if child.name == "TestMultiply" then - multiply_class = child - break - end - end - end - assert.is_not_nil(multiply_class, "should find TestMultiply class") - assert.are.equal("class", multiply_class.kind) - assert.are.equal(2, #multiply_class.children, "TestMultiply should have 2 methods") - end) - - -- ── Discovery: File with Only Functions ───────────────────────────── - - it("discovers file with only standalone functions", function() - local testing = require("basilisk.testing") - - -- Create a test file with only functions. - local fh = io.open(tmpdir .. "/test_funcs_only.py", "w") - fh:write(table.concat({ - "def test_alpha():", - " assert True", - "", - "def test_beta():", - " assert True", - "", - "def test_gamma():", - " assert True", - "", - }, "\n")) - fh:close() - - local output = vim.fn.system({ "pytest", "--collect-only", "-q", tmpdir .. "/test_funcs_only.py" }) - local tree = testing.parse_pytest_output(output) - - assert.are.equal(1, #tree, "should find 1 file") - assert.are.equal(3, #tree[1].children, "should find 3 test functions") - for _, child in ipairs(tree[1].children) do - assert.are.equal("function", child.kind) - end - end) - - -- ── Discovery: Empty Test File ────────────────────────────────────── - - it("discovers nothing from empty test file", function() - local testing = require("basilisk.testing") - - local fh = io.open(tmpdir .. "/test_empty.py", "w") - fh:write("# no tests here\nx = 42\n") - fh:close() - - local output = vim.fn.system({ "pytest", "--collect-only", "-q", tmpdir .. "/test_empty.py" }) - local tree = testing.parse_pytest_output(output) - assert.are.equal(0, #tree) - end) - - -- ── Test Run Results ───────────────────────────────────────────────── - - it("real pytest run produces parseable output", function() - local testing = require("basilisk.testing") - - -- First discover so the tree is populated. - local collect_output = vim.fn.system({ "pytest", "--collect-only", "-q", tmpdir }) - testing.parse_pytest_output(collect_output) - - -- Now run the tests. - local run_output = vim.fn.system({ "pytest", "-v", "--tb=short", tmpdir }) - assert.has_no.errors(function() - testing.parse_test_results(run_output) - end) - end) - - -- ── Panel: Display After Discovery ────────────────────────────────── - - it("panel shows discovered tests after refresh", function() - local testing = require("basilisk.testing") - local config = require("basilisk.config").resolve() - - -- Discover and open. - local output = vim.fn.system({ "pytest", "--collect-only", "-q", tmpdir }) - testing.parse_pytest_output(output) - testing.open(config) - testing.refresh_display() - - -- Find the test buffer and check content. - for _, buf in ipairs(vim.api.nvim_list_bufs()) do - if vim.bo[buf].filetype == "basilisk-tests" then - local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) - assert.is_true(#lines > 0, "buffer should have content") - -- Should NOT show the placeholder text. - assert.is_false( - lines[1]:find("No tests") ~= nil, - "should show test tree, not placeholder" - ) - break - end - end - testing.close() - end) - - -- ── Panel: Buffer Properties ──────────────────────────────────────── - - it("panel buffer is non-modifiable", function() - local testing = require("basilisk.testing") - local config = require("basilisk.config").resolve() - - testing.open(config) - testing.refresh_display() - - for _, buf in ipairs(vim.api.nvim_list_bufs()) do - if vim.bo[buf].filetype == "basilisk-tests" then - assert.is_false(vim.bo[buf].modifiable, "test buffer should be non-modifiable") - assert.is_false(vim.bo[buf].swapfile, "test buffer should have no swapfile") - break - end - end - testing.close() - end) - - -- ── Panel: Window Options ────────────────────────────────────────── - - it("panel window has no line numbers", function() - local testing = require("basilisk.testing") - local config = require("basilisk.config").resolve() - - testing.open(config) - - -- Find the window showing basilisk-tests. - for _, win in ipairs(vim.api.nvim_tabpage_list_wins(0)) do - local buf = vim.api.nvim_win_get_buf(win) - if vim.bo[buf].filetype == "basilisk-tests" then - assert.is_false(vim.wo[win].number, "test panel should have no line numbers") - assert.is_false(vim.wo[win].relativenumber, "test panel should have no relative numbers") - break - end - end - testing.close() - end) -end) diff --git a/basilisk.nvim/tests/lsp/ui_spec.lua b/basilisk.nvim/tests/lsp/ui_spec.lua deleted file mode 100644 index 42aea1794..000000000 --- a/basilisk.nvim/tests/lsp/ui_spec.lua +++ /dev/null @@ -1,346 +0,0 @@ ---- Real UI interaction tests with the actual LSP server. ---- ---- Tests keymaps, inlay hints, code lens, status line updates, ---- and diagnostic displays with REAL LSP — no mocking. - -local helpers = require("tests.lsp.helpers") - -local binary = helpers.find_binary() -if not binary then - describe("basilisk UI interactions (SKIPPED — no binary)", function() - it("skipped", function() - pending("basilisk binary not found") - end) - end) - return -end - -local tmpdir - -describe("basilisk UI interactions with real LSP", function() - before_each(function() - tmpdir = helpers.create_tmpdir() - -- [tool.basilisk.rules] opts into the annotation house rules (off by - -- default) so untyped-parameter diagnostics fire — mirrors the Rust LSP - -- harness fixture (ws_test_common.rs). - local fh = io.open(tmpdir .. "/pyproject.toml", "w") - fh:write('[project]\nname = "test"\nversion = "0.1.0"\n') - fh:write('\n[tool.basilisk.rules]\n"BSK-0001" = "error"\n"BSK-0002" = "error"\n') - fh:close() - - vim.lsp.config("basilisk", { - cmd = { binary, "lsp" }, - filetypes = { "python" }, - root_markers = { "pyproject.toml", ".git" }, - settings = { basilisk = { analysisMode = "wholeModule" } }, - }) - vim.lsp.enable("basilisk") - end) - - after_each(function() - helpers.stop_clients() - helpers.close_all_buffers() - helpers.cleanup_tmpdir(tmpdir) - end) - - -- Status line updates with real LSP state - - it("statusline shows ready state when LSP is running", function() - local statusline = require("basilisk.statusline") - - local buf = helpers.open_python_file(tmpdir, "test_status.py", "x: int = 1\n") - helpers.wait_for_server_ready(buf) - - -- Unpin state so update() can detect the client. - statusline.set_state("ready") - - local text = statusline.get() - assert.truthy(text:find("Basilisk"), "statusline should contain Basilisk") - end) - - it("statusline shows diagnostic counts", function() - local statusline = require("basilisk.statusline") - - local buf = helpers.open_python_file(tmpdir, "test_diag_status.py", "def greet(name):\n return name\n") - helpers.wait_for_server_ready(buf) - helpers.wait_for_diagnostics(buf) - - -- Force state to ready so update() counts diagnostics. - statusline.set_state("ready") - - local text = statusline.get() - -- The status line should reflect some diagnostic presence. - assert.truthy(text:find("Basilisk"), "statusline should contain Basilisk") - end) - - -- Inlay hints with real LSP - - it("inlay hints can be enabled on a buffer", function() - local buf = helpers.open_python_file(tmpdir, "test_hints.py", "x = 42\ny = 'hello'\n") - local client = helpers.wait_for_client(buf) - assert.is_not_nil(client) - helpers.wait_for_server_ready(buf) - - -- Enable inlay hints. - if client:supports_method("textDocument/inlayHint") then - vim.lsp.inlay_hint.enable(true, { bufnr = buf }) - vim.wait(2000) - -- Inlay hints are enabled — this verifies no error occurs. - assert.is_true(true) - end - end) - - -- Code lens with real LSP - - it("code lens refresh does not error", function() - local buf = helpers.open_python_file(tmpdir, "test_codelens.py", "def helper(x: int) -> int:\n return x\n\nresult = helper(42)\n") - local client = helpers.wait_for_client(buf) - assert.is_not_nil(client) - helpers.wait_for_server_ready(buf) - - if client:supports_method("textDocument/codeLens") then - -- Go through the plugin's version-compatible activation so this stays - -- green on Neovim 0.13, where vim.lsp.codelens.refresh is removed. - local ok = pcall(require("basilisk.codelens").activate, buf) - assert.is_true(ok, "code lens activation should not error") - end - end) - - -- vim.lsp.buf.hover() with real LSP - - it("hover function works via real LSP", function() - local buf = helpers.open_python_file(tmpdir, "test_hover_ui.py", "def helper(x: int) -> int:\n return x + 1\n\nresult = helper(42)\n") - local client = helpers.wait_for_client(buf) - assert.is_not_nil(client) - helpers.wait_for_server_ready(buf) - - -- Move cursor to function name. - vim.api.nvim_win_set_cursor(0, { 1, 4 }) - - -- Call hover — should not error. - local ok = pcall(vim.lsp.buf.hover) - assert.is_true(ok, "vim.lsp.buf.hover() should not error") - end) - - -- vim.lsp.buf.definition() with real LSP - - it("go-to-definition works via real LSP", function() - local buf = helpers.open_python_file(tmpdir, "test_gotodef_ui.py", "def helper(x: int) -> int:\n return x + 1\n\nresult = helper(42)\n") - local client = helpers.wait_for_client(buf) - assert.is_not_nil(client) - helpers.wait_for_server_ready(buf) - - -- Place cursor on the call site. - vim.api.nvim_win_set_cursor(0, { 4, 9 }) - - local ok = pcall(vim.lsp.buf.definition) - assert.is_true(ok, "vim.lsp.buf.definition() should not error") - end) - - -- vim.lsp.buf.references() with real LSP - - it("find references works via real LSP", function() - local buf = helpers.open_python_file(tmpdir, "test_refs_ui.py", "def helper(x: int) -> int:\n return x + 1\n\na = helper(1)\nb = helper(2)\n") - local client = helpers.wait_for_client(buf) - assert.is_not_nil(client) - helpers.wait_for_server_ready(buf) - - vim.api.nvim_win_set_cursor(0, { 1, 4 }) - - local ok = pcall(vim.lsp.buf.references) - assert.is_true(ok, "vim.lsp.buf.references() should not error") - end) - - -- vim.lsp.buf.rename() with real LSP - - it("rename works via real LSP", function() - local buf = helpers.open_python_file(tmpdir, "test_rename_ui.py", "def helper(x: int) -> int:\n return x + 1\n\nresult = helper(42)\n") - local client = helpers.wait_for_client(buf) - assert.is_not_nil(client) - helpers.wait_for_server_ready(buf) - - vim.api.nvim_win_set_cursor(0, { 1, 4 }) - - -- Request rename via the LSP request directly (to avoid UI input prompt). - local err, result = helpers.lsp_request(client, "textDocument/rename", { - textDocument = { uri = vim.uri_from_bufnr(buf) }, - position = { line = 0, character = 4 }, - newName = "my_helper", - }, buf) - - assert.is_nil(err) - if result then - -- Apply the workspace edit. - local ok = pcall(vim.lsp.util.apply_workspace_edit, result, "utf-8") - assert.is_true(ok, "applying rename workspace edit should not error") - - -- Verify the buffer content changed. - local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) - local text = table.concat(lines, "\n") - assert.truthy(text:find("my_helper"), "buffer should contain renamed symbol") - end - end) - - -- vim.lsp.buf.code_action() with real LSP - - it("code action works via real LSP", function() - local buf = helpers.open_python_file(tmpdir, "test_action_ui.py", "def greet(name):\n return name\n") - local client = helpers.wait_for_client(buf) - assert.is_not_nil(client) - helpers.wait_for_server_ready(buf) - helpers.wait_for_diagnostics(buf) - - vim.api.nvim_win_set_cursor(0, { 1, 10 }) - - -- Request code actions directly. - local err, result = helpers.lsp_request(client, "textDocument/codeAction", { - textDocument = { uri = vim.uri_from_bufnr(buf) }, - range = { - start = { line = 0, character = 0 }, - ["end"] = { line = 0, character = 20 }, - }, - context = { diagnostics = {} }, - }, buf) - - assert.is_nil(err, "codeAction request should not error") - end) - - -- vim.lsp.buf.format() with real LSP - - it("format works via real LSP", function() - local buf = helpers.open_python_file(tmpdir, "test_format_ui.py", "def greet( name:str )->str:\n return name\n") - local client = helpers.wait_for_client(buf) - assert.is_not_nil(client) - helpers.wait_for_server_ready(buf) - - local ok = pcall(vim.lsp.buf.format, { bufnr = buf, timeout_ms = 5000 }) - -- The Ruff formatter is embedded in the binary ([LSPFMT-ENGINE]); - -- formatting must succeed with no external ruff installed (#254). - assert.is_true(ok, "vim.lsp.buf.format must succeed") - end) - - -- vim.lsp.buf.document_symbol() with real LSP - - it("document symbols work via real LSP", function() - local buf = helpers.open_python_file(tmpdir, "test_symbols_ui.py", "class MyClass:\n def method(self) -> None:\n pass\n\ndef standalone() -> None:\n pass\n") - local client = helpers.wait_for_client(buf) - assert.is_not_nil(client) - helpers.wait_for_server_ready(buf) - - local ok = pcall(vim.lsp.buf.document_symbol) - assert.is_true(ok, "vim.lsp.buf.document_symbol() should not error") - end) - - -- Edit-diagnose-fix-clear cycle (full lifecycle) - - it("full edit-diagnose-fix-clear lifecycle", function() - local buf = helpers.open_python_file(tmpdir, "test_lifecycle.py", "def greet(name: str) -> str:\n return name\n") - helpers.wait_for_server_ready(buf) - - -- Should start clean. - vim.wait(3000) - assert.are.equal(0, #vim.diagnostic.get(buf), "clean code should have no diagnostics") - - -- Introduce an error. - helpers.replace_content(buf, "def greet(name):\n return name\n") - vim.cmd("write") - local diags = helpers.wait_for_diagnostics(buf) - assert.is_true(#diags > 0, "untyped param should produce diagnostics") - - -- Fix the error. - helpers.replace_content(buf, "def greet(name: str) -> str:\n return name\n") - vim.cmd("write") - local cleared = helpers.wait_for_diagnostics_cleared(buf) - assert.is_true(cleared, "diagnostics should clear after fix") - end) - - -- :BasiliskInfo floating window with live LSP - - it(":BasiliskInfo opens floating window with correct content", function() - local buf = helpers.open_python_file(tmpdir, "test_info_cmd.py", "x: int = 1\n") - helpers.wait_for_server_ready(buf) - - -- Register commands manually (normally done by setup()). - local basilisk = require("basilisk") - basilisk.config = require("basilisk.config").resolve({ binary_path = binary }) - require("basilisk.commands").register(basilisk.config) - - vim.cmd("BasiliskInfo") - - -- Find the floating window (not the main editor window). - local wins = vim.api.nvim_list_wins() - local float_win = nil - local float_buf = nil - for _, win in ipairs(wins) do - local win_config = vim.api.nvim_win_get_config(win) - if win_config.relative and win_config.relative ~= "" then - float_win = win - float_buf = vim.api.nvim_win_get_buf(win) - break - end - end - - assert.is_not_nil(float_win, ":BasiliskInfo should open a floating window") - assert.is_not_nil(float_buf, "floating window should have a buffer") - - local lines = vim.api.nvim_buf_get_lines(float_buf, 0, -1, false) - local text = table.concat(lines, "\n") - - -- Verify content. - assert.truthy(text:find("Basilisk LSP Server Info"), "should contain title") - assert.truthy(text:find("Status:%s+active"), "should show active status") - assert.truthy(text:find("Binary:"), "should show binary path") - assert.truthy(text:find("Version:"), "should show version") - assert.truthy(text:find("Mode:"), "should show analysis mode") - - -- Close the float. - if float_win and vim.api.nvim_win_is_valid(float_win) then - vim.api.nvim_win_close(float_win, true) - end - end) - - -- :BasiliskTestToggle side panel - - it(":BasiliskTestToggle opens and closes test explorer panel", function() - local buf = helpers.open_python_file(tmpdir, "test_toggle_cmd.py", "x: int = 1\n") - helpers.wait_for_server_ready(buf) - - -- Register commands manually (normally done by setup()). - local basilisk = require("basilisk") - basilisk.config = require("basilisk.config").resolve({ binary_path = binary }) - require("basilisk.commands").register(basilisk.config) - - local initial_win_count = #vim.api.nvim_list_wins() - - -- Open the test explorer. - vim.cmd("BasiliskTestToggle") - vim.wait(500) - - local after_open_wins = vim.api.nvim_list_wins() - assert.is_true(#after_open_wins > initial_win_count, "toggle should open a new window") - - -- Find the test explorer window by its buffer filetype. - local panel_win = nil - for _, win in ipairs(after_open_wins) do - local win_buf = vim.api.nvim_win_get_buf(win) - local ft = vim.bo[win_buf].filetype - if ft == "basilisk-tests" then - panel_win = win - break - end - end - - assert.is_not_nil(panel_win, "test explorer panel should have filetype basilisk-tests") - - -- Verify the panel window width is reasonable (side panel). - local panel_width = vim.api.nvim_win_get_width(panel_win) - assert.is_true(panel_width > 0 and panel_width < vim.o.columns, "panel should be a side split") - - -- Close via toggle. - vim.cmd("BasiliskTestToggle") - vim.wait(500) - - local after_close_wins = #vim.api.nvim_list_wins() - assert.are.equal(initial_win_count, after_close_wins, "toggle again should close the panel") - end) -end) diff --git a/basilisk.nvim/tests/lsp/uv_spec.lua b/basilisk.nvim/tests/lsp/uv_spec.lua deleted file mode 100644 index a8b3ec168..000000000 --- a/basilisk.nvim/tests/lsp/uv_spec.lua +++ /dev/null @@ -1,207 +0,0 @@ ---- Tests for uv integration in basilisk.nvim. ---- ---- Validates that uv commands are properly defined, config defaults are ---- correct, and uv settings are passed to the LSP server. - -describe("uv integration", function() - local config_mod - - before_each(function() - package.loaded["basilisk.config"] = nil - config_mod = require("basilisk.config") - end) - - -- uv config defaults - - describe("config defaults", function() - it("uv is enabled by default", function() - assert.is_true(config_mod.defaults.uv.enabled) - end) - - it("uv executable_path defaults to nil (auto-detect)", function() - assert.is_nil(config_mod.defaults.uv.executable_path) - end) - - it("uv auto_sync defaults to false", function() - assert.is_false(config_mod.defaults.uv.auto_sync) - end) - - end) - - -- uv config resolution - - describe("config resolution", function() - it("resolves uv defaults when no overrides given", function() - local resolved = config_mod.resolve({}) - assert.is_true(resolved.uv.enabled) - assert.is_nil(resolved.uv.executable_path) - assert.is_false(resolved.uv.auto_sync) - end) - - it("overrides uv settings from user config", function() - local resolved = config_mod.resolve({ - uv = { - enabled = false, - executable_path = "/usr/local/bin/uv", - auto_sync = true, - }, - }) - assert.is_false(resolved.uv.enabled) - assert.are.equal("/usr/local/bin/uv", resolved.uv.executable_path) - assert.is_true(resolved.uv.auto_sync) - end) - - it("partial uv override preserves other defaults", function() - local resolved = config_mod.resolve({ - uv = { auto_sync = true }, - }) - assert.is_true(resolved.uv.enabled) - assert.is_true(resolved.uv.auto_sync) - end) - end) - - -- uv commands are defined - - describe("commands", function() - it("registers all uv user commands", function() - -- Load the commands module to trigger registration. - package.loaded["basilisk.commands"] = nil - local commands_mod = require("basilisk.commands") - local resolved = config_mod.resolve({}) - - -- Stub out dependencies that may not be available in test. - package.loaded["basilisk.lsp"] = { - reset_restart_count = function() end, - restart = function() end, - get_restart_count = function() return 0 end, - } - package.loaded["basilisk.profiling"] = { - start = function() end, - stop = function() end, - snapshot = function() end, - } - package.loaded["basilisk.memory"] = { - start = function() end, - stop = function() end, - refs = function() end, - complete_refs = function() return {} end, - } - package.loaded["basilisk.testing"] = { - discover = function() end, - run = function() end, - debug = function() end, - toggle = function() end, - setup_auto_discover = function() end, - } - - commands_mod.register(resolved) - - local expected_commands = { - "BasiliskUvSync", - "BasiliskUvAdd", - "BasiliskUvAddDev", - "BasiliskUvRemove", - "BasiliskUvLock", - "BasiliskUvCreateEnv", - } - - for _, cmd_name in ipairs(expected_commands) do - local ok, info = pcall(vim.api.nvim_get_commands, { builtin = false }) - if ok and info then - -- nvim_get_commands returns a table keyed by command name. - assert.is_not_nil(info[cmd_name], cmd_name .. " should be registered") - end - end - end) - end) -end) - --- ── Real LSP e2e tests for uv commands ─────────────────────────────────────── - -local helpers = require("tests.lsp.helpers") - -local binary = helpers.find_binary() -if not binary then - describe("uv commands with real LSP (SKIPPED — no binary)", function() - it("skipped", function() - pending("basilisk binary not found") - end) - end) - return -end - -local tmpdir - -describe("uv commands with real LSP", function() - before_each(function() - tmpdir = helpers.create_tmpdir() - local fh = io.open(tmpdir .. "/pyproject.toml", "w") - fh:write('[project]\nname = "test"\nversion = "0.1.0"\n') - fh:close() - - vim.lsp.config("basilisk", { - cmd = { binary, "lsp" }, - filetypes = { "python" }, - root_markers = { "pyproject.toml", ".git" }, - settings = { basilisk = { analysisMode = "wholeModule" } }, - }) - vim.lsp.enable("basilisk") - end) - - after_each(function() - helpers.stop_clients() - helpers.close_all_buffers() - helpers.cleanup_tmpdir(tmpdir) - end) - - --- Helper: register commands and get client. - local function setup_commands(buf) - helpers.wait_for_server_ready(buf) - local basilisk = require("basilisk") - basilisk.config = require("basilisk.config").resolve({ binary_path = binary }) - require("basilisk.commands").register(basilisk.config) - return helpers.wait_for_client(buf) - end - - it(":BasiliskUvSync sends real LSP command", function() - local buf = helpers.open_python_file(tmpdir, "test_uvsync.py", "x: int = 1\n") - setup_commands(buf) - local ok = pcall(vim.cmd, "BasiliskUvSync") - assert.is_true(ok, ":BasiliskUvSync should not error") - end) - - it(":BasiliskUvAdd sends real LSP command", function() - local buf = helpers.open_python_file(tmpdir, "test_uvadd.py", "x: int = 1\n") - setup_commands(buf) - local ok = pcall(vim.cmd, "BasiliskUvAdd requests") - assert.is_true(ok, ":BasiliskUvAdd should not error") - end) - - it(":BasiliskUvAddDev sends real LSP command", function() - local buf = helpers.open_python_file(tmpdir, "test_uvadddev.py", "x: int = 1\n") - setup_commands(buf) - local ok = pcall(vim.cmd, "BasiliskUvAddDev pytest") - assert.is_true(ok, ":BasiliskUvAddDev should not error") - end) - - it(":BasiliskUvRemove sends real LSP command", function() - local buf = helpers.open_python_file(tmpdir, "test_uvremove.py", "x: int = 1\n") - setup_commands(buf) - local ok = pcall(vim.cmd, "BasiliskUvRemove requests") - assert.is_true(ok, ":BasiliskUvRemove should not error") - end) - - it(":BasiliskUvLock sends real LSP command", function() - local buf = helpers.open_python_file(tmpdir, "test_uvlock.py", "x: int = 1\n") - setup_commands(buf) - local ok = pcall(vim.cmd, "BasiliskUvLock") - assert.is_true(ok, ":BasiliskUvLock should not error") - end) - - it(":BasiliskUvCreateEnv sends real LSP command", function() - local buf = helpers.open_python_file(tmpdir, "test_uvenv.py", "x: int = 1\n") - setup_commands(buf) - local ok = pcall(vim.cmd, "BasiliskUvCreateEnv") - assert.is_true(ok, ":BasiliskUvCreateEnv should not error") - end) -end) diff --git a/basilisk.nvim/tests/minimal_init.lua b/basilisk.nvim/tests/minimal_init.lua index 3a75a5c90..bf50cf849 100644 --- a/basilisk.nvim/tests/minimal_init.lua +++ b/basilisk.nvim/tests/minimal_init.lua @@ -1,7 +1,7 @@ --- Minimal init for plenary.nvim tests. --- --- Usage: nvim --headless -u tests/minimal_init.lua -c "PlenaryBustedDirectory tests/basilisk" ---- Coverage: LUACOV=1 nvim --headless -u tests/minimal_init.lua ... +--- Coverage: LUACOV=1 nvim --headless -u tests/minimal_init.lua -l tests/run_coverage.lua -- Add this plugin to the runtime path. local plugin_dir = vim.fn.fnamemodify(debug.getinfo(1, "S").source:sub(2), ":h:h") @@ -52,13 +52,7 @@ if os.getenv("LUACOV") and not plenary_parent then local ok, runner = pcall(require, "luacov.runner") if ok then - runner.init({ - configfile = plugin_dir .. "/.luacov", - -- Plenary terminates children with `:cq`, which can bypass a complete - -- exit-only flush. Periodic saves are safe now that the orchestration - -- parent is excluded and children run sequentially. - tick = true, - }) + runner.init({ configfile = plugin_dir .. "/.luacov", tick = true }) -- Flush coverage data on VimLeave so headless runs don't lose data. vim.api.nvim_create_autocmd("VimLeavePre", { callback = function() @@ -84,33 +78,6 @@ for _, path in ipairs(plenary_paths) do end end --- Add mini.test if available. -local mini_paths = { - "/tmp/mini.nvim", - vim.fn.expand("~/.local/share/nvim/lazy/mini.nvim"), - vim.fn.expand("~/.local/share/nvim/lazy/mini.test"), - vim.fn.stdpath("data") .. "/lazy/mini.nvim", -} -for _, path in ipairs(mini_paths) do - if vim.fn.isdirectory(path) == 1 then - vim.opt.rtp:prepend(path) - break - end -end - --- Add nvim-dap if available. -local dap_paths = { - "/tmp/nvim-dap", - vim.fn.expand("~/.local/share/nvim/lazy/nvim-dap"), - vim.fn.stdpath("data") .. "/lazy/nvim-dap", -} -for _, path in ipairs(dap_paths) do - if vim.fn.isdirectory(path) == 1 then - vim.opt.rtp:prepend(path) - break - end -end - -- Minimal settings. vim.o.swapfile = false vim.o.backup = false diff --git a/basilisk.nvim/tests/run_coverage.lua b/basilisk.nvim/tests/run_coverage.lua index 8951eb183..570877e91 100644 --- a/basilisk.nvim/tests/run_coverage.lua +++ b/basilisk.nvim/tests/run_coverage.lua @@ -1,748 +1,35 @@ ---- Full e2e coverage exerciser — runs REAL LSP, REAL nvim-dap, REAL pytest. +--- Coverage exerciser — drives every public entry point of the notice plugin. --- ---- No mocks. No unit tests. Every code path exercised through real interactions. +--- The plugin is a notice ([WITHDRAWAL-SURFACES]): three modules, no server, no +--- adapter. Plenary runs its specs in child processes whose luacov stats do not +--- survive to the parent, so this single-process pass is what produces +--- luacov.stats.out for the threshold gate. --- --- Usage: LUACOV=1 nvim --headless -u tests/minimal_init.lua -l tests/run_coverage.lua -local function close_floats() - for _, w in ipairs(vim.api.nvim_list_wins()) do - local cfg = vim.api.nvim_win_get_config(w) - if cfg.relative and cfg.relative ~= "" then - pcall(vim.api.nvim_win_close, w, true) - end - end -end - -local function wait(ms) - vim.wait(ms or 200) -end - -print("=== Full e2e coverage exerciser ===\n") - --- Resolve binary upfront. -local binary_mod = require("basilisk.binary") -local lsp_binary = binary_mod.resolve() - or vim.fn.exepath("basilisk") -if lsp_binary == "" then lsp_binary = nil end - --- ============================================================ --- 1. config.lua — 100% achievable, pure logic --- ============================================================ -print("--- config.lua ---") -local config_mod = require("basilisk.config") --- Defaults access -local d = config_mod.defaults -assert(d.analysis_mode == "wholeModule") -assert(d.enabled and d.use_lsp) -assert(d.trace_server == "off") -assert(d.inlay_hints.parameter_names and d.inlay_hints.variable_types) -assert(d.formatter == "ruff") -assert(d.debugger.enabled and not d.debugger.type_checking) -assert(d.test_explorer.enabled and d.test_explorer.framework == "auto") -assert(d.test_explorer.pytest_path == "pytest" and d.test_explorer.auto_discover_on_save) -assert(d.test_explorer.position == "right" and d.test_explorer.width == 40) -assert(d.uv.enabled and not d.uv.auto_sync) -assert(d.keymaps.enabled and d.keymaps.prefix == "b") -assert(d.statusline.enabled and d.log_level == "info") - --- resolve: no opts, empty opts, overrides, deep merge -config_mod.resolve() -config_mod.resolve({}) -config_mod.resolve({ analysis_mode = "openFilesOnly", formatter = "none" }) -config_mod.resolve({ inlay_hints = { parameter_names = false } }) -config_mod.resolve({ debugger = { type_checking = true } }) -config_mod.resolve({ uv = { auto_sync = true } }) -config_mod.resolve({ test_explorer = { position = "left", width = 30 } }) -config_mod.resolve({ test_explorer = { position = "bottom" } }) - --- validate: valid + every error branch -assert(#config_mod.validate(config_mod.resolve()) == 0) -assert(#config_mod.validate(config_mod.resolve({ analysis_mode = "bad" })) == 1) -assert(#config_mod.validate(config_mod.resolve({ test_explorer = { framework = "bad" } })) == 1) -assert(#config_mod.validate(config_mod.resolve({ test_explorer = { position = "top" } })) == 1) -assert(#config_mod.validate(config_mod.resolve({ log_level = "verbose" })) == 1) - --- ============================================================ --- 2. binary.lua — all resolution paths --- ============================================================ -print("--- binary.lua ---") --- is_executable: the public guard `lsp.start` consults before resolving, over --- every shape a configured `binary_path` can take. -assert(binary_mod.is_executable(nil) == false) -assert(binary_mod.is_executable("") == false) -assert(binary_mod.is_executable("/nonexistent/basilisk") == false) -assert(binary_mod.is_executable(42) == false) --- configured path: nil, empty, nonexistent, valid -binary_mod.resolve(nil) -binary_mod.resolve("") -binary_mod.resolve("/nonexistent/basilisk") -local ls_path = vim.fn.exepath("ls") -if ls_path ~= "" then - binary_mod.resolve(ls_path) - assert(binary_mod.is_executable(ls_path) == true) -end --- env var: nil, empty, valid, invalid -local orig_env = vim.env.BASILISK_PATH -vim.env.BASILISK_PATH = nil -binary_mod.resolve() -vim.env.BASILISK_PATH = "" -binary_mod.resolve() -if ls_path ~= "" then - vim.env.BASILISK_PATH = ls_path - binary_mod.resolve() -end -vim.env.BASILISK_PATH = "/nonexistent" -binary_mod.resolve() -vim.env.BASILISK_PATH = orig_env --- well-known paths: exercised by resolve() above --- PATH fallback: exercised by resolve() above --- managed cache scan ([NVIM-BINARY-UPGRADE-MANAGED-DISCOVERY]): two installed --- versions (newest must win) plus a binary-less dir from a failed extraction. --- Earlier cascade steps are blinded, otherwise a real install on this machine --- answers first and the scan never runs. -local managed_root = vim.fn.stdpath("data") .. "/basilisk" -local coverage_dirs = { "v0.0.1-coverage", "v0.0.2-coverage", "v0.0.3-coverage-empty" } -for index, version in ipairs(coverage_dirs) do - local dir = managed_root .. "/" .. version - vim.fn.mkdir(dir, "p") - if index < 3 then - vim.fn.writefile({ "#!/bin/sh", "echo 'basilisk 0.0.0'" }, dir .. "/basilisk") - vim.fn.setfperm(dir .. "/basilisk", "rwxr-xr-x") - end -end -local orig_exepath, orig_executable = vim.fn.exepath, vim.fn.executable -vim.fn.exepath = function() return "" end -vim.fn.executable = function(path) - return type(path) == "string" and path:find(managed_root, 1, true) == 1 and 1 or 0 -end -binary_mod.locate(nil) -vim.fn.exepath, vim.fn.executable = orig_exepath, orig_executable -for _, version in ipairs(coverage_dirs) do - vim.fn.delete(managed_root .. "/" .. version, "rf") -end --- and the absent-cache path, with the cache moved aside -local stash = managed_root .. ".coverage-stash" -local had_cache = vim.fn.isdirectory(managed_root) == 1 -if had_cache then vim.fn.rename(managed_root, stash) end -binary_mod.locate(nil) -if had_cache then - vim.fn.delete(managed_root, "rf") - vim.fn.rename(stash, managed_root) -end --- version: nonexistent, valid -binary_mod.version("/nonexistent") -if ls_path ~= "" then binary_mod.version(ls_path) end --- is_newer_version: all comparison paths -assert(binary_mod.is_newer_version("0.1.0", "0.2.0")) -assert(binary_mod.is_newer_version("0.2.0", "1.0.0")) -assert(binary_mod.is_newer_version("0.2.1", "0.2.2")) -assert(not binary_mod.is_newer_version("0.2.1", "0.2.1")) -assert(not binary_mod.is_newer_version("1.0.0", "0.9.9")) -assert(binary_mod.is_newer_version("v0.1.0", "v0.2.0")) -assert(binary_mod.is_newer_version("basilisk 0.1.0", "v0.2.0")) --- platform_asset_name: detect current platform -local asset_name, is_windows = binary_mod.platform_asset_name() -if asset_name then - assert(asset_name:match("^basilisk%-")) - assert(type(is_windows) == "boolean") -end --- fetch_latest_release: real GitHub API call -local release = binary_mod.fetch_latest_release() -if release then - assert(type(release.tag_name) == "string") - assert(type(release.assets) == "table") -end --- download: real download from GitHub -local dl_path, dl_version = binary_mod.download() -if dl_path then - assert(vim.fn.executable(dl_path) == 1) - -- Clean up. - local dl_dir = vim.fn.stdpath("data") .. "/basilisk/" .. dl_version - vim.fn.delete(dl_dir, "rf") -end --- check_for_updates: async, non-blocking -binary_mod.check_for_updates("/nonexistent") -if ls_path ~= "" then binary_mod.check_for_updates(ls_path) end - --- ============================================================ --- 3. log.lua — every level, file logging, edge cases --- ============================================================ -print("--- log.lua ---") -local log = require("basilisk.log") --- Every level -for _, lvl in ipairs({ "trace", "debug", "info", "warn", "error" }) do - log.set_level(lvl) - log.trace("t %s", "a") - log.debug("d %d", 1) - log.info("i") - log.warn("w") - log.error("e") -end -log.set_level("invalid") -- no-op -log.set_level("info") --- File logging -local tmplog = vim.fn.tempname() .. ".log" -log.enable_file(tmplog) -log.info("file test") -log.close_file() -log.close_file() -- double close -local fh = io.open(tmplog, "r") -assert(fh and fh:read("*a"):find("file test")) -if fh then fh:close() end -os.remove(tmplog) - --- ============================================================ --- 4. statusline.lua — every state, diagnostics, lualine --- ============================================================ -print("--- statusline.lua ---") -local sl = require("basilisk.statusline") --- All four states -for _, state in ipairs({ "stopped", "starting", "error", "ready" }) do - sl.set_state(state) - local t = sl.get() - assert(t:find("Basilisk")) - sl.get_color() -end --- Stopped = Comment, starting = DiagnosticWarn, error = DiagnosticError -sl.set_state("stopped") -assert(sl.get_color() == "Comment") -sl.set_state("starting") -assert(sl.get_color() == "DiagnosticWarn") -sl.set_state("error") -assert(sl.get_color() == "DiagnosticError") --- Ready unpin + update -sl.set_state("ready") -sl.update() -sl.get() -sl.get_color() --- lualine_component -assert(type(sl.lualine_component[1]()) == "string") -sl.lualine_component.color() - --- ============================================================ --- 5. lsp.lua — start, restart paths, backoff --- ============================================================ -print("--- lsp.lua ---") -local lsp_mod = require("basilisk.lsp") -assert(lsp_mod.get_restart_count() >= 0) -lsp_mod.reset_restart_count() --- Start with no binary -lsp_mod.start(config_mod.resolve({ binary_path = "/nonexistent" })) --- Start with real binary (if available) -if lsp_binary then - lsp_mod.start(config_mod.resolve({ binary_path = lsp_binary })) -end --- Restart: non-force (increments count), force (resets) -lsp_mod.restart(config_mod.resolve(), false) -lsp_mod.restart(config_mod.resolve(), true) --- Hit max restarts -lsp_mod.reset_restart_count() -for _ = 1, 4 do - lsp_mod.restart(config_mod.resolve(), false) -end --- Force restart resets -lsp_mod.restart(config_mod.resolve(), true) - --- ============================================================ --- 5b. codelens.lua — both activation paths on one runtime --- ============================================================ --- Implements [NVIM-LSP-CLIENT-CONFIGURATION-API-MAPPINGS] (Code Lens row). --- Which branch `activate` takes is decided by the Neovim it runs on, so the --- version under test would otherwise dictate which half of the contract is ever --- executed. Swapping `vim.lsp.codelens` drives BOTH: the 0.12+ `enable` API and --- the 0.10/0.11 `refresh` fallback with its manual BufEnter/InsertLeave loop. -print("--- codelens.lua ---") -local codelens = require("basilisk.codelens") -local real_codelens = vim.lsp.codelens -local lens_buf = vim.api.nvim_create_buf(false, true) - --- Modern runtime: enable() exists and owns its own refresh scheduling. -vim.lsp.codelens = { - enable = function(_, _) end, - refresh = function(_) end, -} -codelens.activate(lens_buf) - --- Legacy runtime: no enable(), so activate() refreshes now and on each event. -vim.lsp.codelens = { refresh = function(_) end } -codelens.activate(lens_buf) -vim.api.nvim_exec_autocmds("BufEnter", { buffer = lens_buf }) -vim.api.nvim_exec_autocmds("InsertLeave", { buffer = lens_buf }) - -vim.lsp.codelens = real_codelens -vim.api.nvim_buf_delete(lens_buf, { force = true }) - --- ============================================================ --- 6. memory.lua — complete_refs, display, LSP calls --- ============================================================ -print("--- memory.lua ---") -local mem = require("basilisk.memory") --- complete_refs: match, no match, empty, case-insensitive -assert(#mem.complete_refs("") > 0) -assert(mem.complete_refs("Data")[1] == "DataFrame") -assert(#mem.complete_refs("nonexistent_xyz") == 0) -assert(#mem.complete_refs("tensor") > 0) --- display_leak_report: nil, empty, populated -mem.display_leak_report(nil); close_floats() -mem.display_leak_report({ leaks = {} }); close_floats() -mem.display_leak_report({ - leaks = { - { typeName = "DataFrame", count = 15, totalSize = "1.2MB", - location = { file = "/tmp/t.py", line = 42 } }, - { typeName = "dict", count = 100, totalSize = "500KB" }, - }, -}); close_floats() --- display_retention_paths: nil, empty, populated -mem.display_retention_paths("dict", nil); close_floats() -mem.display_retention_paths("DataFrame", { retentionPaths = {} }); close_floats() -mem.display_retention_paths("DataFrame", { - retentionPaths = { { - confidence = 0.85, - steps = { { name = "cache", kind = "variable" }, { name = "__dict__", kind = "attribute" } }, - } }, -}); close_floats() --- start/stop/refs without client (graceful no-op) -mem.start() -mem.stop() -mem.refs("dict") - --- ============================================================ --- 7. profiling.lua — display, heat map, export, LSP calls --- ============================================================ -print("--- profiling.lua ---") -local prof = require("basilisk.profiling") --- display_results: nil, empty, populated -prof.display_results(nil); close_floats() -prof.display_results({ hotFunctions = {} }); close_floats() -prof.display_results({ - hotFunctions = { - { name = "hot", file = "/tmp/test.py", line = 10, percentage = 55 }, - { name = "warm", file = "/tmp/test.py", line = 25, percentage = 25 }, - { name = "cool", file = "/tmp/test.py", line = 40, percentage = 5 }, - }, -}); close_floats() --- apply_heat_map: nil, empty, populated (with nonexistent files) -prof.apply_heat_map(nil) -prof.apply_heat_map({}) -prof.apply_heat_map({ { name = "x", file = "/nonexistent.py", line = 1, percentage = 60 } }) --- start/stop/snapshot without client -prof.start() -prof.start(1234) -prof.stop() -prof.snapshot() --- export_flamegraph: nil, no flamegraphPath, missing file, real file -prof.export_flamegraph(nil) -prof.export_flamegraph({}) -prof.export_flamegraph({ exportError = "no samples were collected" }) -prof.export_flamegraph({ flamegraphPath = "/nonexistent/basilisk.flamegraph.svg" }) -local cov_svg = vim.fn.tempname() .. ".flamegraph.svg" -local cov_fh = assert(io.open(cov_svg, "w")) -cov_fh:write("") -cov_fh:close() -local cov_ui_open = vim.ui.open -vim.ui.open = function() end -prof.export_flamegraph({ flamegraphPath = cov_svg, outputFile = "/tmp/x.speedscope.json" }) -vim.ui.open = cov_ui_open -os.remove(cov_svg) - --- ============================================================ --- 8. testing.lua — parser, tree, panel, run, debug, coverage --- ============================================================ -print("--- testing.lua ---") -local testing = require("basilisk.testing") --- parse_pytest_output: every variation -testing.parse_pytest_output("") -testing.parse_pytest_output("no tests ran\n") -testing.parse_pytest_output("===== 5 items =====\n") -testing.parse_pytest_output("test_a.py::test_one\ntest_a.py::test_two\n") -testing.parse_pytest_output("test_a.py::TestClass::test_method\n") -testing.parse_pytest_output("test_a.py::TestClass::test_m1\ntest_a.py::TestClass::test_m2\n") -testing.parse_pytest_output("test_a.py::test_one\ntest_b.py::test_two\n") --- set_status: hit, miss, nil -testing.set_status("test_a.py::test_one", "passed") -testing.set_status("test_a.py::test_one", "failed") -testing.set_status("nonexistent", "passed") -testing.set_status(nil, "passed") --- parse_test_results -testing.parse_test_results("") -testing.parse_test_results("test_a.py::test_one PASSED\ntest_a.py::test_two FAILED\n") --- update_diagnostics -testing.update_diagnostics() --- refresh_display with no buffer -testing.refresh_display() --- open/close/toggle for every position -for _, pos in ipairs({ "right", "left", "bottom" }) do - testing.open(config_mod.resolve({ test_explorer = { position = pos, width = 30 } })) - testing.refresh_display() - testing.close() -end -testing.toggle(config_mod.resolve()) -testing.toggle(config_mod.resolve()) --- setup_auto_discover: on and off -testing.setup_auto_discover(config_mod.resolve({ test_explorer = { auto_discover_on_save = false } })) -testing.setup_auto_discover(config_mod.resolve({ test_explorer = { auto_discover_on_save = true } })) - --- REAL pytest e2e: create actual test files and run discover + run -local test_tmpdir = vim.fn.tempname() .. "-pytest-e2e" -vim.fn.mkdir(test_tmpdir, "p") -local test_file = test_tmpdir .. "/test_example.py" -local tfh = io.open(test_file, "w") -tfh:write("def test_pass():\n assert 1 + 1 == 2\n\ndef test_fail():\n assert 1 == 2\n") -tfh:close() --- Run pytest synchronously to exercise callbacks in this process. -local pytest_cfg = config_mod.resolve() --- Discover: synchronous fallback via vim.fn.system. -local discover_output = vim.fn.system({ "pytest", "--collect-only", "-q", test_file }) -if vim.v.shell_error == 0 or vim.v.shell_error == 5 then - local tree = testing.parse_pytest_output(discover_output) - -- Manually call the code that on_stdout would call. - testing.refresh_display() -end --- Run: synchronous via vim.fn.system. -local run_output = vim.fn.system({ "pytest", "-v", "--tb=short", test_file }) -testing.parse_test_results(run_output) -testing.refresh_display() -testing.update_diagnostics() --- Also exercise the async path (jobstart) — it will fire callbacks eventually. -testing.discover(pytest_cfg) -vim.wait(3000, function() return false end, 100) -testing.run(pytest_cfg, test_file .. "::test_pass") -vim.wait(3000, function() return false end, 100) --- Debug without dap (graceful error). -pcall(testing.debug, pytest_cfg, test_file .. "::test_pass") - --- apply_coverage: real XML -local cov_xml = test_tmpdir .. "/coverage.xml" -local cxfh = io.open(cov_xml, "w") -cxfh:write([[ - - - -]]) -cxfh:close() -testing.apply_coverage(cov_xml) -testing.apply_coverage("/nonexistent/coverage.xml") -vim.fn.delete(test_tmpdir, "rf") - --- ============================================================ --- 8b. modules.lua — panel lifecycle, render, keybindings --- ============================================================ -print("--- modules.lua ---") -local modules = require("basilisk.modules") --- open/close/toggle lifecycle -modules.open() -wait() -modules.refresh() -wait() -modules.close() -modules.close() -- double close -modules.toggle() -wait() -modules.toggle() --- Re-open to exercise window re-focus path -modules.open() -modules.open() -- re-open focuses existing -modules.close() - --- ============================================================ --- 8c. type_health.lua — panel lifecycle, render --- ============================================================ -print("--- type_health.lua ---") -local type_health = require("basilisk.type_health") --- open/close/toggle lifecycle -type_health.open() -wait() -type_health.refresh() -wait() -type_health.close() -type_health.close() -- double close -type_health.toggle() -wait() -type_health.toggle() --- Re-open to exercise window re-focus path -type_health.open() -type_health.open() -- re-open focuses existing -type_health.close() - --- ============================================================ --- 8d. info.lua — additional paths --- ============================================================ -print("--- info.lua (extra) ---") -local info = require("basilisk.info") --- show with different configs -info.show(config_mod.resolve({ python = "python3.12" })) -wait() -info.refresh(config_mod.resolve({ python = "python3.12" })) -info.close() --- show → show (replaces existing float) -info.show(config_mod.resolve()) -info.show(config_mod.resolve()) -info.close() --- refresh when not open -info.refresh(config_mod.resolve()) --- close when not open -info.close() - --- ============================================================ --- 9. tab_tracking.lua — all modes, real buffer lifecycle --- ============================================================ -print("--- tab_tracking.lua ---") -local tt = require("basilisk.tab_tracking") -tt.setup(config_mod.resolve({ analysis_mode = "wholeModule" })) -tt.setup(config_mod.resolve({ analysis_mode = "crossModule" })) -tt.setup(config_mod.resolve({ analysis_mode = "openFilesOnly" })) --- Real buffer lifecycle in openFilesOnly mode -local tmppy1 = vim.fn.tempname() .. ".py" -local tmppy2 = vim.fn.tempname() .. ".py" -local f1 = io.open(tmppy1, "w"); if f1 then f1:write("x = 1\n"); f1:close() end -local f2 = io.open(tmppy2, "w"); if f2 then f2:write("y = 2\n"); f2:close() end -vim.cmd("edit " .. vim.fn.fnameescape(tmppy1)) -pcall(vim.cmd, "vsplit " .. vim.fn.fnameescape(tmppy2)) -wait() -pcall(vim.cmd, "close") -wait() -pcall(vim.cmd, "enew") -wait() -pcall(vim.cmd, "bdelete! " .. vim.fn.bufnr(tmppy1)) -wait() -os.remove(tmppy1) -os.remove(tmppy2) - --- ============================================================ --- 10. dap.lua — full e2e with nvim-dap --- ============================================================ -print("--- dap.lua ---") -local dap_mod = require("basilisk.dap") --- setup with debugger disabled -dap_mod.setup(config_mod.resolve({ debugger = { enabled = false } })) --- setup with debugger enabled (needs nvim-dap) -dap_mod.setup(config_mod.resolve({ debugger = { enabled = true } })) --- stop_session without active session -dap_mod.stop_session() --- Test parse_dap_message and frame_dap_message via the proxy path --- Create a proxy on a random port (it'll listen but nobody connects) -pcall(dap_mod.create_proxy, "127.0.0.1", 19999, function(proxy_port) - print(" proxy listening on port " .. proxy_port) -end) -wait(500) - --- ============================================================ --- 11. init.lua — full setup() --- ============================================================ -print("--- init.lua ---") -package.loaded["basilisk"] = nil -package.loaded["basilisk.init"] = nil -local init_mod = require("basilisk") -init_mod.setup({}) -init_mod.setup({}) -- guard: second call is no-op - --- ============================================================ --- 12. commands.lua — register + execute every command --- ============================================================ -print("--- commands.lua ---") -local cmds = require("basilisk.commands") -cmds.register(init_mod.config or config_mod.resolve()) --- Execute every command (they gracefully handle no-client or missing data). -local safe_cmds = { - "BasiliskInfo", "BasiliskOrganizeImports", - "BasiliskFixFile", "BasiliskFixWorkspace", - "BasiliskAdoptFile", "BasiliskAdoptWorkspace", "BasiliskUnadoptFile", - "BasiliskShowOutput", - "BasiliskProfile", "BasiliskProfileStop", "BasiliskProfileSnapshot", - "BasiliskMemLeak", "BasiliskMemStop", - "BasiliskTestToggle", - "BasiliskUvSync", "BasiliskUvLock", - "BasiliskRestart", -} -for _, name in ipairs(safe_cmds) do - pcall(vim.cmd, name) - close_floats() -end -pcall(vim.cmd, "BasiliskMemRefs dict") -pcall(vim.cmd, "BasiliskProfile 1234") -pcall(vim.cmd, "BasiliskUvAdd requests") -pcall(vim.cmd, "BasiliskUvAddDev pytest") -pcall(vim.cmd, "BasiliskUvRemove requests") -pcall(vim.cmd, "BasiliskUvCreateEnv 3.12") -pcall(vim.cmd, "BasiliskTestRun") -pcall(vim.cmd, "BasiliskTestDebug test_foo.py::test_bar") -pcall(vim.cmd, "BasiliskExtractVariable") -pcall(vim.cmd, "BasiliskExtractConstant") -pcall(vim.cmd, "BasiliskConvertUnion") -pcall(vim.cmd, "BasiliskImplementMethods") -testing.close() - --- ============================================================ --- 13. health.lua — full check --- ============================================================ -print("--- health.lua ---") +local basilisk = require("basilisk") local health = require("basilisk.health") -health.check() - --- ============================================================ --- 14. REAL LSP e2e — hit every LSP callback path --- ============================================================ -if lsp_binary then - print("--- REAL LSP e2e ---") - -- Stop any existing clients first. - for _, c in ipairs(vim.lsp.get_clients({ name = "basilisk" })) do - c:stop(true) - end - vim.wait(2000, function() - return #vim.lsp.get_clients({ name = "basilisk" }) == 0 - end) +local notice = require("basilisk.notice") - local lsp_tmpdir = vim.fn.tempname() .. "-lsp-cov" - vim.fn.mkdir(lsp_tmpdir, "p") - local ptfh = io.open(lsp_tmpdir .. "/pyproject.toml", "w") - if ptfh then ptfh:write('[project]\nname = "test"\nversion = "0.1.0"\n'); ptfh:close() end +assert(#notice.lines > 0, "the notice must have content") +assert(notice.text == basilisk.notice(), "the plugin must serve the generated notice") - vim.lsp.config("basilisk", { - cmd = { lsp_binary, "lsp" }, - filetypes = { "python" }, - root_markers = { "pyproject.toml" }, - settings = { basilisk = { analysisMode = "wholeModule" } }, - }) - vim.lsp.enable("basilisk") - - -- Create a Python file with errors. - local pyfile = lsp_tmpdir .. "/test_cov.py" - local pyfh = io.open(pyfile, "w") - if pyfh then pyfh:write("def greet(name):\n return name\n"); pyfh:close() end - vim.cmd("edit " .. vim.fn.fnameescape(pyfile)) - local lsp_buf = vim.api.nvim_get_current_buf() - - -- Wait for client attach. - local lsp_client = nil - for _ = 1, 100 do - local clients = vim.lsp.get_clients({ name = "basilisk", bufnr = lsp_buf }) - if #clients > 0 then lsp_client = clients[1]; break end - wait(200) - end - - if lsp_client then - -- Wait for server ready (documentSymbol responds). - for _ = 1, 50 do - local done = false - lsp_client:request("textDocument/documentSymbol", { - textDocument = { uri = vim.uri_from_bufnr(lsp_buf) }, - }, function() done = true end, lsp_buf) - vim.wait(500, function() return done end) - if done then break end - end - - -- Wait for diagnostics. - vim.wait(10000, function() - return #vim.diagnostic.get(lsp_buf) > 0 - end) - - print(" LSP ready — exercising callback paths") - - -- Statusline with real client. - sl.set_state("ready") - sl.update() - local status_text = sl.get() - sl.get_color() - - -- Server-notification handlers on a REAL attached client. `install_handlers` - -- is the public seam that re-installs them after an external - -- `vim.lsp.config` (as this exerciser and any user config do), and nothing - -- else in the suite called it — so `window/logMessage`, - -- `window/showMessage` and `workspace/applyEdit` were never dispatched - -- through the plugin's own handlers. Drive each one the way the server - -- does, including the message levels that pick different log routes and - -- the applyEdit shapes ([CONFIGEDITOR-SOURCES]: `changes` vs - -- `documentChanges`, and a non-config document that must NOT be persisted). - lsp_mod.install_handlers() - local handlers = lsp_client.handlers or {} - local function dispatch(method, params) - local handler = handlers[method] - if handler then - pcall(handler, nil, params, { method = method, client_id = lsp_client.id }) - end - end - for _, level in ipairs({ 1, 2, 3, 4 }) do - dispatch("window/logMessage", { type = level, message = "Basilisk: level " .. level }) - dispatch("window/showMessage", { type = level, message = "Basilisk: shown " .. level }) - end - -- Degenerate payloads: absent, empty and non-string messages are ignored. - dispatch("window/logMessage", nil) - dispatch("window/logMessage", { type = 3, message = "" }) - dispatch("window/showMessage", { type = 3, message = 42 }) - local edited_uri = vim.uri_from_fname(lsp_tmpdir .. "/pyproject.toml") - dispatch("workspace/applyEdit", { - edit = { changes = { [edited_uri] = {} } }, - }) - dispatch("workspace/applyEdit", { - edit = { - documentChanges = { - { textDocument = { uri = edited_uri, version = 1 }, edits = {} }, - { kind = "create", uri = vim.uri_from_fname(lsp_tmpdir .. "/created.py") }, - }, - }, - }) - dispatch("workspace/applyEdit", { edit = { changes = { [vim.uri_from_bufnr(lsp_buf)] = {} } } }) - dispatch("workspace/applyEdit", { edit = "not a table" }) - wait(200) - - -- Execute commands with real LSP client. - pcall(vim.cmd, "BasiliskOrganizeImports") - pcall(vim.cmd, "BasiliskFixFile") - pcall(vim.cmd, "BasiliskAdoptFile") - pcall(vim.cmd, "BasiliskFixWorkspace") - pcall(vim.cmd, "BasiliskAdoptWorkspace") - pcall(vim.cmd, "BasiliskUnadoptFile") - pcall(vim.cmd, "BasiliskUvSync") - pcall(vim.cmd, "BasiliskUvLock") - pcall(vim.cmd, "BasiliskProfile") - pcall(vim.cmd, "BasiliskProfileStop") - pcall(vim.cmd, "BasiliskProfileSnapshot") - pcall(vim.cmd, "BasiliskMemLeak") - pcall(vim.cmd, "BasiliskMemStop") - pcall(vim.cmd, "BasiliskMemRefs dict") - wait(500) - close_floats() - - -- Info float with real data. - pcall(vim.cmd, "BasiliskInfo") - wait(200) - close_floats() - - -- Module explorer with real data. - modules.open() - wait(1000) - modules.refresh() - wait(500) - modules.close() - - -- Type health with real data. - type_health.open() - wait(1000) - type_health.refresh() - wait(500) - type_health.close() - - -- Restart via command. - pcall(vim.cmd, "BasiliskRestart") - wait(3000) +local announced = 0 +basilisk.announce(function() + announced = announced + 1 +end) +basilisk.setup({ anything = true }, function() + announced = announced + 1 +end) +assert(announced == 2, "announce and setup must both emit the notice") - -- Stop clients. - for _, c in ipairs(vim.lsp.get_clients({ name = "basilisk" })) do - c:stop(true) - end - vim.wait(2000, function() - return #vim.lsp.get_clients({ name = "basilisk" }) == 0 - end) - end +health.check({ + start = function() end, + warn = function() end, +}) - vim.fn.delete(lsp_tmpdir, "rf") +local runner_ok, runner = pcall(require, "luacov.runner") +if runner_ok then + runner.save_stats() end - --- ============================================================ --- Done — flush coverage --- ============================================================ -print("\n=== Coverage exerciser complete ===") -local runner = require("luacov.runner") -runner.save_stats() -runner.shutdown() -vim.cmd("qa!") +print("coverage exerciser done") diff --git a/basilisk.nvim/tests/ui/helpers.lua b/basilisk.nvim/tests/ui/helpers.lua deleted file mode 100644 index 9e8aa9bd8..000000000 --- a/basilisk.nvim/tests/ui/helpers.lua +++ /dev/null @@ -1,119 +0,0 @@ ---- Shared UI test helpers for basilisk.nvim. ---- ---- Provides utilities for testing floating windows, extmarks, ---- keymaps, and buffer state in headless Neovim. - -local M = {} - ---- Wait for a condition to be true, with timeout. ----@param condition fun(): boolean ----@param timeout_ms? integer Default 2000. ----@param interval_ms? integer Default 50. ----@return boolean success -function M.wait_for(condition, timeout_ms, interval_ms) - timeout_ms = timeout_ms or 2000 - interval_ms = interval_ms or 50 - local elapsed = 0 - while elapsed < timeout_ms do - if condition() then - return true - end - vim.wait(interval_ms) - elapsed = elapsed + interval_ms - end - return false -end - ---- Assert that a floating window is open. ----@return integer? win_id The floating window ID, or nil. -function M.find_floating_window() - for _, win in ipairs(vim.api.nvim_list_wins()) do - local config = vim.api.nvim_win_get_config(win) - if config.relative and config.relative ~= "" then - return win - end - end - return nil -end - ---- Assert floating window content contains expected lines. ----@param win integer Window ID. ----@param expected string[] Expected substrings in buffer lines. ----@return boolean -function M.float_contains(win, expected) - local buf = vim.api.nvim_win_get_buf(win) - local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) - local text = table.concat(lines, "\n") - for _, exp in ipairs(expected) do - if not text:find(exp, 1, true) then - return false - end - end - return true -end - ---- Get all extmarks in a buffer for a given namespace. ----@param buf integer ----@param ns_name string Namespace name. ----@return table[] marks -function M.get_extmarks(buf, ns_name) - local ns = vim.api.nvim_get_namespaces()[ns_name] - if not ns then - return {} - end - return vim.api.nvim_buf_get_extmarks(buf, ns, 0, -1, { details = true }) -end - ---- Get buffer-local keymaps for a given buffer. ----@param buf integer ----@param mode? string Default "n". ----@return table[] maps -function M.get_buf_keymaps(buf, mode) - mode = mode or "n" - return vim.api.nvim_buf_get_keymap(buf, mode) -end - ---- Check if a specific keymap exists on a buffer. ----@param buf integer ----@param mode string ----@param lhs string ----@return boolean -function M.has_keymap(buf, mode, lhs) - local maps = M.get_buf_keymaps(buf, mode) - for _, map in ipairs(maps) do - if map.lhs == lhs then - return true - end - end - return false -end - ---- Count windows in the current tabpage. ----@return integer -function M.window_count() - return #vim.api.nvim_tabpage_list_wins(0) -end - ---- Get diagnostic count for a buffer in a given namespace. ----@param buf integer ----@param ns_name string ----@return integer -function M.diagnostic_count(buf, ns_name) - local ns = vim.api.nvim_get_namespaces()[ns_name] - if not ns then - return 0 - end - return #vim.diagnostic.get(buf, { namespace = ns }) -end - ---- Create a temporary Python buffer with content. ----@param lines string[] ----@return integer buf -function M.create_python_buf(lines) - local buf = vim.api.nvim_create_buf(true, false) - vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines) - vim.bo[buf].filetype = "python" - return buf -end - -return M diff --git a/basilisk.nvim/tests/ui/run_screenshots.lua b/basilisk.nvim/tests/ui/run_screenshots.lua deleted file mode 100644 index 40a2a57f7..000000000 --- a/basilisk.nvim/tests/ui/run_screenshots.lua +++ /dev/null @@ -1,293 +0,0 @@ ---- Screenshot regression tests using mini.test. ---- ---- Captures terminal state for key UI elements and compares against ---- reference screenshots stored in tests/ui/screenshots/. ---- On first run, reference screenshots are auto-created. ---- ---- Run: nvim --headless -u tests/minimal_init.lua -l tests/ui/run_screenshots.lua - -local ok, MiniTest = pcall(require, "mini.test") -if not ok then - print("SKIP: mini.test not available") - vim.cmd("qa!") - return -end - -local helpers = require("tests.lsp.helpers") -local binary = helpers.find_binary() -if not binary then - print("SKIP: basilisk binary not found") - vim.cmd("qa!") - return -end - -local plugin_dir = vim.fn.fnamemodify(debug.getinfo(1, "S").source:sub(2), ":h:h:h") -local screenshot_dir = plugin_dir .. "/tests/ui/screenshots" - -MiniTest.setup() - -local new_set = MiniTest.new_set -local expect = MiniTest.expect - ---- Compare two screenshot attribute grids with a tolerance threshold. ---- Returns true when the fraction of differing cells is within the threshold. ----@param ref_attr string[] ----@param cur_attr string[] ----@param threshold number Maximum fraction of cells allowed to differ (0.0–1.0). -local function attrs_within_threshold(ref_attr, cur_attr, threshold) - local total, diffs = 0, 0 - for row = 1, math.min(#ref_attr, #cur_attr) do - local ref_row = ref_attr[row] - local cur_row = cur_attr[row] - for col = 1, math.min(#ref_row, #cur_row) do - total = total + 1 - if ref_row:sub(col, col) ~= cur_row:sub(col, col) then - diffs = diffs + 1 - end - end - end - if total == 0 then return true end - return (diffs / total) <= threshold -end - ---- Load a reference screenshot file. Returns { text = {...}, attr = {...} } ---- or nil if the file does not exist. -local function load_reference(path) - local fh = io.open(path, "r") - if not fh then return nil end - local lines = {} - for line in fh:lines() do lines[#lines + 1] = line end - fh:close() - -- Format: text rows, separator "--|---...", attr rows, separator, empty - local sep_idx = nil - for idx, line in ipairs(lines) do - if line:match("^%-%-|") then sep_idx = idx; break end - end - if not sep_idx then return nil end - local text, attr = {}, {} - for idx = 1, sep_idx - 1 do text[#text + 1] = lines[idx] end - -- After separator, attr rows until next separator or end. - for idx = sep_idx + 1, #lines do - if lines[idx]:match("^%-%-|") or lines[idx] == "" then break end - attr[#attr + 1] = lines[idx] - end - return { text = text, attr = attr } -end - ---- Threshold-aware screenshot assertion. Falls back to threshold check when ---- exact match fails and the attribute diff is within the given threshold. ----@param screenshot table child.get_screenshot() result ----@param threshold number Maximum fraction of attr cells allowed to differ (0.0–1.0). ----@param opts? table { directory, ignore_text } -local function assert_screenshot(screenshot, threshold, opts) - opts = opts or {} - local dir = opts.directory or screenshot_dir - - -- Delegate to mini.test for reference creation and text-level checks. - -- If it passes, great. If it fails, check whether it's within threshold. - local ok_exact, err = pcall(expect.reference_screenshot, screenshot, nil, { - directory = dir, - ignore_text = opts.ignore_text, - }) - if ok_exact then return end - - -- Exact match failed — check if attr diff is within threshold. - -- Determine the reference path that mini.test would have used. - -- mini.test names references after the test case path. - -- We need to find the most recently written reference file. - local ref_files = vim.fn.glob(dir .. "/*", false, true) - if #ref_files == 0 then error(err) end - - -- Try each reference file (there should be one per test, named by case). - -- Pick the one whose text layer matches (ignoring text if requested). - for _, ref_path in ipairs(ref_files) do - local ref = load_reference(ref_path) - if ref then - -- Extract attr lines from the current screenshot string repr. - local cur_lines = {} - local cur_str = tostring(screenshot) - for line in cur_str:gmatch("[^\n]+") do cur_lines[#cur_lines + 1] = line end - local cur_sep = nil - for idx, line in ipairs(cur_lines) do - if line:match("^%-%-|") then cur_sep = idx; break end - end - if cur_sep then - local cur_attr = {} - for idx = cur_sep + 1, #cur_lines do - if cur_lines[idx]:match("^%-%-|") or cur_lines[idx] == "" then break end - cur_attr[#cur_attr + 1] = cur_lines[idx] - end - if attrs_within_threshold(ref.attr, cur_attr, threshold) then return end - end - end - end - - -- Still outside threshold — propagate the original error. - error(err) -end - ---- Create a child Neovim with basilisk configured. -local function make_child() - local child = MiniTest.new_child_neovim() - child.start() - - child.lua("vim.opt.rtp:prepend(...)", { plugin_dir }) - child.lua("vim.opt.rtp:prepend(...)", { "/tmp/plenary.nvim" }) - child.lua("vim.opt.rtp:prepend(...)", { "/tmp/mini.nvim" }) - - child.lua([[ - vim.o.swapfile = false - vim.o.number = true - vim.o.signcolumn = "yes" - vim.o.lines = 24 - vim.o.columns = 80 - vim.o.laststatus = 2 - vim.o.cmdheight = 1 - -- Stable statusline that won't contain random temp paths. - vim.o.statusline = " %t %m%= %l,%c %P " - vim.cmd("filetype plugin indent on") - vim.cmd("syntax enable") - ]]) - - return child -end - -local function setup_project(child) - local tmpdir = child.lua_get("vim.fn.tempname()") - child.lua("vim.fn.mkdir(..., 'p')", { tmpdir }) - child.lua([[ - local dir = select(1, ...) - local fh = io.open(dir .. "/pyproject.toml", "w") - fh:write('[project]\nname = "test"\nversion = "0.1.0"\n') - fh:close() - ]], { tmpdir }) - return tmpdir -end - -local function start_lsp(child) - child.lua([[ - local bin = select(1, ...) - vim.lsp.config("basilisk", { - cmd = { bin, "lsp" }, - filetypes = { "python" }, - root_markers = { "pyproject.toml" }, - settings = { basilisk = { analysisMode = "wholeModule" } }, - }) - vim.lsp.enable("basilisk") - ]], { binary }) -end - -local function open_and_wait(child, tmpdir, filename, content) - local filepath = tmpdir .. "/" .. filename - child.lua([[ - local path, text = select(1, ...), select(2, ...) - local fh = io.open(path, "w"); fh:write(text); fh:close() - vim.cmd("edit " .. vim.fn.fnameescape(path)) - ]], { filepath, content }) - child.lua([[ - vim.wait(8000, function() - return #vim.lsp.get_clients({ bufnr = 0 }) > 0 - end, 100) - vim.wait(3000, function() return false end, 100) - ]]) -end - -local function register_commands(child) - child.lua([[ - local bin = select(1, ...) - local basilisk = require("basilisk") - basilisk.config = require("basilisk.config").resolve({ binary_path = bin }) - require("basilisk.commands").register(basilisk.config) - ]], { binary }) -end - --- ── Tests ──────────────────────────────────────────────────────────────────── - -local T = new_set() - -T["diagnostics_untyped"] = function() - local child = make_child() - local tmpdir = setup_project(child) - start_lsp(child) - open_and_wait(child, tmpdir, "bad.py", "def greet(name):\n return name\n\ndef add(a, b):\n return a + b\n\nx = greet('world')\n") - -- Threshold: highlight group IDs shift across environments (local vs CI). - assert_screenshot(child.get_screenshot(), 0.10, { directory = screenshot_dir }) - child.stop() -end - -T["diagnostics_clean"] = function() - local child = make_child() - local tmpdir = setup_project(child) - start_lsp(child) - open_and_wait(child, tmpdir, "good.py", "def greet(name: str) -> str:\n return 'Hello ' + name\n\ndef add(a: int, b: int) -> int:\n return a + b\n\nx: str = greet('world')\n") - -- Threshold: highlight group IDs shift across environments (local vs CI). - assert_screenshot(child.get_screenshot(), 0.10, { directory = screenshot_dir }) - child.stop() -end - -T["basilisk_info_float"] = function() - local child = make_child() - local tmpdir = setup_project(child) - start_lsp(child) - open_and_wait(child, tmpdir, "info.py", "x: int = 1\n") - register_commands(child) - child.lua("vim.cmd('BasiliskInfo'); vim.wait(500)") - -- ignore_text because the float contains random temp dir paths in Root field. - assert_screenshot(child.get_screenshot(), 0.40, { directory = screenshot_dir, ignore_text = true }) - child.stop() -end - -T["test_explorer_panel"] = function() - local child = make_child() - local tmpdir = setup_project(child) - start_lsp(child) - open_and_wait(child, tmpdir, "panel.py", "x: int = 1\n") - register_commands(child) - child.lua("vim.cmd('BasiliskTestToggle'); vim.wait(500)") - -- Threshold: highlight group IDs shift across environments (local vs CI). - assert_screenshot(child.get_screenshot(), 0.10, { directory = screenshot_dir }) - child.stop() -end - -T["diagnostic_float"] = function() - local child = make_child() - local tmpdir = setup_project(child) - start_lsp(child) - open_and_wait(child, tmpdir, "diag_float.py", "def greet(name):\n return name\n") - child.lua([[ - vim.api.nvim_win_set_cursor(0, { 1, 4 }) - vim.diagnostic.open_float() - vim.wait(500) - ]]) - -- Threshold: highlight group IDs shift across environments (local vs CI). - assert_screenshot(child.get_screenshot(), 0.10, { directory = screenshot_dir }) - child.stop() -end - -T["statusline_ready"] = function() - local child = make_child() - local tmpdir = setup_project(child) - start_lsp(child) - child.lua([[ - local sl = require("basilisk.statusline") - sl.set_state("ready") - vim.o.statusline = "%{%v:lua.require('basilisk.statusline').get()%} %f" - ]]) - open_and_wait(child, tmpdir, "status.py", "x: int = 1\n") - child.cmd("redraw!") - -- ignore_text + threshold because the statusline contains temp dir paths - -- that differ across environments, shifting the attribute grid. - assert_screenshot(child.get_screenshot(), 0.15, { directory = screenshot_dir, ignore_text = true }) - child.stop() -end - --- ── Execute ────────────────────────────────────────────────────────────────── - --- Guard against re-entry (run_file sources this file). -if _G._basilisk_screenshot_running then return T end -_G._basilisk_screenshot_running = true - -local script_path = debug.getinfo(1, "S").source:sub(2) -MiniTest.run_file(script_path, { - execute = { reporter = MiniTest.gen_reporter.stdout({}) }, -}) diff --git a/basilisk.nvim/tests/ui/screenshot_spec.lua b/basilisk.nvim/tests/ui/screenshot_spec.lua deleted file mode 100644 index 44083f2f9..000000000 --- a/basilisk.nvim/tests/ui/screenshot_spec.lua +++ /dev/null @@ -1,272 +0,0 @@ ---- Screenshot regression tests using mini.test. ---- ---- Captures terminal state for key UI elements and compares against ---- reference screenshots stored in tests/ui/screenshots/. ---- On first run, reference screenshots are auto-created. ---- ---- Run: nvim --headless -u tests/minimal_init.lua -l tests/ui/screenshot_spec.lua - -local ok, MiniTest = pcall(require, "mini.test") -if not ok then - -- mini.test not available — skip gracefully. - print("mini.test not available — skipping screenshot tests") - return -end - -local helpers = require("tests.lsp.helpers") -local binary = helpers.find_binary() -if not binary then - print("basilisk binary not found — skipping screenshot tests") - return -end - -local plugin_dir = vim.fn.fnamemodify(debug.getinfo(1, "S").source:sub(2), ":h:h:h") -local screenshot_dir = plugin_dir .. "/tests/ui/screenshots" - -local new_set = MiniTest.new_set -local expect = MiniTest.expect - ---- Create a child Neovim with basilisk configured. -local function make_child() - local child = MiniTest.new_child_neovim() - child.setup() - - -- Set up runtime path. - child.lua("vim.opt.rtp:prepend(...)", { plugin_dir }) - child.lua("vim.opt.rtp:prepend('/tmp/plenary.nvim')") - - -- Minimal settings for consistent screenshots. - child.lua([[ - vim.o.swapfile = false - vim.o.number = true - vim.o.signcolumn = "yes" - vim.o.lines = 24 - vim.o.columns = 80 - vim.o.laststatus = 2 - vim.o.cmdheight = 1 - vim.cmd("filetype plugin indent on") - vim.cmd("syntax enable") - ]]) - - return child -end - ---- Create a temp dir with pyproject.toml for LSP root detection. -local function setup_project(child) - local tmpdir = child.lua_get("vim.fn.tempname()") - child.lua("vim.fn.mkdir(..., 'p')", { tmpdir }) - child.lua( - [[local fh = io.open(...[1] .. "/pyproject.toml", "w"); fh:write('[project]\nname = "test"\nversion = "0.1.0"\n'); fh:close()]], - { { tmpdir } } - ) - return tmpdir -end - ---- Start the basilisk LSP in the child. -local function start_lsp(child, tmpdir) - child.lua( - [[ - vim.lsp.config("basilisk", { - cmd = { ...[1], "lsp" }, - filetypes = { "python" }, - root_markers = { "pyproject.toml" }, - settings = { basilisk = { analysisMode = "wholeModule" } }, - }) - vim.lsp.enable("basilisk") - ]], - { { binary } } - ) -end - ---- Open a Python file in the child and wait for LSP. -local function open_and_wait(child, tmpdir, filename, content) - local filepath = tmpdir .. "/" .. filename - child.lua( - [[ - local fh = io.open(...[1], "w"); fh:write(...[2]); fh:close() - vim.cmd("edit " .. vim.fn.fnameescape(...[1])) - ]], - { { filepath, content } } - ) - -- Wait for LSP to attach and produce diagnostics. - child.lua([[ - vim.wait(8000, function() - local clients = vim.lsp.get_clients({ bufnr = 0 }) - return #clients > 0 - end, 100) - vim.wait(3000, function() return false end, 100) - ]]) -end - --- ── Test suite ─────────────────────────────────────────────────────────────── - -local T = new_set({ - hooks = { - pre_case = function() end, - post_case = function() end, - }, -}) - --- 1. Diagnostics on untyped code - -T["diagnostics_untyped"] = function() - local child = make_child() - local tmpdir = setup_project(child) - start_lsp(child, tmpdir) - - open_and_wait(child, tmpdir, "bad.py", table.concat({ - "def greet(name):", - " return name", - "", - "def add(a, b):", - " return a + b", - "", - "x = greet('world')", - "", - }, "\n")) - - expect.reference_screenshot(child.get_screenshot(), nil, { - directory = screenshot_dir, - }) - - child.stop() -end - --- 2. Clean code (no diagnostics) - -T["diagnostics_clean"] = function() - local child = make_child() - local tmpdir = setup_project(child) - start_lsp(child, tmpdir) - - open_and_wait(child, tmpdir, "good.py", table.concat({ - "def greet(name: str) -> str:", - ' return "Hello " + name', - "", - "def add(a: int, b: int) -> int:", - " return a + b", - "", - 'x: str = greet("world")', - "", - }, "\n")) - - expect.reference_screenshot(child.get_screenshot(), nil, { - directory = screenshot_dir, - }) - - child.stop() -end - --- 3. :BasiliskInfo floating window - -T["basilisk_info_float"] = function() - local child = make_child() - local tmpdir = setup_project(child) - start_lsp(child, tmpdir) - - open_and_wait(child, tmpdir, "info.py", "x: int = 1\n") - - -- Register commands and open info float. - child.lua( - [[ - local basilisk = require("basilisk") - basilisk.config = require("basilisk.config").resolve({ binary_path = ...[1] }) - require("basilisk.commands").register(basilisk.config) - vim.cmd("BasiliskInfo") - vim.wait(500) - ]], - { { binary } } - ) - - expect.reference_screenshot(child.get_screenshot(), nil, { - directory = screenshot_dir, - }) - - child.stop() -end - --- 4. Test explorer panel - -T["test_explorer_panel"] = function() - local child = make_child() - local tmpdir = setup_project(child) - start_lsp(child, tmpdir) - - open_and_wait(child, tmpdir, "panel.py", "x: int = 1\n") - - -- Register commands and open test panel. - child.lua( - [[ - local basilisk = require("basilisk") - basilisk.config = require("basilisk.config").resolve({ binary_path = ...[1] }) - require("basilisk.commands").register(basilisk.config) - vim.cmd("BasiliskTestToggle") - vim.wait(500) - ]], - { { binary } } - ) - - expect.reference_screenshot(child.get_screenshot(), nil, { - directory = screenshot_dir, - }) - - child.stop() -end - --- 5. Diagnostic float - -T["diagnostic_float"] = function() - local child = make_child() - local tmpdir = setup_project(child) - start_lsp(child, tmpdir) - - open_and_wait(child, tmpdir, "diag_float.py", table.concat({ - "def greet(name):", - " return name", - "", - }, "\n")) - - -- Move cursor to error line and open diagnostic float. - child.lua([[ - vim.api.nvim_win_set_cursor(0, { 1, 4 }) - vim.diagnostic.open_float() - vim.wait(500) - ]]) - - expect.reference_screenshot(child.get_screenshot(), nil, { - directory = screenshot_dir, - }) - - child.stop() -end - --- 6. Status line states - -T["statusline_ready"] = function() - local child = make_child() - local tmpdir = setup_project(child) - start_lsp(child, tmpdir) - - -- Configure statusline. - child.lua([[ - local sl = require("basilisk.statusline") - sl.set_state("ready") - vim.o.statusline = "%{%v:lua.require('basilisk.statusline').get()%} %f" - ]]) - - open_and_wait(child, tmpdir, "status.py", "x: int = 1\n") - - -- Force a redraw. - child.cmd("redraw!") - - expect.reference_screenshot(child.get_screenshot(), nil, { - directory = screenshot_dir, - }) - - child.stop() -end - --- ── Run ────────────────────────────────────────────────────────────────────── - -MiniTest.run({ collect = { find_files = function() return {} end } }) -return T diff --git a/basilisk.nvim/tests/ui/screenshots/tests-ui-run_screenshots.lua---basilisk_info_float b/basilisk.nvim/tests/ui/screenshots/tests-ui-run_screenshots.lua---basilisk_info_float deleted file mode 100644 index 3f6fe7d07..000000000 --- a/basilisk.nvim/tests/ui/screenshots/tests-ui-run_screenshots.lua---basilisk_info_float +++ /dev/null @@ -1,51 +0,0 @@ ---|---------|---------|---------|---------|---------|---------|---------|---------| -01|W 1 x: int = 1 -02|~ -03|~ -04|~ -05|╭─────────────────────────────── Basilisk Info ────────────────────────────────╮ -06|│Basilisk LSP Server Info │ -07|│ │ -08|│ Status: active │ -09|│ Client ID: 1 │ -10|│ Root: /tmp/nvim-basilisk/session/0 │ -11|│ristianfindlay/f5dexg/0 │ -12|│ │ -13|│ Binary: /opt/basilisk/target/release/basilisk │ -14|│ │ -15|│ Version: basilisk 0.1.0 │ -16|│ Python: auto-detect │ -17|│ Mode: wholeModule │ -18|│ Restarts: 0 │ -19|│ │ -20|│ Ruff: enabled │ -21|│ Debugger: enabled │ -22|╰──────────────────────────────────────────────────────────────────────────────╯ -23| info.py 1,1 All -24| 1,1 Top - ---|---------|---------|---------|---------|---------|---------|---------|---------| -01|00111123344433353333333333333333333333333333333333333333333333333333333333333333 -02|11111111111111111111111111111111111111111111111111111111111111111111111111111111 -03|11111111111111111111111111111111111111111111111111111111111111111111111111111111 -04|11111111111111111111111111111111111111111111111111111111111111111111111111111111 -05|66666666666666666666666666666666777777777777777666666666666666666666666666666666 -06|66666666666666666666666666666666666666666666666666666666666666666666666666666666 -07|66666666666666666666666666666666666666666666666666666666666666666666666666666666 -08|66666666666666666666666666666666666666666666666666666666666666666666666666666666 -09|66666666666666666666666666666666666666666666666666666666666666666666666666666666 -10|66666666666666666666666666666666666666666666666666666666666666666666666666666666 -11|66666666666666666666666666666666666666666666666666666666666666666666666666666666 -12|66666666666666666666666666666666666666666666666666666666666666666666666666666666 -13|66666666666666666666666666666666666666666666666666666666666666666666666666666666 -14|66666666666666666666666666666666666666666666666666666666666666666666666666666666 -15|66666666666666666666666666666666666666666666666666666666666666666666666666666666 -16|66666666666666666666666666666666666666666666666666666666666666666666666666666666 -17|66666666666666666666666666666666666666666666666666666666666666666666666666666666 -18|66666666666666666666666666666666666666666666666666666666666666666666666666666666 -19|66666666666666666666666666666666666666666666666666666666666666666666666666666666 -20|66666666666666666666666666666666666666666666666666666666666666666666666666666666 -21|66666666666666666666666666666666666666666666666666666666666666666666666666666666 -22|66666666666666666666666666666666666666666666666666666666666666666666666666666666 -23|88888888888888888888888888888888888888888888888888888888888888888888888888888888 -24|99999999999999999999999999999999999999999999999999999999999999999999999999999999 diff --git a/basilisk.nvim/tests/ui/screenshots/tests-ui-run_screenshots.lua---diagnostic_float b/basilisk.nvim/tests/ui/screenshots/tests-ui-run_screenshots.lua---diagnostic_float deleted file mode 100644 index 5ee92b9ed..000000000 --- a/basilisk.nvim/tests/ui/screenshots/tests-ui-run_screenshots.lua---diagnostic_float +++ /dev/null @@ -1,51 +0,0 @@ ---|---------|---------|---------|---------|---------|---------|---------|---------| -01|E 1 def greet(name): -02| 2 return name -03|~ -04|~ -05|~ -06|~ -07|~ -08|~ -09|~ -10|~ -11|~ -12|~ -13|~ -14|~ -15|~ -16|~ -17|~ -18|~ -19|~ -20|~ -21|~ -22|~ -23| diag_float.py 1,5 All -24| - ---|---------|---------|---------|---------|---------|---------|---------|---------| -01|00111122234444456666533333333333333333333333333333333333333333333333333333333333 -02|11111133332222223333333333333333333333333333333333333333333333333333333333333333 -03|11111111111111111111111111111111111111111111111111111111111111111111111111111111 -04|11111111111111111111111111111111111111111111111111111111111111111111111111111111 -05|11111111111111111111111111111111111111111111111111111111111111111111111111111111 -06|11111111111111111111111111111111111111111111111111111111111111111111111111111111 -07|11111111111111111111111111111111111111111111111111111111111111111111111111111111 -08|11111111111111111111111111111111111111111111111111111111111111111111111111111111 -09|11111111111111111111111111111111111111111111111111111111111111111111111111111111 -10|11111111111111111111111111111111111111111111111111111111111111111111111111111111 -11|11111111111111111111111111111111111111111111111111111111111111111111111111111111 -12|11111111111111111111111111111111111111111111111111111111111111111111111111111111 -13|11111111111111111111111111111111111111111111111111111111111111111111111111111111 -14|11111111111111111111111111111111111111111111111111111111111111111111111111111111 -15|11111111111111111111111111111111111111111111111111111111111111111111111111111111 -16|11111111111111111111111111111111111111111111111111111111111111111111111111111111 -17|11111111111111111111111111111111111111111111111111111111111111111111111111111111 -18|11111111111111111111111111111111111111111111111111111111111111111111111111111111 -19|11111111111111111111111111111111111111111111111111111111111111111111111111111111 -20|11111111111111111111111111111111111111111111111111111111111111111111111111111111 -21|11111111111111111111111111111111111111111111111111111111111111111111111111111111 -22|11111111111111111111111111111111111111111111111111111111111111111111111111111111 -23|77777777777777777777777777777777777777777777777777777777777777777777777777777777 -24|88888888888888888888888888888888888888888888888888888888888888888888888888888888 diff --git a/basilisk.nvim/tests/ui/screenshots/tests-ui-run_screenshots.lua---diagnostics_clean b/basilisk.nvim/tests/ui/screenshots/tests-ui-run_screenshots.lua---diagnostics_clean deleted file mode 100644 index b866b8d74..000000000 --- a/basilisk.nvim/tests/ui/screenshots/tests-ui-run_screenshots.lua---diagnostics_clean +++ /dev/null @@ -1,51 +0,0 @@ ---|---------|---------|---------|---------|---------|---------|---------|---------| -01| 1 def greet(name: str) -> str: -02| 2 return 'Hello ' + name -03| 3 -04| 4 def add(a: int, b: int) -> int: -05| 5 return a + b -06| 6 -07| 7 x: str = greet('world') -08|~ -09|~ -10|~ -11|~ -12|~ -13|~ -14|~ -15|~ -16|~ -17|~ -18|~ -19|~ -20|~ -21|~ -22|~ -23| good.py 1,1 All -24| - ---|---------|---------|---------|---------|---------|---------|---------|---------| -01|00000011123333324444225552222255522222222222222222222222222222222222222222222222 -02|00000022221111112666666662222222222222222222222222222222222222222222222222222222 -03|00000022222222222222222222222222222222222222222222222222222222222222222222222222 -04|00000011123332422555224225552222255522222222222222222222222222222222222222222222 -05|00000022221111112222222222222222222222222222222222222222222222222222222222222222 -06|00000022222222222222222222222222222222222222222222222222222222222222222222222222 -07|00000042233322222222266666662222222222222222222222222222222222222222222222222222 -08|00000000000000000000000000000000000000000000000000000000000000000000000000000000 -09|00000000000000000000000000000000000000000000000000000000000000000000000000000000 -10|00000000000000000000000000000000000000000000000000000000000000000000000000000000 -11|00000000000000000000000000000000000000000000000000000000000000000000000000000000 -12|00000000000000000000000000000000000000000000000000000000000000000000000000000000 -13|00000000000000000000000000000000000000000000000000000000000000000000000000000000 -14|00000000000000000000000000000000000000000000000000000000000000000000000000000000 -15|00000000000000000000000000000000000000000000000000000000000000000000000000000000 -16|00000000000000000000000000000000000000000000000000000000000000000000000000000000 -17|00000000000000000000000000000000000000000000000000000000000000000000000000000000 -18|00000000000000000000000000000000000000000000000000000000000000000000000000000000 -19|00000000000000000000000000000000000000000000000000000000000000000000000000000000 -20|00000000000000000000000000000000000000000000000000000000000000000000000000000000 -21|00000000000000000000000000000000000000000000000000000000000000000000000000000000 -22|00000000000000000000000000000000000000000000000000000000000000000000000000000000 -23|77777777777777777777777777777777777777777777777777777777777777777777777777777777 -24|88888888888888888888888888888888888888888888888888888888888888888888888888888888 diff --git a/basilisk.nvim/tests/ui/screenshots/tests-ui-run_screenshots.lua---diagnostics_untyped b/basilisk.nvim/tests/ui/screenshots/tests-ui-run_screenshots.lua---diagnostics_untyped deleted file mode 100644 index e56049292..000000000 --- a/basilisk.nvim/tests/ui/screenshots/tests-ui-run_screenshots.lua---diagnostics_untyped +++ /dev/null @@ -1,51 +0,0 @@ ---|---------|---------|---------|---------|---------|---------|---------|---------| -01|E 1 def greet(name): -02| 2 return name -03| 3 -04|E 4 def add(a, b): -05| 5 return a + b -06| 6 -07| 7 x = greet('world') -08|~ -09|~ -10|~ -11|~ -12|~ -13|~ -14|~ -15|~ -16|~ -17|~ -18|~ -19|~ -20|~ -21|~ -22|~ -23| bad.py 1,1 All -24| - ---|---------|---------|---------|---------|---------|---------|---------|---------| -01|00111122234444456666533333333333333333333333333333333333333333333333333333333333 -02|11111133332222223333333333333333333333333333333333333333333333333333333333333333 -03|11111133333333333333333333333333333333333333333333333333333333333333333333333333 -04|00111122234445655653333333333333333333333333333333333333333333333333333333333333 -05|11111133332222223333333333333333333333333333333333333333333333333333333333333333 -06|11111133333333333333333333333333333333333333333333333333333333333333333333333333 -07|11111163333333337777777333333333333333333333333333333333333333333333333333333333 -08|11111111111111111111111111111111111111111111111111111111111111111111111111111111 -09|11111111111111111111111111111111111111111111111111111111111111111111111111111111 -10|11111111111111111111111111111111111111111111111111111111111111111111111111111111 -11|11111111111111111111111111111111111111111111111111111111111111111111111111111111 -12|11111111111111111111111111111111111111111111111111111111111111111111111111111111 -13|11111111111111111111111111111111111111111111111111111111111111111111111111111111 -14|11111111111111111111111111111111111111111111111111111111111111111111111111111111 -15|11111111111111111111111111111111111111111111111111111111111111111111111111111111 -16|11111111111111111111111111111111111111111111111111111111111111111111111111111111 -17|11111111111111111111111111111111111111111111111111111111111111111111111111111111 -18|11111111111111111111111111111111111111111111111111111111111111111111111111111111 -19|11111111111111111111111111111111111111111111111111111111111111111111111111111111 -20|11111111111111111111111111111111111111111111111111111111111111111111111111111111 -21|11111111111111111111111111111111111111111111111111111111111111111111111111111111 -22|11111111111111111111111111111111111111111111111111111111111111111111111111111111 -23|88888888888888888888888888888888888888888888888888888888888888888888888888888888 -24|99999999999999999999999999999999999999999999999999999999999999999999999999999999 diff --git a/basilisk.nvim/tests/ui/screenshots/tests-ui-run_screenshots.lua---statusline_ready b/basilisk.nvim/tests/ui/screenshots/tests-ui-run_screenshots.lua---statusline_ready deleted file mode 100644 index 43f40ccb3..000000000 --- a/basilisk.nvim/tests/ui/screenshots/tests-ui-run_screenshots.lua---statusline_ready +++ /dev/null @@ -1,51 +0,0 @@ ---|---------|---------|---------|---------|---------|---------|---------|---------| -01|W 1 x: int = 1 -02|~ -03|~ -04|~ -05|~ -06|~ -07|~ -08|~ -09|~ -10|~ -11|~ -12|~ -13|~ -14|~ -15|~ -16|~ -17|~ -18|~ -19|~ -20|~ -21|~ -22|~ -23|/tmp/nvim-basilisk/session/status.py 1,1 All -24| - ---|---------|---------|---------|---------|---------|---------|---------|---------| -01|00111123344433353333333333333333333333333333333333333333333333333333333333333333 -02|11111111111111111111111111111111111111111111111111111111111111111111111111111111 -03|11111111111111111111111111111111111111111111111111111111111111111111111111111111 -04|11111111111111111111111111111111111111111111111111111111111111111111111111111111 -05|11111111111111111111111111111111111111111111111111111111111111111111111111111111 -06|11111111111111111111111111111111111111111111111111111111111111111111111111111111 -07|11111111111111111111111111111111111111111111111111111111111111111111111111111111 -08|11111111111111111111111111111111111111111111111111111111111111111111111111111111 -09|11111111111111111111111111111111111111111111111111111111111111111111111111111111 -10|11111111111111111111111111111111111111111111111111111111111111111111111111111111 -11|11111111111111111111111111111111111111111111111111111111111111111111111111111111 -12|11111111111111111111111111111111111111111111111111111111111111111111111111111111 -13|11111111111111111111111111111111111111111111111111111111111111111111111111111111 -14|11111111111111111111111111111111111111111111111111111111111111111111111111111111 -15|11111111111111111111111111111111111111111111111111111111111111111111111111111111 -16|11111111111111111111111111111111111111111111111111111111111111111111111111111111 -17|11111111111111111111111111111111111111111111111111111111111111111111111111111111 -18|11111111111111111111111111111111111111111111111111111111111111111111111111111111 -19|11111111111111111111111111111111111111111111111111111111111111111111111111111111 -20|11111111111111111111111111111111111111111111111111111111111111111111111111111111 -21|11111111111111111111111111111111111111111111111111111111111111111111111111111111 -22|11111111111111111111111111111111111111111111111111111111111111111111111111111111 -23|66666666666666666666666666666666666666666666666666666666666666666666666666666666 -24|77777777777777777777777777777777777777777777777777777777777777777777777777777777 diff --git a/basilisk.nvim/tests/ui/screenshots/tests-ui-run_screenshots.lua---test_explorer_panel b/basilisk.nvim/tests/ui/screenshots/tests-ui-run_screenshots.lua---test_explorer_panel deleted file mode 100644 index d81020e91..000000000 --- a/basilisk.nvim/tests/ui/screenshots/tests-ui-run_screenshots.lua---test_explorer_panel +++ /dev/null @@ -1,51 +0,0 @@ ---|---------|---------|---------|---------|---------|---------|---------|---------| -01|W 1 x: int = 1 │ No tests discovered. -02|~ │ Run :BasiliskTestDiscover -03|~ │~ -04|~ │~ -05|~ │~ -06|~ │~ -07|~ │~ -08|~ │~ -09|~ │~ -10|~ │~ -11|~ │~ -12|~ │~ -13|~ │~ -14|~ │~ -15|~ │~ -16|~ │~ -17|~ │~ -18|~ │~ -19|~ │~ -20|~ │~ -21|~ │~ -22|~ │~ -23| panel.py 1,1 All [Scratch] [-] 1,1 All -24| - ---|---------|---------|---------|---------|---------|---------|---------|---------| -01|00111123344433353333333333333333333333363333333333333333333333333333333333333333 -02|11111111111111111111111111111111111111163333333333333333333333333333333333333333 -03|11111111111111111111111111111111111111161111111111111111111111111111111111111111 -04|11111111111111111111111111111111111111161111111111111111111111111111111111111111 -05|11111111111111111111111111111111111111161111111111111111111111111111111111111111 -06|11111111111111111111111111111111111111161111111111111111111111111111111111111111 -07|11111111111111111111111111111111111111161111111111111111111111111111111111111111 -08|11111111111111111111111111111111111111161111111111111111111111111111111111111111 -09|11111111111111111111111111111111111111161111111111111111111111111111111111111111 -10|11111111111111111111111111111111111111161111111111111111111111111111111111111111 -11|11111111111111111111111111111111111111161111111111111111111111111111111111111111 -12|11111111111111111111111111111111111111161111111111111111111111111111111111111111 -13|11111111111111111111111111111111111111161111111111111111111111111111111111111111 -14|11111111111111111111111111111111111111161111111111111111111111111111111111111111 -15|11111111111111111111111111111111111111161111111111111111111111111111111111111111 -16|11111111111111111111111111111111111111161111111111111111111111111111111111111111 -17|11111111111111111111111111111111111111161111111111111111111111111111111111111111 -18|11111111111111111111111111111111111111161111111111111111111111111111111111111111 -19|11111111111111111111111111111111111111161111111111111111111111111111111111111111 -20|11111111111111111111111111111111111111161111111111111111111111111111111111111111 -21|11111111111111111111111111111111111111161111111111111111111111111111111111111111 -22|11111111111111111111111111111111111111161111111111111111111111111111111111111111 -23|77777777777777777777777777777777777777778888888888888888888888888888888888888888 -24|99999999999999999999999999999999999999999999999999999999999999999999999999999999 diff --git a/basilisk.nvim/tests/ui/statusline_spec.lua b/basilisk.nvim/tests/ui/statusline_spec.lua deleted file mode 100644 index 16521a98c..000000000 --- a/basilisk.nvim/tests/ui/statusline_spec.lua +++ /dev/null @@ -1,183 +0,0 @@ ---- UI tests for basilisk.statusline module. ---- ---- Tests [NVIM-STATUS-LINE]. ---- ---- Covers: get/get_color for all states, lualine component, profiler status, ---- state pinning, diagnostic counts. - -describe("basilisk.statusline", function() - local statusline = require("basilisk.statusline") - - after_each(function() - statusline.set_state("stopped") - statusline.set_profiler_status(nil) - end) - - describe("get", function() - it("shows stopped state when no LSP client", function() - statusline.set_state("stopped") - local text = statusline.get() - assert.truthy(text:find("Basilisk")) - end) - - it("returns a string", function() - assert.is_string(statusline.get()) - end) - - it("contains the state icon", function() - statusline.set_state("stopped") - local text = statusline.get() - -- Stopped icon is ⊘ (U+2298). - assert.truthy(text:find("\u{2298}"), "stopped state should have ⊘ icon") - end) - - it("starting state has rotating icon", function() - statusline.set_state("starting") - local text = statusline.get() - assert.truthy(text:find("\u{27f3}"), "starting state should have ⟳ icon") - end) - - it("error state has cross icon", function() - statusline.set_state("error") - local text = statusline.get() - assert.truthy(text:find("\u{2717}"), "error state should have ✗ icon") - end) - end) - - describe("get_color", function() - it("returns Comment for stopped state", function() - statusline.set_state("stopped") - assert.are.equal("Comment", statusline.get_color()) - end) - - it("returns DiagnosticWarn for starting state", function() - statusline.set_state("starting") - assert.are.equal("DiagnosticWarn", statusline.get_color()) - end) - - it("returns DiagnosticError for error state", function() - statusline.set_state("error") - assert.are.equal("DiagnosticError", statusline.get_color()) - end) - - it("returns a highlight group name", function() - local color = statusline.get_color() - assert.is_string(color) - assert.is_true(#color > 0) - end) - end) - - describe("set_state", function() - it("changes to starting", function() - statusline.set_state("starting") - assert.truthy(statusline.get():find("Basilisk")) - assert.are.equal("DiagnosticWarn", statusline.get_color()) - end) - - it("changes to error", function() - statusline.set_state("error") - assert.are.equal("DiagnosticError", statusline.get_color()) - end) - - it("changes to stopped", function() - statusline.set_state("stopped") - assert.are.equal("Comment", statusline.get_color()) - end) - - it("pinned starting state is not overridden by update", function() - statusline.set_state("starting") - statusline.update() - -- Starting is pinned — update should not change it to stopped. - assert.are.equal("DiagnosticWarn", statusline.get_color()) - end) - - it("pinned error state is not overridden by update", function() - statusline.set_state("error") - statusline.update() - assert.are.equal("DiagnosticError", statusline.get_color()) - end) - - it("stopped state unpins and allows update", function() - statusline.set_state("starting") - statusline.set_state("stopped") - -- Should now be unpinned. - statusline.update() - assert.are.equal("Comment", statusline.get_color()) - end) - end) - - describe("lualine_component", function() - it("is a valid table", function() - assert.is_table(statusline.lualine_component) - end) - - it("has a callable function at index 1", function() - assert.is_function(statusline.lualine_component[1]) - end) - - it("function returns a string", function() - local result = statusline.lualine_component[1]() - assert.is_string(result) - assert.truthy(result:find("Basilisk")) - end) - - it("has a color function", function() - assert.is_function(statusline.lualine_component.color) - end) - - it("color function returns a table with fg", function() - local result = statusline.lualine_component.color() - assert.is_table(result) - -- fg may be nil if the highlight group doesn't have a foreground set, - -- but the table should exist. - end) - end) - - describe("profiler status", function() - it("get_profiler returns empty string when not profiling", function() - statusline.set_profiler_status(nil) - assert.are.equal("", statusline.get_profiler()) - end) - - it("get_profiler returns formatted string when profiling", function() - statusline.set_profiler_status({ - pid = 12345, - elapsedSeconds = 10, - totalSamples = 500, - }) - local result = statusline.get_profiler() - assert.truthy(result:find("12345"), "should contain PID") - assert.truthy(result:find("10"), "should contain elapsed seconds") - assert.truthy(result:find("500"), "should contain sample count") - assert.truthy(result:find("Profiling"), "should contain 'Profiling'") - end) - - it("set_profiler_status with nil clears profiler", function() - statusline.set_profiler_status({ pid = 1, elapsedSeconds = 0, totalSamples = 0 }) - assert.is_true(#statusline.get_profiler() > 0) - statusline.set_profiler_status(nil) - assert.are.equal("", statusline.get_profiler()) - end) - - it("handles missing fields gracefully", function() - statusline.set_profiler_status({}) - local result = statusline.get_profiler() - assert.is_string(result) - assert.truthy(result:find("Profiling")) - end) - end) - - describe("update", function() - it("sets stopped when no clients exist", function() - statusline.set_state("stopped") - statusline.update() - assert.are.equal("Comment", statusline.get_color()) - end) - - it("does not error", function() - assert.has_no.errors(function() - statusline.update() - end) - end) - end) -end) diff --git a/basilisk.nvim/tests/ui/testing_spec.lua b/basilisk.nvim/tests/ui/testing_spec.lua deleted file mode 100644 index 67abbc79c..000000000 --- a/basilisk.nvim/tests/ui/testing_spec.lua +++ /dev/null @@ -1,484 +0,0 @@ ---- UI tests for basilisk.testing module. - -describe("basilisk.testing", function() - local testing = require("basilisk.testing") - - -- ── parse_pytest_output ──────────────────────────────────────────── - - describe("parse_pytest_output", function() - it("parses simple test output", function() - local output = "test_example.py::test_add\ntest_example.py::test_subtract\n" - local tree = testing.parse_pytest_output(output) - assert.are.equal(1, #tree) - assert.are.equal("test_example.py", tree[1].name) - assert.are.equal("file", tree[1].kind) - assert.are.equal(2, #tree[1].children) - assert.are.equal("test_add", tree[1].children[1].name) - assert.are.equal("test_subtract", tree[1].children[2].name) - end) - - it("parses class-based tests", function() - local output = "test_math.py::TestMath::test_add\ntest_math.py::TestMath::test_multiply\n" - local tree = testing.parse_pytest_output(output) - assert.are.equal(1, #tree) - assert.are.equal(1, #tree[1].children) - assert.are.equal("TestMath", tree[1].children[1].name) - assert.are.equal("class", tree[1].children[1].kind) - assert.are.equal(2, #tree[1].children[1].children) - end) - - it("handles multiple files", function() - local output = "test_a.py::test_one\ntest_b.py::test_two\n" - local tree = testing.parse_pytest_output(output) - assert.are.equal(2, #tree) - end) - - it("skips empty lines and summary lines", function() - local output = "\n===== 5 items =====\ntest_x.py::test_foo\n" - local tree = testing.parse_pytest_output(output) - assert.are.equal(1, #tree) - assert.are.equal(1, #tree[1].children) - end) - - it("returns empty tree for no tests", function() - local output = "no tests ran\n" - local tree = testing.parse_pytest_output(output) - assert.are.equal(0, #tree) - end) - - it("preserves full test ID", function() - local output = "tests/test_core.py::TestClass::test_method\n" - local tree = testing.parse_pytest_output(output) - local test_node = tree[1].children[1].children[1] - assert.are.equal("tests/test_core.py::TestClass::test_method", test_node.id) - end) - - it("defaults status to unknown", function() - local output = "test_x.py::test_foo\n" - local tree = testing.parse_pytest_output(output) - assert.are.equal("unknown", tree[1].status) - assert.are.equal("unknown", tree[1].children[1].status) - end) - - it("groups methods under the same class", function() - local output = table.concat({ - "test_api.py::TestUsers::test_create", - "test_api.py::TestUsers::test_delete", - "test_api.py::TestUsers::test_update", - }, "\n") .. "\n" - local tree = testing.parse_pytest_output(output) - assert.are.equal(1, #tree) - assert.are.equal(1, #tree[1].children) - local class_node = tree[1].children[1] - assert.are.equal("TestUsers", class_node.name) - assert.are.equal(3, #class_node.children) - assert.are.equal("test_create", class_node.children[1].name) - assert.are.equal("test_delete", class_node.children[2].name) - assert.are.equal("test_update", class_node.children[3].name) - end) - - it("handles multiple classes in one file", function() - local output = table.concat({ - "test_db.py::TestInsert::test_row", - "test_db.py::TestQuery::test_select", - }, "\n") .. "\n" - local tree = testing.parse_pytest_output(output) - assert.are.equal(1, #tree) - assert.are.equal(2, #tree[1].children) - assert.are.equal("TestInsert", tree[1].children[1].name) - assert.are.equal("TestQuery", tree[1].children[2].name) - end) - - it("handles mixed functions and classes in one file", function() - local output = table.concat({ - "test_mixed.py::test_standalone", - "test_mixed.py::TestGroup::test_method", - }, "\n") .. "\n" - local tree = testing.parse_pytest_output(output) - assert.are.equal(1, #tree) - assert.are.equal(2, #tree[1].children) - assert.are.equal("test_standalone", tree[1].children[1].name) - assert.are.equal("function", tree[1].children[1].kind) - assert.are.equal("TestGroup", tree[1].children[2].name) - assert.are.equal("class", tree[1].children[2].kind) - end) - - it("handles subdirectory paths in test IDs", function() - local output = "tests/unit/test_core.py::test_main\n" - local tree = testing.parse_pytest_output(output) - assert.are.equal(1, #tree) - assert.are.equal("test_core.py", tree[1].name) - assert.are.equal("tests/unit/test_core.py", tree[1].file) - end) - - it("file node id is the file path", function() - local output = "test_api.py::test_get\n" - local tree = testing.parse_pytest_output(output) - assert.are.equal("test_api.py", tree[1].id) - end) - - it("class node id includes file and class name", function() - local output = "test_api.py::TestEndpoint::test_get\n" - local tree = testing.parse_pytest_output(output) - assert.are.equal("test_api.py::TestEndpoint", tree[1].children[1].id) - end) - - it("function node children are empty arrays", function() - local output = "test_api.py::test_get\n" - local tree = testing.parse_pytest_output(output) - assert.are.equal(0, #tree[1].children[1].children) - end) - - it("handles many files in large output", function() - local lines = {} - for i = 1, 20 do - lines[i] = string.format("test_file_%d.py::test_case_%d", i, i) - end - local output = table.concat(lines, "\n") .. "\n" - local tree = testing.parse_pytest_output(output) - assert.are.equal(20, #tree) - end) - - it("ignores lines without .py pattern", function() - local output = "not_a_test::test_foo\ntest_real.py::test_bar\n" - local tree = testing.parse_pytest_output(output) - assert.are.equal(1, #tree) - assert.are.equal("test_real.py", tree[1].name) - end) - - it("handles empty string input", function() - local tree = testing.parse_pytest_output("") - assert.are.equal(0, #tree) - end) - end) - - -- ── parse_test_results ───────────────────────────────────────────── - - describe("parse_test_results", function() - it("does not error on empty output", function() - assert.has_no.errors(function() - testing.parse_test_results("") - end) - end) - - it("does not error on pytest verbose output", function() - local output = table.concat({ - "test_math.py::test_add PASSED", - "test_math.py::test_subtract FAILED", - "", - "========= 1 passed, 1 failed =========", - }, "\n") - assert.has_no.errors(function() - testing.parse_test_results(output) - end) - end) - - it("does not error on all-passing output", function() - local output = table.concat({ - "test_a.py::test_one PASSED", - "test_a.py::test_two PASSED", - "test_a.py::test_three PASSED", - }, "\n") - assert.has_no.errors(function() - testing.parse_test_results(output) - end) - end) - - it("does not error on all-failing output", function() - local output = table.concat({ - "test_a.py::test_one FAILED", - "test_a.py::test_two FAILED", - }, "\n") - assert.has_no.errors(function() - testing.parse_test_results(output) - end) - end) - - it("does not error on class method results", function() - local output = "test_api.py::TestEndpoint::test_get PASSED\n" - assert.has_no.errors(function() - testing.parse_test_results(output) - end) - end) - end) - - -- ── set_status ───────────────────────────────────────────────────── - - describe("set_status", function() - it("updates a node status by ID", function() - testing.parse_pytest_output("test_x.py::test_foo\ntest_x.py::test_bar\n") - testing.set_status("test_x.py::test_foo", "passed") - assert.is_true(true) - end) - - it("does not error for nil test_id", function() - assert.has_no.errors(function() - testing.set_status(nil, "passed") - end) - end) - - it("does not error for non-existent test_id", function() - testing.parse_pytest_output("test_x.py::test_real\n") - assert.has_no.errors(function() - testing.set_status("test_x.py::test_nonexistent", "failed") - end) - end) - - it("accepts all valid status values", function() - testing.parse_pytest_output("test_s.py::test_s\n") - for _, status in ipairs({ "unknown", "running", "passed", "failed" }) do - assert.has_no.errors(function() - testing.set_status("test_s.py::test_s", status) - end) - end - end) - end) - - -- ── update_diagnostics ───────────────────────────────────────────── - - describe("update_diagnostics", function() - it("does not error when called with empty tree", function() - testing.parse_pytest_output("") - assert.has_no.errors(function() - testing.update_diagnostics() - end) - end) - - it("does not error after parsing results", function() - testing.parse_pytest_output("test_d.py::test_diag\n") - testing.parse_test_results("test_d.py::test_diag FAILED\n") - assert.has_no.errors(function() - testing.update_diagnostics() - end) - end) - end) - - -- ── apply_coverage ───────────────────────────────────────────────── - - describe("apply_coverage", function() - it("does not error for non-existent file", function() - assert.has_no.errors(function() - testing.apply_coverage("/tmp/nonexistent_coverage.xml") - end) - end) - - it("parses valid coverage XML", function() - local tmpfile = vim.fn.tempname() .. "_coverage.xml" - local fh = io.open(tmpfile, "w") - fh:write(table.concat({ - '', - '', - ' ', - ' ', - ' ', - ' ', - ' ', - ' ', - ' ', - ' ', - '', - }, "\n")) - fh:close() - assert.has_no.errors(function() - testing.apply_coverage(tmpfile) - end) - os.remove(tmpfile) - end) - - it("handles empty coverage XML", function() - local tmpfile = vim.fn.tempname() .. "_empty_cov.xml" - local fh = io.open(tmpfile, "w") - fh:write('\n\n') - fh:close() - assert.has_no.errors(function() - testing.apply_coverage(tmpfile) - end) - os.remove(tmpfile) - end) - - it("handles multiple classes in coverage XML", function() - local tmpfile = vim.fn.tempname() .. "_multi_cov.xml" - local fh = io.open(tmpfile, "w") - fh:write(table.concat({ - '', - '', - ' ', - ' ', - ' ', - ' ', - ' ', - ' ', - ' ', - ' ', - '', - }, "\n")) - fh:close() - assert.has_no.errors(function() - testing.apply_coverage(tmpfile) - end) - os.remove(tmpfile) - end) - end) - - -- ── Panel open/close/toggle ──────────────────────────────────────── - - describe("panel lifecycle", function() - after_each(function() - testing.close() - end) - - it("open creates a buffer with basilisk-tests filetype", function() - local config = require("basilisk.config").resolve() - testing.open(config) - local found = false - for _, buf in ipairs(vim.api.nvim_list_bufs()) do - if vim.bo[buf].filetype == "basilisk-tests" then - found = true - break - end - end - assert.is_true(found, "should create buffer with basilisk-tests filetype") - end) - - it("open increases window count", function() - local config = require("basilisk.config").resolve() - local before = #vim.api.nvim_tabpage_list_wins(0) - testing.open(config) - local after = #vim.api.nvim_tabpage_list_wins(0) - assert.is_true(after > before, "opening panel should add a window") - end) - - it("close restores window count", function() - local config = require("basilisk.config").resolve() - local before = #vim.api.nvim_tabpage_list_wins(0) - testing.open(config) - testing.close() - assert.are.equal(before, #vim.api.nvim_tabpage_list_wins(0)) - end) - - it("double close does not error", function() - local config = require("basilisk.config").resolve() - testing.open(config) - testing.close() - assert.has_no.errors(function() - testing.close() - end) - end) - - it("toggle opens when closed", function() - local config = require("basilisk.config").resolve() - local before = #vim.api.nvim_tabpage_list_wins(0) - testing.toggle(config) - assert.is_true(#vim.api.nvim_tabpage_list_wins(0) > before) - end) - - it("toggle closes when open", function() - local config = require("basilisk.config").resolve() - local before = #vim.api.nvim_tabpage_list_wins(0) - testing.toggle(config) - testing.toggle(config) - assert.are.equal(before, #vim.api.nvim_tabpage_list_wins(0)) - end) - - it("open with left position works", function() - local config = require("basilisk.config").resolve({ test_explorer = { position = "left", width = 25 } }) - assert.has_no.errors(function() - testing.open(config) - end) - assert.is_true(#vim.api.nvim_tabpage_list_wins(0) >= 2) - end) - - it("open with bottom position works", function() - local config = require("basilisk.config").resolve({ test_explorer = { position = "bottom" } }) - assert.has_no.errors(function() - testing.open(config) - end) - assert.is_true(#vim.api.nvim_tabpage_list_wins(0) >= 2) - end) - - it("re-open focuses existing panel instead of creating new one", function() - local config = require("basilisk.config").resolve() - testing.open(config) - local count_after_first = #vim.api.nvim_tabpage_list_wins(0) - testing.open(config) - assert.are.equal(count_after_first, #vim.api.nvim_tabpage_list_wins(0)) - end) - end) - - -- ── refresh_display ──────────────────────────────────────────────── - - describe("refresh_display", function() - it("does not error when no panel is open", function() - testing.close() - assert.has_no.errors(function() - testing.refresh_display() - end) - end) - - it("shows placeholder text when tree is empty", function() - local config = require("basilisk.config").resolve() - testing.parse_pytest_output("") - testing.open(config) - testing.refresh_display() - -- Buffer should have placeholder content. - local found = false - for _, buf in ipairs(vim.api.nvim_list_bufs()) do - if vim.bo[buf].filetype == "basilisk-tests" then - local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) - if #lines > 0 and lines[1]:find("No tests") then - found = true - end - break - end - end - assert.is_true(found, "should show placeholder text when no tests") - testing.close() - end) - end) - - -- ── setup_auto_discover ──────────────────────────────────────────── - - describe("setup_auto_discover", function() - it("creates autogroup when enabled", function() - local config = require("basilisk.config").resolve({ test_explorer = { auto_discover_on_save = true } }) - assert.has_no.errors(function() - testing.setup_auto_discover(config) - end) - end) - - it("does not create autogroup when disabled", function() - local config = require("basilisk.config").resolve({ test_explorer = { auto_discover_on_save = false } }) - assert.has_no.errors(function() - testing.setup_auto_discover(config) - end) - end) - end) -end) - --- ── Memory module tests (keep separate from testing) ───────────────── - -describe("complete_refs (memory module)", function() - local memory = require("basilisk.memory") - - it("returns matching types", function() - local matches = memory.complete_refs("Data") - assert.is_true(#matches > 0) - assert.are.equal("DataFrame", matches[1]) - end) - - it("returns all types for empty input", function() - local matches = memory.complete_refs("") - assert.is_true(#matches > 0) - end) - - it("is case-insensitive", function() - local matches = memory.complete_refs("dict") - local found = false - for _, m in ipairs(matches) do - if m == "dict" then - found = true - end - end - assert.is_true(found) - end) -end) diff --git a/book/README.md b/book/README.md index a5e34f88a..4b06afa08 100644 --- a/book/README.md +++ b/book/README.md @@ -1,5 +1,7 @@ # The Basilisk Book +> **NOT BEING PUBLISHED.** Basilisk is unlisted: its type checker was producing incorrect results, and every distribution channel is being unlisted ([the statement](https://www.basilisk-python.dev/)). A book teaching people to install and rely on that checker is not going out. This folder stays as the record of what was drafted; nothing in it is a current claim about a product, and none of it is being finished. + This folder is the publication workspace for *The Basilisk Book*: a free, cover-to-cover guide to using Basilisk and understanding the Python typing ideas that make its feedback useful. diff --git a/conformance/test_release_attribution.py b/conformance/test_release_attribution.py index 2b3d4849b..cebd68fd5 100644 --- a/conformance/test_release_attribution.py +++ b/conformance/test_release_attribution.py @@ -253,7 +253,10 @@ def test_release_vsix_recipe_fails_when_packager_produces_no_vsix(self) -> None: ) self.assertNotEqual(result.returncode, 0, result.stdout + result.stderr) - self.assertIn("missing VSIX", result.stderr) + # The recipe's own verifier is what refuses ([WITHDRAWAL-SURFACES]): + # scripts/verify-vsix-inert.sh inspects the packaged zip and cannot + # inspect a file the packager never wrote. + self.assertIn("no such VSIX", result.stderr) def test_readmes_describe_typeshed_composite_license(self) -> None: # [STUBRES-TYPESHED-LICENSE] Typeshed is not Apache-only: its root @@ -268,27 +271,27 @@ def test_readmes_describe_typeshed_composite_license(self) -> None: "Apache-2.0, with MIT-licensed parts", (REPO_ROOT / relative).read_text(), ) - for relative in ("README.zh.md", "vscode-extension/README.zh.md"): - with self.subTest(readme=relative): - self.assertIn( - "Apache-2.0,部分内容采用 MIT 许可证", - (REPO_ROOT / relative).read_text(), - ) def test_package_metadata_names_every_license_in_shipped_binaries(self) -> None: # PEP 639 License-Expression covers the containing distribution, so the - # wheel must name the licenses of its embedded Typeshed snapshot and - # statically linked runtime, not just Basilisk's own MIT source license. + # wheel must name every license in the statically linked runtime, not + # just Basilisk's own MIT source license. The expression is far shorter + # than it was: the binary is inert ([WITHDRAWAL-INERT]) and links no + # typeshed snapshot, no embedded formatter, and no download runtime, so + # naming their licenses would claim they ship when they do not. pyproject = tomllib.loads((REPO_ROOT / "pyproject.toml").read_text()) manifest = json.loads((REPO_ROOT / "runtime-license-manifest.json").read_text()) + expressions = manifest["wheel_license_expressions"] self.assertEqual( pyproject["project"]["license"], - manifest["wheel_license_expressions"]["aarch64-apple-darwin"], - ) - self.assertEqual( - set(manifest["targets"]), set(manifest["wheel_license_expressions"]) + expressions["aarch64-apple-darwin"], ) - self.assertEqual(len(set(manifest["wheel_license_expressions"].values())), 2) + self.assertEqual(set(manifest["targets"]), set(expressions)) + # Every target is covered, and every expression names Basilisk's own + # license. An empty or partial expression is the failure to catch here. + for target, expression in expressions.items(): + with self.subTest(target=target): + self.assertIn("MIT", expression) # VS Code's manifest specification requires a packaged root license to # be referenced by filename. `vsce` maps source LICENSE to LICENSE.txt. diff --git a/conformance/test_run_conformance.py b/conformance/test_run_conformance.py index 3c82e08be..a91ab070e 100644 --- a/conformance/test_run_conformance.py +++ b/conformance/test_run_conformance.py @@ -134,8 +134,16 @@ def test_reuse_accepts_only_the_matching_active_run_marker(self) -> None: run_conformance.resolve_suite(opts, destination), expected ) - def test_rust_gate_owns_one_isolated_clone_for_all_three_passes(self) -> None: - """Sync, coverage, and release scoring must share one run-owned clone.""" + def test_rust_gate_scores_nothing_and_owns_its_one_clone(self) -> None: + """The conformance passes are gone; only the fixture sync may run. + + Implements [CHKARCH-CONFORMANCE]. python/typing no longer registers a + Basilisk checker, so the harness grades nothing and both scoring passes + are commented out. This test used to require all three invocations; it + now requires that the two SCORING ones stay absent, so an agent cannot + quietly reinstate a gate that can only ever produce a number nobody may + publish. The remaining call syncs fixtures and scores nothing. + """ script = (ROOT / "scripts" / "test-rust.sh").read_text() invocations = [ line.strip() @@ -145,12 +153,13 @@ def test_rust_gate_owns_one_isolated_clone_for_all_three_passes(self) -> None: and not line.lstrip().startswith("#") ] - self.assertEqual(len(invocations), 3) + self.assertEqual(len(invocations), 1, invocations) + self.assertIn("--sync-tests", invocations[0]) + self.assertNotIn("--gate", invocations[0]) + self.assertNotIn("--bin", invocations[0]) self.assertTrue( all('--suite-dir "$TYPING_SUITE_DIR"' in line for line in invocations) ) - self.assertNotIn("--reuse-clone", invocations[0]) - self.assertTrue(all("--reuse-clone" in line for line in invocations[1:])) self.assertIn("mktemp -d", script) self.assertIn("BASILISK_TEST_RUST_LOCK_FD", script) self.assertIn("BASILISK_CONFORMANCE_RUN_ID", script) @@ -326,25 +335,30 @@ def test_competing_process_fails_before_running_its_command(self) -> None: class GeneratedReferenceTests(unittest.TestCase): - def test_checked_in_conformance_references_match_the_live_report(self) -> None: - result = subprocess.run( - [ - sys.executable, - str(ROOT / "scripts" / "gen_conformance_reference.py"), - "--check", - ], - cwd=ROOT, - capture_output=True, - text=True, - check=False, - ) + """No surface may carry a generated conformance figure, ever again. - self.assertEqual( - result.returncode, - 0, - result.stdout + result.stderr, + This used to assert that the checked-in conformance reference matched the + live report. Both the generator and every page it wrote are deleted + ([WITHDRAWAL-PROHIBITED]), so the assertion inverted: the machinery must + stay gone. A regenerated reference would put a withdrawn number back on a + public page, which is the specific failure this project exists to stop. + """ + + def test_the_conformance_reference_generator_stays_deleted(self) -> None: + self.assertFalse( + (ROOT / "scripts" / "gen_conformance_reference.py").exists(), + "the conformance reference generator must not come back", ) + def test_no_generated_conformance_figure_is_committed(self) -> None: + for relative in ( + "website/src/_data/conformance.js", + "website/src/_data/conformance_report.json", + "website/src/docs/conformance.md", + ): + with self.subTest(path=relative): + self.assertFalse((ROOT / relative).exists(), relative) + if __name__ == "__main__": unittest.main() diff --git a/coverage-thresholds.json b/coverage-thresholds.json index 39e8b16b8..5262cf43d 100644 --- a/coverage-thresholds.json +++ b/coverage-thresholds.json @@ -28,16 +28,16 @@ "threshold": 97 }, "vsix": { - "threshold": 93 + "threshold": 99 }, "nvim": { - "threshold": 44 + "threshold": 47 } }, "conformance": { - "_doc": "DEAD AS OF 2026-08-08: this measurement CANNOT RUN. Basilisk's withdrawal from python/typing removed BasiliskTypeChecker from the suite's conformance/src/type_checker.py, whose TYPE_CHECKERS tuple now registers only mypy, pyright, zuban, pyrefly, pycroscope and ty. `--only-run` is matched by name against that tuple (conformance/src/main.py: `if options.only_run and options.only_run != type_checker.name: continue`), so `--only-run basilisk` is not an argparse error — it matches NOTHING: main.py exits 0 having graded no checker and written no results/basilisk/*.toml, and run_conformance.py's run_harness() then raises \"the real harness wrote no results ... it did not run\". The conformance jobs in .github/workflows/release.yml and the two conformance passes in scripts/test-rust.sh are COMMENTED OUT for this reason — a gate that can only fail must not block releases. Restoring the measurement would require vendoring a scorer or injecting an adapter, which [CHKARCH-CONFORMANCE] declares a BUILD FAILURE. Whether this block is deleted outright or replaced by a disclosed non-official harness is the user's decision, not an agent's. The rest of this note describes the mechanism as it worked while upstream still carried the adapter. Live PEP conformance measurement. POLICY: the number is a REGRESSION DETECTOR, never a target \u2014 see [CHKARCH-CONFORMANCE]. It samples one fixed corpus the checker was historically developed against, so it cannot tell you whether a rule analyses code; only whether today's binary agrees with yesterday's on files it has already seen. NEVER publish, quote, or market this figure. conformance/run_conformance.py clones python/typing@main FRESH on every run, then runs the suite's OWN unmodified src/main.py --only-run basilisk against the compiled binary \u2014 via the adapter the suite USED to carry \u2014 and records the exact graded commit in website/src/_data/conformance_report.json. The result is the REAL harness's OWN verdict, produced by the same code that grades pyright/mypy/pyrefly/ty/zuban/pycroscope: a file passes only when its errors_diff is empty, counting every diagnostic the binary emits (errors AND warnings). There is NO vendored calculator and NO cached-fixtures fallback \u2014 if the real harness cannot be cloned and run, the build FAILS. The binary runs in its default configuration \u2014 the pure PEP set; Basilisk's opt-in house-style rules never run (see [CHKARCH-CONFIGURATION-ONLY]). Configuring a rule off before measuring, hand-editing conformance_status.csv, or editing this block to match a run is forbidden. KNOWN CONTRADICTION: `threshold` below is a pass-percentage floor, and a floor over a corpus the code was fitted to is the incentive that produced the fitted predicates (CONFORMANCE-INTEGRITY-AUDIT \u00a76.3). Deleting a rule that decides from source text rather than resolved symbols is REQUIRED ([CHKARCH-TEXT-MATCHED-LOGIC]) and is expected to LOWER this number \u2014 which this floor turns into a build failure. Removing the floor is the user's decision, not an agent's. Until they decide: make the deletion, report the drop and the failing gate, and stop there. Do NOT restore the code, refit the rule, or lower this value to get green.", + "_doc": "DEAD AS OF 2026-08-08: this measurement CANNOT RUN. Basilisk's withdrawal from python/typing removed BasiliskTypeChecker from the suite's conformance/src/type_checker.py, whose TYPE_CHECKERS tuple now registers only mypy, pyright, zuban, pyrefly, pycroscope and ty. `--only-run` is matched by name against that tuple (conformance/src/main.py: `if options.only_run and options.only_run != type_checker.name: continue`), so `--only-run basilisk` is not an argparse error — it matches NOTHING: main.py exits 0 having graded no checker and written no results/basilisk/*.toml, and run_conformance.py's run_harness() then raises \"the real harness wrote no results ... it did not run\". The conformance jobs in .github/workflows/release.yml and the two conformance passes in scripts/test-rust.sh are COMMENTED OUT for this reason — a gate that can only fail must not block releases. Restoring the measurement would require vendoring a scorer or injecting an adapter, which [CHKARCH-CONFORMANCE] declares a BUILD FAILURE. Whether this block is deleted outright or replaced by a disclosed non-official harness is the user's decision, not an agent's. The rest of this note describes the mechanism as it worked while upstream still carried the adapter. Live PEP conformance measurement. POLICY: the number is a REGRESSION DETECTOR, never a target — see [CHKARCH-CONFORMANCE]. It samples one fixed corpus the checker was historically developed against, so it cannot tell you whether a rule analyses code; only whether today's binary agrees with yesterday's on files it has already seen. NEVER publish, quote, or market this figure. conformance/run_conformance.py clones python/typing@main FRESH on every run, then runs the suite's OWN unmodified src/main.py --only-run basilisk against the compiled binary — via the adapter the suite USED to carry — and records the exact graded commit in website/src/_data/conformance_report.json. The result is the REAL harness's OWN verdict, produced by the same code that grades pyright/mypy/pyrefly/ty/zuban/pycroscope: a file passes only when its errors_diff is empty, counting every diagnostic the binary emits (errors AND warnings). There is NO vendored calculator and NO cached-fixtures fallback — if the real harness cannot be cloned and run, the build FAILS. The binary runs in its default configuration — the pure PEP set; Basilisk's opt-in house-style rules never run (see [CHKARCH-CONFIGURATION-ONLY]). Configuring a rule off before measuring, hand-editing conformance_status.csv, or editing this block to match a run is forbidden. KNOWN CONTRADICTION: `threshold` below is a pass-percentage floor, and a floor over a corpus the code was fitted to is the incentive that produced the fitted predicates (CONFORMANCE-INTEGRITY-AUDIT §6.3). Deleting a rule that decides from source text rather than resolved symbols is REQUIRED ([CHKARCH-TEXT-MATCHED-LOGIC]) and is expected to LOWER this number — which this floor turns into a build failure. Removing the floor is the user's decision, not an agent's. Until they decide: make the deletion, report the drop and the failing gate, and stop there. Do NOT restore the code, refit the rule, or lower this value to get green.", "threshold": 100, - "_fp_ceiling_doc": "Total false-positive diagnostics across the suite (diagnostics Basilisk reports on a line the suite does NOT mark # E, or outside a satisfied # E[tag] group). Measured by conformance/run_conformance.py --gate, which runs the REAL python/typing harness on the compiled binary and delegates the comparison to conformance/assert_wheel_conformance.py (run by scripts/test-rust.sh inside make test). A false positive on real code is a genuine defect worth fixing on its own merits, independent of this suite. Same contradiction as `threshold` above: close a gap by fixing the checker or by deleting logic that never analysed anything \u2014 never by silencing a rule to hold the ceiling.", + "_fp_ceiling_doc": "Total false-positive diagnostics across the suite (diagnostics Basilisk reports on a line the suite does NOT mark # E, or outside a satisfied # E[tag] group). Measured by conformance/run_conformance.py --gate, which runs the REAL python/typing harness on the compiled binary and delegates the comparison to conformance/assert_wheel_conformance.py (run by scripts/test-rust.sh inside make test). A false positive on real code is a genuine defect worth fixing on its own merits, independent of this suite. Same contradiction as `threshold` above: close a gap by fixing the checker or by deleting logic that never analysed anything — never by silencing a rule to hold the ceiling.", "max_false_positives": 0 } } diff --git a/crates/basilisk-checker/README.md b/crates/basilisk-checker/README.md index 27180e92a..cd75f6631 100644 --- a/crates/basilisk-checker/README.md +++ b/crates/basilisk-checker/README.md @@ -1,5 +1,12 @@ # basilisk-checker +> **A record, not a product claim.** Basilisk is unlisted and its type checker is +> inert ([WITHDRAWAL](../../docs/specs/DOCS-WITHDRAWAL-MESSAGING-SPEC.md#WITHDRAWAL)). +> Nothing described below ships in anything a user can install: the `basilisk` +> binary analyses nothing, and the editor extensions carry no checker. This file +> is kept as an account of what was built, and nothing in it authorises +> rebuilding what it describes. + Core type checking rules and diagnostic emission for Basilisk. ## Role in Basilisk @@ -48,7 +55,7 @@ for the canonical tag model. ## Status -The checker and severity engine are shipped. The canonical rule-catalog API, -strict-first adoption transaction, opt-in suppression diagnostics, and visual -configuration editor are tracked in -[`LSP-CONFIGURATION-EDITOR-PLAN.md`](../../docs/plans/LSP-CONFIGURATION-EDITOR-PLAN.md). +This is the code that produced incorrect results. It ships in nothing: the +`basilisk` binary does not link it, and neither editor extension carries it. +It is not being fixed, audited, or extended +([WITHDRAWAL-REBUILD](../../docs/specs/DOCS-WITHDRAWAL-MESSAGING-SPEC.md#WITHDRAWAL-CLAIMS)). diff --git a/crates/basilisk-cli/Cargo.toml b/crates/basilisk-cli/Cargo.toml index ee37495e2..e7a1338e5 100644 --- a/crates/basilisk-cli/Cargo.toml +++ b/crates/basilisk-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "basilisk-cli" -description = "Basilisk CLI — an open-source Python type checker and language server built in Rust: diagnostics, refactoring, formatting, integrated debugging, and profiling. Strictness is configured per rule, so a codebase can adopt type safety incrementally." +description = "Basilisk's type checker produced incorrect results. Basilisk is unlisted and is being rebuilt from the ground up as a new product." version.workspace = true edition.workspace = true license.workspace = true @@ -9,37 +9,17 @@ license.workspace = true name = "basilisk" path = "src/main.rs" +# The `basilisk` binary is inert ([WITHDRAWAL-INERT]): it prints the approved +# notice and exits 4. It depends on no Basilisk crate — not the parser, the +# resolver, the checker, or the LSP — because it runs none of them, and a +# dependency edge to code that produced incorrect results is exactly what must +# not ship. `--version` is the sole surface left, so Shipwright is the sole +# dependency. [dependencies] -basilisk-config.workspace = true -basilisk-parser.workspace = true -basilisk-resolver.workspace = true -basilisk-checker.workspace = true -basilisk-lsp.workspace = true -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" -shipwright-manifest = "0.10.0" -colored.workspace = true -tower-lsp = "0.20" -walkdir.workspace = true -serde.workspace = true -serde_json.workspace = true -tokio = { version = "1", features = ["rt-multi-thread", "macros"] } -tracing.workspace = true -tracing-subscriber.workspace = true +shipwright = "0.10.0" +shipwright-manifest = "0.10.0" [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 23424046a..b43d58b22 100644 --- a/crates/basilisk-cli/README.md +++ b/crates/basilisk-cli/README.md @@ -1,36 +1,11 @@ # basilisk-cli -Command-line interface for Basilisk — the `basilisk` binary. +The `basilisk` binary. It is inert. -## Role in Basilisk +Basilisk's type checker was producing incorrect results, so it no longer runs. Every invocation — bare `basilisk`, every former subcommand, every flag — prints the approved statement to stderr and exits `4`. Stdout stays empty. No file is read or written, and no server starts. `--version` is the only surface that still answers, so package managers and installed editor extensions get a reply instead of hanging. -This is the **user-facing entry point** for command-line usage. It wires the full analysis pipeline together (parser, resolver, checker) and presents diagnostics in rustc-style output. Used directly by developers and in CI pipelines. +Implements [WITHDRAWAL-INERT](../../docs/specs/DOCS-WITHDRAWAL-MESSAGING-SPEC.md#WITHDRAWAL-INERT). The statement itself is generated from that spec into `src/withdrawal_notice.txt` by `scripts/gen_withdrawal_copy.py` and drift-gated in CI, so this crate cannot print its own version of it. -```sh -basilisk check src/ # check a directory -basilisk check app.py # check a single file -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. -- **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 - -| Crate | Purpose | -|-------|---------| -| `basilisk-parser` | Parsing | -| `basilisk-resolver` | Name resolution | -| `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 | - -## Status - -Complete — stable binary published as `basilisk`. +- **Exit code** — `4` (unlisted), always. Never `0`: a pipeline that still calls Basilisk must fail loudly rather than read a clean run into a checker that was wrong. Never `1`: "error diagnostics were found" would be one more incorrect result. See [CHKARCH-CLI-EXITCODES](../../docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-CLI-EXITCODES). +- **Dependencies** — Shipwright only. The parser, resolver, checker and language server are not linked in; the binary cannot analyse anything even by accident. +- **Tests** — `tests/inert_cli.rs` drives the real binary over every argument shape and asserts the exact stderr bytes, the empty stdout, the exit status, and that nothing on disk changed. diff --git a/crates/basilisk-cli/src/adopt.rs b/crates/basilisk-cli/src/adopt.rs deleted file mode 100644 index 50f5dc376..000000000 --- a/crates/basilisk-cli/src/adopt.rs +++ /dev/null @@ -1,627 +0,0 @@ -//! Implements [AUTOFIX-ADOPTION]. See docs/specs/LSP-MASS-AUTOFIX-SPEC.md#AUTOFIX-ADOPTION -//! `basilisk adopt`, `basilisk unadopt`, and `basilisk adopt --status`. -//! -//! Adoption records current error debt as **ordinary warning-severity rule -//! entries** in the config file of the nearest folder governing each affected -//! file — plain `code -> severity` entries in the one configuration model -//! ([CHKARCH-CONFIG-MODEL]). There are no exact-file overrides, ownership -//! markers, or sidecar state: the adoption state IS the set of -//! warning-severity `[tool.basilisk.rules]` entries, `unadopt` deletes them, -//! and re-running `adopt` recomputes them so rules that no longer fire revert -//! without manual bookkeeping ([AUTOFIX-ADOPTION-FLOW]). - -use std::collections::{BTreeMap, BTreeSet}; -use std::path::{Path, PathBuf}; - -use basilisk_config::{RuleConfigUpdate, RuleSeverity}; -use tracing::{error, info}; - -use crate::pipeline::{ - collect_and_check, find_project_root, first_path_dir, parent_dir_of, pluralise, - DiagnosticScope, PipelineError, -}; - -/// Run the adopt subcommand. -/// -/// Exit codes ([CHKARCH-CLI-EXITCODES]): -/// - `0` — adoption recorded successfully -/// - `2` — invalid configuration -/// - `3` — internal error -pub(crate) fn run_adopt(paths: &[String]) -> u8 { - match adopt_folders(paths) { - Ok(summary) => { - println!( - "Adopted {} folder config{} with {} demoted rule code{}.", - summary.folders_updated, - pluralise(summary.folders_updated), - summary.demoted_count, - pluralise(summary.demoted_count), - ); - 0 - } - Err(err) => report_failure(&err, "adopt failed"), - } -} - -/// Run the unadopt subcommand. -/// -/// Exit codes: `0` on success, `2` on invalid configuration, `3` on -/// internal error. -pub(crate) fn run_unadopt(paths: &[String]) -> u8 { - match unadopt_folders(paths) { - Ok(removed) => { - println!( - "Un-adopted {} rule entr{}.", - removed, - if removed == 1 { "y" } else { "ies" }, - ); - 0 - } - Err(err) => report_failure(&err, "unadopt failed"), - } -} - -/// Run the adopt --status subcommand. -/// -/// Reports, per governing folder config, the warning-severity rule entries -/// that constitute the adoption state ([AUTOFIX-ADOPTION]). -/// -/// Exit codes: `0` on success, `3` on internal error. -pub(crate) fn run_adopt_status(paths: &[String]) -> u8 { - let roots = match governing_roots(paths) { - Ok(roots) => roots, - Err(err) => return report_failure(&err, "adopt --status failed"), - }; - let mut adopted_any = false; - for root in roots { - let entries = match adopted_entries(&root) { - Ok(entries) => entries, - Err(err) => return report_failure(&err, "adopt --status failed"), - }; - if entries.is_empty() { - continue; - } - adopted_any = true; - println!( - "{} ({} demoted code{}):", - root.display(), - entries.len(), - pluralise(entries.len()), - ); - for code in entries { - println!(" {code}"); - } - } - if !adopted_any { - println!("No folders are currently adopted."); - } - 0 -} - -/// Log a pipeline failure and map it to its exit code. -fn report_failure(err: &PipelineError, context: &'static str) -> u8 { - match err { - PipelineError::Config(message) => { - error!(%message, "{context}: configuration error"); - 2 - } - PipelineError::NoSource(message) => { - error!(%message, "{context}"); - 3 - } - PipelineError::Internal(message) => { - error!(%message, "{context}"); - 3 - } - } -} - -/// Summary of an adopt run. -struct AdoptSummary { - /// Number of folder configs that were rewritten. - folders_updated: usize, - /// Total number of rule codes demoted across all folders. - demoted_count: usize, -} - -/// Current debt for one governing folder config. -#[derive(Default)] -struct FolderDebt { - /// Codes firing at `error`/`safety-violation` — the debt to demote. - error_codes: BTreeSet, - /// Codes firing at any severity — existing adoption entries for codes - /// absent here have graduated and are removed on recompute. - firing_codes: BTreeSet, -} - -/// Adopt: check both command scopes at their resolved severities -/// ([CHKARCH-COMMANDS]) and rewrite each governing folder config's adoption -/// entries to exactly the current debt ([AUTOFIX-ADOPTION-FLOW]). -fn adopt_folders(paths: &[String]) -> Result { - let debt_by_root = collect_folder_debt(paths)?; - let mut folders_updated: usize = 0; - let mut demoted_count: usize = 0; - - for (root, debt) in debt_by_root { - let existing = adopted_entries(&root)?; - let mut rules: BTreeMap> = debt - .error_codes - .iter() - .map(|code| (code.clone(), Some(RuleSeverity::Warning))) - .collect(); - // Recompute: an adoption entry whose rule no longer fires anywhere in - // the scanned scope has graduated — delete it ([AUTOFIX-ADOPTION-FLOW]). - for code in existing { - if !debt.firing_codes.contains(&code) { - let _ = rules.entry(code).or_insert(None); - } - } - if rules.is_empty() { - continue; - } - write_rule_entries(&root, rules.clone())?; - folders_updated += 1; - demoted_count += debt.error_codes.len(); - info!( - root = %root.display(), - demoted = debt.error_codes.len(), - "adopted folder config" - ); - } - - Ok(AdoptSummary { - folders_updated, - demoted_count, - }) -} - -/// Unadopt: delete every warning-severity rule entry — the adoption state — -/// from each governing folder config ([AUTOFIX-ADOPTION]). -fn unadopt_folders(paths: &[String]) -> Result { - let mut removed: usize = 0; - for root in governing_roots(paths)? { - let entries = adopted_entries(&root)?; - if entries.is_empty() { - continue; - } - removed += entries.len(); - let rules: BTreeMap> = - entries.into_iter().map(|code| (code, None)).collect(); - write_rule_entries(&root, rules)?; - info!(root = %root.display(), "un-adopted folder config"); - } - Ok(removed) -} - -/// Run the shared pipeline over both scopes and group the result per -/// governing folder config. Every scanned file registers its root even when -/// clean, so recompute can graduate stale entries. -fn collect_folder_debt(paths: &[String]) -> Result, PipelineError> { - // Adoption rewrites the very configuration a cache entry is fingerprinted - // against, so it always runs cold — the project's `cache` key does not - // apply here ([CHKCACHE-CONFIG]). - let no_cache = crate::cache_check::CacheOptions { - enabled: crate::cache_check::CacheOverride::ForceOff, - dir: None, - stats: false, - }; - let mut stats = crate::cache_check::CacheStats::default(); - let outcome = collect_and_check(paths, &no_cache, &mut stats, DiagnosticScope::Union)?; - for failure in &outcome.failures { - tracing::warn!(path = %failure.path, error = %failure.message, "error checking file"); - } - - let mut debt: BTreeMap = BTreeMap::new(); - for source in &outcome.sources { - let _ = debt.entry(governing_root(&source.path)).or_default(); - } - for diagnostic in &outcome.diagnostics { - let entry = debt.entry(governing_root(&diagnostic.path)).or_default(); - let code = diagnostic.code.code.to_owned(); - if matches!( - diagnostic.severity, - basilisk_checker::Severity::Error | basilisk_checker::Severity::SafetyViolation - ) { - let _ = entry.error_codes.insert(code.clone()); - } - let _ = entry.firing_codes.insert(code); - } - Ok(debt) -} - -/// The unique governing folder configs for the Python files under `paths`. -fn governing_roots(paths: &[String]) -> Result, PipelineError> { - let config_root = first_path_dir(paths); - let config = basilisk_config::load_basilisk_config(&config_root); - let excluded = crate::pipeline::excluded_dirs_and_log(&config, &config_root); - let python_files = - crate::pipeline::collect_python_files(paths, &excluded).map_err(PipelineError::Internal)?; - Ok(python_files - .iter() - .map(|file| governing_root(file)) - .collect()) -} - -/// The folder whose config file governs `file`: the nearest ancestor holding -/// a `[tool.basilisk]` table, else the project root (whose `pyproject.toml` -/// becomes the creation target). [CHKARCH-CONFIG-DISCOVERY] -fn governing_root(file: &str) -> PathBuf { - let parent = parent_dir_of(file); - basilisk_config::discover_config_dir(&parent).unwrap_or_else(|| find_project_root(&parent)) -} - -/// The adoption state of one folder config: its warning-severity -/// `[tool.basilisk.rules]` entries ([AUTOFIX-ADOPTION]). -fn adopted_entries(root: &Path) -> Result, PipelineError> { - let document = discover_document(root)?; - Ok(document - .config - .nearest_tables() - .map(|tables| { - tables - .rules - .iter() - .filter(|(_, severity)| **severity == RuleSeverity::Warning) - .map(|(code, _)| code.clone()) - .collect() - }) - .unwrap_or_default()) -} - -/// Apply plain rule-entry updates to the folder config at `root` through the -/// shared configuration mutation service ([AUTOFIX-ADOPTION-FLOW]). -fn write_rule_entries( - root: &Path, - rules: BTreeMap>, -) -> Result<(), PipelineError> { - let document = discover_document(root)?; - let update = RuleConfigUpdate { - rules, - rule_tags: BTreeMap::new(), - }; - let patch = basilisk_config::build_rule_patch(&document, &update) - .map_err(|err| PipelineError::Config(err.to_string()))?; - basilisk_config::apply_config_patch(&patch) - .map_err(|err| PipelineError::Internal(err.to_string())) -} - -fn discover_document(root: &Path) -> Result { - basilisk_config::discover_config_document(root) - .map_err(|err| PipelineError::Config(err.to_string())) -} - -#[cfg(test)] -#[expect( - clippy::unwrap_used, - reason = "test-only code: unwrap acceptable in unit tests" -)] -mod tests { - use super::*; - use std::fs; - - /// Python code with a missing parameter annotation (triggers BSK-0001) - /// and a missing return type annotation (triggers BSK-0002). - // `x` has no default to infer from (BSK-0001) and `return x` is not - // inferable (BSK-0002) — a `pass` body would infer `-> None` and only - // fire BSK-0001 ([TYPEINF-FUNC-RETURN]). - const BAD_PYTHON: &str = "def foo(x):\n return x\n"; - - /// Fully typed Python code that should produce zero errors. - const CLEAN_PYTHON: &str = "def greet(name: str) -> str:\n return name\n"; - - /// Python code with a check-scope (pep) error: wrong return type. - const PEP_ERROR_PYTHON: &str = "def bad() -> int:\n return \"x\"\n"; - - /// Create a fresh temporary project directory (removing any leftover from - /// a prior run) that ships a `pyproject.toml` opting into the annotation - /// house rules. `adopt` records the diagnostics a project has enabled, - /// and those analyze-scope rules are off by default — so the test project - /// turns them on exactly as a real adopter would ([CHKARCH-COMMANDS]). - fn temp_dir(name: &str) -> PathBuf { - // Per-process dir name (same pattern as `stage_project` in - // cli_binary_tests): a stray watcher or leftover harness process from - // a previous run must never touch this run's fixture files. - let dir = - std::env::temp_dir().join(format!("bsk_adopt_test_{name}.{}", std::process::id())); - let _ = fs::remove_dir_all(&dir); - fs::create_dir_all(&dir).unwrap(); - fs::write( - dir.join("pyproject.toml"), - "[tool.basilisk.rules]\n\"BSK-0001\" = \"error\"\n\"BSK-0002\" = \"error\"\n", - ) - .unwrap(); - dir - } - - /// Write a `.py` file inside `dir` and return its absolute path as a `String`. - fn write_py(dir: &Path, filename: &str, content: &str) -> String { - let path = dir.join(filename); - fs::write(&path, content).unwrap(); - path.to_string_lossy().into_owned() - } - - /// The warning-severity rule entries in `dir`'s config — the adoption - /// state ([AUTOFIX-ADOPTION]). - fn adoption(dir: &Path) -> BTreeSet { - adopted_entries(dir).unwrap() - } - - /// The full `[tool.basilisk.rules]` table in `dir`'s config. - fn rule_entries(dir: &Path) -> BTreeMap { - let document = basilisk_config::discover_config_document(dir).unwrap(); - document - .config - .nearest_tables() - .map(|tables| tables.rules.clone().into_iter().collect()) - .unwrap_or_default() - } - - // ── run_adopt ([AUTOFIX-ADOPTION]) ─────────────────────────────────── - - /// [AUTOFIX-ADOPTION]: adopting a folder with analyze-scope error debt - /// demotes the firing codes to plain warning entries in the governing - /// folder config — no exact-file overrides, no markers. - #[test] - fn run_adopt_bad_code_demotes_codes_in_folder_config() { - let dir = temp_dir("adopt_bad"); - let path = write_py(&dir, "bad.py", BAD_PYTHON); - - let exit = run_adopt(&[path]); - assert_eq!(exit, 0, "adopt should succeed with exit code 0"); - - let entries = rule_entries(&dir); - assert_eq!( - entries.get("BSK-0001"), - Some(&RuleSeverity::Warning), - "BSK-0001 must be demoted to a folder-level warning entry, got: {entries:?}" - ); - assert_eq!( - entries.get("BSK-0002"), - Some(&RuleSeverity::Warning), - "BSK-0002 must be demoted to a folder-level warning entry, got: {entries:?}" - ); - } - - /// [AUTOFIX-ADOPTION-FLOW]: pep debt is demoted to `warning` (never below - /// info) as an ordinary folder entry, so `check` reports it as a warning - /// afterwards. - #[test] - fn run_adopt_records_pep_debt_as_warning_entry() { - // Per-process dir name — see `temp_dir` for the rationale. - let dir = - std::env::temp_dir().join(format!("bsk_adopt_test_pep_debt.{}", std::process::id())); - let _ = fs::remove_dir_all(&dir); - fs::create_dir_all(&dir).unwrap(); - fs::write( - dir.join("pyproject.toml"), - "[project]\nname = \"x\"\nversion = \"0.0.0\"\n", - ) - .unwrap(); - let path = write_py(&dir, "bad.py", PEP_ERROR_PYTHON); - - let exit = run_adopt(&[path]); - assert_eq!(exit, 0, "adopt should succeed"); - - let entries = rule_entries(&dir); - let demoted_pep: Vec<_> = entries - .iter() - .filter(|(code, severity)| { - basilisk_checker::is_pep_rule(code) && **severity == RuleSeverity::Warning - }) - .collect(); - assert!( - !demoted_pep.is_empty(), - "the firing pep code must be demoted to warning in the folder config, got: {entries:?}" - ); - } - - #[test] - fn run_adopt_clean_code_produces_no_adoptions() { - let dir = temp_dir("adopt_clean"); - let path = write_py(&dir, "clean.py", CLEAN_PYTHON); - - let exit = run_adopt(&[path]); - assert_eq!(exit, 0); - - assert!( - adoption(&dir).is_empty(), - "clean code should produce no adoption entries" - ); - } - - #[test] - fn run_adopt_nonexistent_path_returns_3() { - let exit = run_adopt(&["/no/such/path/ever.py".to_owned()]); - assert_eq!(exit, 3, "nonexistent path should return exit code 3"); - } - - /// [AUTOFIX-ADOPTION-RULES]: a folder entry is a plain override — two bad - /// files in one folder produce one set of folder entries, not per-file - /// state. - #[test] - fn run_adopt_directory_traversal_writes_one_folder_entry_set() { - let dir = temp_dir("adopt_multi"); - let _ = write_py(&dir, "a.py", BAD_PYTHON); - let _ = write_py(&dir, "b.py", BAD_PYTHON); - - let exit = run_adopt(&[dir.to_string_lossy().into_owned()]); - assert_eq!(exit, 0); - - let adopted = adoption(&dir); - assert_eq!( - adopted, - ["BSK-0001", "BSK-0002"] - .into_iter() - .map(str::to_owned) - .collect::>(), - "both files' debt collapses into the one governing folder config" - ); - } - - /// [AUTOFIX-ADOPTION]: debt in differently-governed folders is demoted in - /// each folder's own config file (the old single-store restriction is - /// gone). - #[test] - fn run_adopt_writes_each_governing_folder_config() { - let first = temp_dir("adopt_cross_root_first"); - let second = temp_dir("adopt_cross_root_second"); - let first_path = write_py(&first, "first.py", BAD_PYTHON); - let second_path = write_py(&second, "second.py", BAD_PYTHON); - - assert_eq!(run_adopt(&[first_path, second_path]), 0); - assert!( - adoption(&first).contains("BSK-0001"), - "first root must hold its own adoption entries" - ); - assert!( - adoption(&second).contains("BSK-0001"), - "second root must hold its own adoption entries" - ); - } - - /// [AUTOFIX-ADOPTION-FLOW]: re-running adopt recomputes — entries for - /// rules that no longer fire anywhere in the folder are deleted. - #[test] - fn run_adopt_rerun_graduates_fixed_rules() { - let dir = temp_dir("adopt_rerun"); - let path = write_py(&dir, "bad.py", BAD_PYTHON); - - assert_eq!(run_adopt(std::slice::from_ref(&path)), 0); - assert!( - !adoption(&dir).is_empty(), - "precondition: adoption entries exist" - ); - - // Fix the debt, re-run adopt: the entries must graduate away. - let _ = write_py(&dir, "bad.py", CLEAN_PYTHON); - assert_eq!(run_adopt(&[path]), 0); - assert!( - adoption(&dir).is_empty(), - "re-running adopt must remove entries whose rules no longer fire, got: {:?}", - adoption(&dir) - ); - } - - // ── run_unadopt ([AUTOFIX-ADOPTION]) ───────────────────────────────── - - /// [AUTOFIX-ADOPTION-FLOW]: unadopt deletes the folder's warning entries, - /// restoring the ancestor severity. - #[test] - fn run_unadopt_removes_adoption_entries() { - let dir = temp_dir("unadopt_remove"); - let path = write_py(&dir, "bad.py", BAD_PYTHON); - - // First adopt. - let exit = run_adopt(std::slice::from_ref(&path)); - assert_eq!(exit, 0); - assert!( - !adoption(&dir).is_empty(), - "precondition: adoption must exist" - ); - - // Then unadopt. - let exit = run_unadopt(&[path]); - assert_eq!(exit, 0); - - assert!( - adoption(&dir).is_empty(), - "active config must have no adoption entries after unadopt" - ); - } - - /// Unadopt leaves non-warning entries (the user's own error opt-ins) - /// untouched — only the adoption state is deleted. [AUTOFIX-ADOPTION] - #[test] - fn run_unadopt_preserves_error_entries() { - let dir = temp_dir("unadopt_preserve"); - let path = write_py(&dir, "bad.py", BAD_PYTHON); - assert_eq!(run_adopt(std::slice::from_ref(&path)), 0); - assert_eq!(run_unadopt(&[path]), 0); - - // BSK-0001/BSK-0002 were rewritten to warning by adopt and removed by - // unadopt; a config with only non-warning entries would keep them. - let entries = rule_entries(&dir); - assert!( - entries - .values() - .all(|severity| *severity != RuleSeverity::Warning), - "no warning entries may remain after unadopt, got: {entries:?}" - ); - } - - #[test] - fn run_unadopt_on_clean_dir_returns_0() { - let dir = temp_dir("unadopt_clean"); - let _ = write_py(&dir, "clean.py", CLEAN_PYTHON); - - let exit = run_unadopt(&[dir.to_string_lossy().into_owned()]); - assert_eq!(exit, 0); - } - - #[test] - fn run_unadopt_nonexistent_path_returns_3() { - let exit = run_unadopt(&["/no/such/path/ever.py".to_owned()]); - assert_eq!(exit, 3); - } - - // ── run_adopt_status ([AUTOFIX-ADOPTION]) ──────────────────────────── - - #[test] - fn run_adopt_status_empty_prints_no_folders() { - let dir = temp_dir("status_empty"); - // Create the directory but no adoptions. - let _ = write_py(&dir, "clean.py", CLEAN_PYTHON); - let exit = run_adopt_status(&[dir.to_string_lossy().into_owned()]); - assert_eq!(exit, 0); - } - - #[test] - fn run_adopt_status_shows_adopted_folders() { - let dir = temp_dir("status_shows"); - let path = write_py(&dir, "bad.py", BAD_PYTHON); - - let exit = run_adopt(&[path]); - assert_eq!(exit, 0); - - let exit = run_adopt_status(&[dir.to_string_lossy().into_owned()]); - assert_eq!(exit, 0); - } - - // ── governing_root ([CHKARCH-CONFIG-DISCOVERY]) ────────────────────── - - #[test] - fn governing_root_file_returns_config_dir() { - let dir = temp_dir("resolve_file"); - let path = write_py(&dir, "foo.py", CLEAN_PYTHON); - - // Discovery preserves the caller's path spelling (no - // canonicalization) — a symlinked temp dir stays as given. - assert_eq!(governing_root(&path), dir); - } - - #[test] - fn governing_root_nested_file_finds_project_config() { - let dir = temp_dir("resolve_nested"); - let src = dir.join("src"); - fs::create_dir_all(&src).unwrap(); - let path = write_py(&src, "nested.py", CLEAN_PYTHON); - assert_eq!(governing_root(&path), dir); - } - - /// A nested folder with its own `[tool.basilisk]` table governs its files - /// — adoption writes there, exactly where `check` discovers. - /// [CHKARCH-CONFIG-DISCOVERY] - #[test] - fn governing_root_prefers_nearest_config_table() { - let dir = temp_dir("resolve_nearest"); - let sub = dir.join("sub"); - fs::create_dir_all(&sub).unwrap(); - fs::write( - sub.join("pyproject.toml"), - "[tool.basilisk.rules]\n\"BSK-0001\" = \"error\"\n", - ) - .unwrap(); - let path = write_py(&sub, "nested.py", CLEAN_PYTHON); - assert_eq!(governing_root(&path), sub); - } -} diff --git a/crates/basilisk-cli/src/cache_check.rs b/crates/basilisk-cli/src/cache_check.rs deleted file mode 100644 index ae4364a9d..000000000 --- a/crates/basilisk-cli/src/cache_check.rs +++ /dev/null @@ -1,421 +0,0 @@ -//! Implements [CHKCACHE-CLI] / [CHKCACHE-FINGERPRINT]. -//! 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 -//! tracks hit/miss counts. - -use std::path::{Path, PathBuf}; - -use basilisk_checker::{CachedDiagnostic, Diagnostic}; -use basilisk_common::fs::{content_hash, ReadRecorder}; -use basilisk_config::BasiliskConfig; -use basilisk_db::cache::{CheckCache, Fingerprint}; -use basilisk_lsp::import_resolver::ImportSearchPaths; - -/// What this invocation's flags say about the persistent result cache -/// ([CHKCACHE-CONFIG]). -/// -/// The project states the standing policy in `[tool.basilisk] cache`; a flag -/// is a per-run override of it. `Project` is the flagless case — the config -/// decides, and with no key written the cache stays off exactly as before. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub enum CacheOverride { - /// No `--cache`/`--no-cache`: `[tool.basilisk] cache` decides. - #[default] - Project, - /// `--cache`: run the cache regardless of configuration. - ForceOn, - /// `--no-cache`: skip the cache regardless of configuration. - ForceOff, -} - -impl CacheOverride { - /// Fold the two mutually reinforcing flags into one decision. - /// - /// `--no-cache` wins when both are passed: an explicit opt-out is the - /// safer reading of a contradictory command line, and it is the flag a - /// user reaches for when they suspect the cache. - #[must_use] - pub const fn from_flags(cache: bool, no_cache: bool) -> Self { - match (cache, no_cache) { - (_, true) => Self::ForceOff, - (true, false) => Self::ForceOn, - (false, false) => Self::Project, - } - } - - /// Resolve against the project configuration ([CHKCACHE-CONFIG]). - fn resolve(self, config: &BasiliskConfig) -> bool { - match self { - Self::ForceOn => true, - Self::ForceOff => false, - Self::Project => config.cache_is_enabled(), - } - } -} - -/// Parsed `--cache*` flags. -#[derive(Debug, Clone)] -pub struct CacheOptions { - /// Per-run override of the configured cache policy. - pub enabled: CacheOverride, - /// Override for the cache directory (`--cache-dir`). - pub dir: Option, - /// Whether to print hit/miss stats (`--cache-stats`). - pub stats: bool, -} - -/// Running hit/miss tally for one `check` invocation. -#[derive(Debug, Default)] -pub struct CacheStats { - /// Number of cache hits. - pub hits: usize, - /// Number of cache misses (full checks). - pub misses: usize, -} - -impl CacheStats { - /// Print the tally to stderr (kept off stdout so JSON output stays clean). - pub fn report(&self) { - eprintln!("cache: {} hit / {} miss", self.hits, self.misses); - } -} - -/// A built cache plus the fingerprint of the non-file inputs for this run. -#[derive(Debug)] -pub struct CacheContext { - cache: CheckCache, - fingerprint: Fingerprint, -} - -/// Build a [`CacheContext`] when the cache is enabled, else `None`. -/// -/// `dir_configs` is the per-directory rule-config map for this run -/// ([CHKARCH-CONFIG-DISCOVERY]) — every directory's config participates in -/// the fingerprint so a child config edit invalidates cached results. -/// `project_config` is the project-root configuration whose `cache`/`cache-dir` -/// keys are this project's standing policy; the flags in `options` override it -/// for this run only ([CHKCACHE-CONFIG]). -#[must_use] -pub fn build_context( - options: &CacheOptions, - project_config: &BasiliskConfig, - dir_configs: &std::collections::BTreeMap>, - search_paths: &ImportSearchPaths, - project_root: &Path, -) -> Option { - let enabled = options.enabled.resolve(project_config); - tracing::debug!( - enabled, - override_source = ?options.enabled, - configured = ?project_config.cache_enabled, - "resolved persistent result-cache policy" - ); - if !enabled { - return None; - } - let dir = options - .dir - .clone() - .unwrap_or_else(|| project_config.cache_directory(project_root)); - let fingerprint = Fingerprint { - version: env!("CARGO_PKG_VERSION").to_owned(), - config_hash: hash_dir_configs(dir_configs), - env_hash: hash_env(search_paths, project_root), - typeshed_id: typeshed_snapshot_identity(search_paths), - }; - Some(CacheContext { - cache: CheckCache::new(dir), - fingerprint, - }) -} - -/// Identity of the active step-3 typeshed snapshot for the fingerprint -/// ([STUBRES-TYPESHED], [CHKCACHE-FINGERPRINT]). -/// -/// The gate-accepted snapshot is the only step-3 identity. Configuration -/// values cannot substitute for bytes the checker actually consumed. -fn typeshed_snapshot_identity(search_paths: &ImportSearchPaths) -> String { - search_paths.typeshed_snapshot.as_ref().map_or_else( - || "unavailable".to_owned(), - basilisk_checker::imports::ActiveTypeshed::identity_fingerprint, - ) -} - -/// Hash the *effective* per-directory configs. Canonicalised through -/// `serde_json::Value` so the hash is stable across runs despite `HashMap` -/// iteration order; the `BTreeMap` fixes the directory order. -fn hash_dir_configs( - dir_configs: &std::collections::BTreeMap>, -) -> u64 { - let parts: Vec = dir_configs - .iter() - .map(|(dir, config)| { - let json = serde_json::to_value(config.as_ref()) - .ok() - .and_then(|value| serde_json::to_string(&value).ok()) - .unwrap_or_default(); - format!("{}={json}", dir.display()) - }) - .collect(); - content_hash(&parts.join("\n")) -} - -/// Hash the resolution environment: search paths plus `uv.lock` contents. -/// -/// This is the v1 boundary: site-packages changes without a `uv.lock` edit are -/// not detected, which is why the cache is opt-in. -// Implements [CHKCACHE-LIMITS] -fn hash_env(search_paths: &ImportSearchPaths, project_root: &Path) -> u64 { - let mut parts = vec![paths_field("roots", &search_paths.roots)]; - parts.push(paths_field("extra", &search_paths.extra_paths)); - parts.push(paths_field("stub", &search_paths.stub_paths)); - parts.push(paths_field("members", &search_paths.workspace_members)); - let site = search_paths - .site_packages - .as_ref() - .map(|p| p.display().to_string()) - .unwrap_or_default(); - parts.push(format!("site={site}")); - parts.push(format!("registry={}", search_paths.registry.is_some())); - if let Ok(lock) = std::fs::read_to_string(project_root.join("uv.lock")) { - parts.push(format!("lock={}", content_hash(&lock))); - } - content_hash(&parts.join("\n")) -} - -/// Render a labelled, order-preserving list of paths for the env fingerprint. -fn paths_field(label: &str, paths: &[PathBuf]) -> String { - let joined = paths - .iter() - .map(|p| p.display().to_string()) - .collect::>() - .join(","); - format!("{label}=[{joined}]") -} - -/// Run a single file's check, served from cache when possible. -/// -/// On a miss, `cold` runs under a [`ReadRecorder`] so the exact read-set is -/// captured and stored. On a hit, the stored diagnostics are replayed and the -/// target source is re-read for rendering. -/// -/// # Errors -/// -/// Propagates `cold`'s error, or an I/O error reading the source on a hit. -pub fn check_file( - context: Option<&CacheContext>, - stats: &mut CacheStats, - path: &str, - cold: F, -) -> Result<(Vec, String), String> -where - F: FnOnce() -> Result<(Vec, String), String>, -{ - let Some(context) = context else { - return cold(); - }; - let target = Path::new(path); - if let Some(hit) = context - .cache - .lookup::>(target, &context.fingerprint) - { - stats.hits += 1; - let source = std::fs::read_to_string(path).map_err(|err| err.to_string())?; - let diagnostics = hit - .into_iter() - .map(CachedDiagnostic::into_diagnostic) - .collect(); - return Ok((diagnostics, source)); - } - stats.misses += 1; - store_fresh(context, target, path, cold) -} - -/// Run `cold` under a recorder and persist the result. -fn store_fresh( - context: &CacheContext, - target: &Path, - path: &str, - cold: F, -) -> Result<(Vec, String), String> -where - F: FnOnce() -> Result<(Vec, String), String>, -{ - let recorder = ReadRecorder::start(); - let result = cold(); - let read_set = recorder.finish(); - let (diagnostics, source) = result?; - let cached: Vec = diagnostics.iter().map(CachedDiagnostic::from).collect(); - match context - .cache - .store(target, &context.fingerprint, read_set, &cached) - { - Ok(()) => tracing::debug!(path, "cache miss: stored fresh result"), - Err(err) => tracing::warn!(path, %err, "failed to write cache entry"), - } - Ok((diagnostics, source)) -} - -#[cfg(test)] -#[expect( - clippy::expect_used, - reason = "test-only fixed Snapshot fixtures must fail loudly" -)] -mod tests { - use std::collections::BTreeMap; - use std::sync::Arc; - - use basilisk_checker::imports::ActiveTypeshed; - use basilisk_stubs::typeshed::archive::{Archive, ArchiveEntry, ArchiveVfs}; - use basilisk_stubs::typeshed::gittree::{FileMode, Oid}; - use basilisk_stubs::typeshed::snapshot::Snapshot; - use basilisk_stubs::typeshed::source::{ - LicenseStatus, SourceIdentity, SourceKind, TypeshedStatus, - }; - - use super::{ - build_context, typeshed_snapshot_identity, BasiliskConfig, CacheContext, CacheOptions, - CacheOverride, - }; - - fn snapshot(identity: SourceIdentity) -> Arc { - let status = TypeshedStatus { - active_source: if matches!(identity, SourceIdentity::Custom { .. }) { - SourceKind::Custom - } else { - SourceKind::ExactCommit - }, - commit: identity.commit(), - tree: identity.commit(), - license_status: if matches!(identity, SourceIdentity::Custom { .. }) { - LicenseStatus::NotSupplied - } else { - LicenseStatus::Approved - }, - license_reference: None, - warnings: Vec::new(), - }; - let archive = Archive::new(vec![ - ArchiveEntry { - path: "stdlib/VERSIONS".to_owned().into(), - mode: FileMode::Regular, - data: b"os: 3.0-\n".to_vec().into(), - }, - ArchiveEntry { - path: "stdlib/os.pyi".to_owned().into(), - mode: FileMode::Regular, - data: b"name: str\n".to_vec().into(), - }, - ]); - let uri = identity.uri_component(); - Arc::new( - Snapshot::build(identity, status, ArchiveVfs::new(uri, archive), None) - .expect("valid cache-identity fixture"), - ) - } - - fn fingerprint(snapshot: Arc) -> String { - let mut paths = crate::import_search::roots_only(Vec::new()); - paths.typeshed_snapshot = Some(ActiveTypeshed::new(snapshot, None)); - typeshed_snapshot_identity(&paths) - } - - fn cache_context(cache_dir: &std::path::Path, snapshot: Arc) -> CacheContext { - let mut paths = crate::import_search::roots_only(Vec::new()); - paths.typeshed_snapshot = Some(ActiveTypeshed::new(snapshot, None)); - build_context( - &CacheOptions { - enabled: CacheOverride::ForceOn, - dir: Some(cache_dir.to_path_buf()), - stats: false, - }, - &BasiliskConfig::default(), - &BTreeMap::new(), - &paths, - cache_dir, - ) - .expect("enabled cache context") - } - - #[test] - fn active_snapshot_identity_distinguishes_commits_custom_and_bundle() { - let commit_a = - Oid::from_hex("1111111111111111111111111111111111111111").expect("valid commit A"); - let commit_b = - Oid::from_hex("2222222222222222222222222222222222222222").expect("valid commit B"); - let a = fingerprint(snapshot(SourceIdentity::Commit { - commit: commit_a, - pinned: true, - })); - let same_a = fingerprint(snapshot(SourceIdentity::Commit { - commit: commit_a, - pinned: false, - })); - let b = fingerprint(snapshot(SourceIdentity::Commit { - commit: commit_b, - pinned: false, - })); - let custom = fingerprint(snapshot(SourceIdentity::Custom { - digest: "custom-tree".to_owned(), - })); - let bundled = fingerprint(snapshot(SourceIdentity::Bundled { commit: commit_a })); - - assert_eq!(a, same_a, "pin policy does not change identical bytes"); - assert_ne!(a, b); - assert_ne!(a, custom); - assert_ne!(a, bundled); - } - - #[test] - fn checker_cache_hits_only_for_the_identical_active_snapshot_identity() { - let directory = tempfile::tempdir().expect("cache directory"); - let target = std::path::Path::new("/workspace/module.py"); - let commit_a = Oid::from_hex("1111111111111111111111111111111111111111").expect("commit A"); - let commit_b = Oid::from_hex("2222222222222222222222222222222222222222").expect("commit B"); - let stored = cache_context( - directory.path(), - snapshot(SourceIdentity::Commit { - commit: commit_a, - pinned: true, - }), - ); - let payload = vec!["cached diagnostics".to_owned()]; - stored - .cache - .store(target, &stored.fingerprint, BTreeMap::new(), &payload) - .expect("store checker result"); - - let identical = cache_context( - directory.path(), - snapshot(SourceIdentity::Commit { - commit: commit_a, - pinned: false, - }), - ); - assert_eq!( - identical - .cache - .lookup::>(target, &identical.fingerprint), - Some(payload) - ); - - for identity in [ - SourceIdentity::Commit { - commit: commit_b, - pinned: false, - }, - SourceIdentity::Custom { - digest: "custom-tree".to_owned(), - }, - SourceIdentity::Bundled { commit: commit_a }, - ] { - let changed = cache_context(directory.path(), snapshot(identity)); - assert!(changed - .cache - .lookup::>(target, &changed.fingerprint) - .is_none()); - } - } -} diff --git a/crates/basilisk-cli/src/fix.rs b/crates/basilisk-cli/src/fix.rs deleted file mode 100644 index ac610f8aa..000000000 --- a/crates/basilisk-cli/src/fix.rs +++ /dev/null @@ -1,547 +0,0 @@ -//! Implements [CHKARCH-CLI]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-CLI -//! `basilisk fix` subcommand — apply autofixes to Python files. -//! -//! For each Python file: parse → resolve → check → generate fixes → apply. -//! Writes the fixed source back to disk. - -use basilisk_lsp::code_actions::mass_fix::{ALL_FIXABLE_RULES, SAFE_FIXABLE_RULES}; -use tower_lsp::lsp_types::{TextEdit, Url}; -use tracing::{info, warn}; - -use crate::pipeline::pluralise; - -/// Run the fix subcommand. -/// -/// Exit codes: -/// - `0` — fixes applied successfully -/// - `1` — some files had errors that couldn't be fixed -/// - `3` — internal error -pub(crate) fn run_fix(paths: &[String], include_unsafe: bool, rules: &[String]) -> u8 { - let allowed_rules = resolve_rules(include_unsafe, rules); - let allowed_refs: Vec<&str> = allowed_rules.iter().map(String::as_str).collect(); - match collect_and_fix(paths, &allowed_refs) { - Ok(summary) => { - println!( - "Fixed {} diagnostic{} in {} file{}.", - summary.fixed_count, - pluralise(summary.fixed_count), - summary.files_fixed, - pluralise(summary.files_fixed), - ); - u8::from(summary.had_unfixable_errors) - } - Err(err) => { - tracing::error!(%err, "internal error"); - 3 - } - } -} - -/// Summary of a fix run. -struct FixSummary { - /// Total number of diagnostics that were fixed. - fixed_count: usize, - /// Number of files that had at least one fix applied. - files_fixed: usize, - /// Whether any file had errors that could not be auto-fixed. - had_unfixable_errors: bool, -} - -/// Resolve which rules to apply based on CLI flags. -/// -/// - Empty `rules` + `include_unsafe` false → safe rules only. -/// - Empty `rules` + `include_unsafe` true → all fixable rules. -/// - Single entry `"all"` (case-insensitive) → all fixable rules. -/// - Otherwise → the provided rules verbatim. -fn resolve_rules(include_unsafe: bool, rules: &[String]) -> Vec { - if rules.is_empty() { - if include_unsafe { - ALL_FIXABLE_RULES.iter().map(|s| (*s).to_owned()).collect() - } else { - SAFE_FIXABLE_RULES.iter().map(|s| (*s).to_owned()).collect() - } - } else if rules.len() == 1 && rules.first().is_some_and(|r| r.eq_ignore_ascii_case("all")) { - ALL_FIXABLE_RULES.iter().map(|s| (*s).to_owned()).collect() - } else { - rules.to_vec() - } -} - -/// Collect Python files, analyse them, apply fixes, and write back. -fn collect_and_fix(paths: &[String], allowed_rules: &[&str]) -> Result { - // [CHKARCH-CONFIG-DISCOVERY] Rule config resolves per file, exactly like - // `basilisk check` (GitHub #311). - let config_root = crate::pipeline::first_path_dir(paths); - let config = basilisk_config::load_basilisk_config(&config_root); - - let excluded = crate::pipeline::excluded_dirs_and_log(&config, &config_root); - - // [CHKARCH-CONFIG-INCLUDE] (GitHub #333): a no-args run walks only the - // configured include roots, exactly like `check`/`analyze`. `fix` mutates - // files, so defaulting to the whole working directory would rewrite - // vendored sources (`venv/`) the user never asked it to touch. - let paths = &crate::pipeline::effective_check_paths(paths, &config, &config_root); - let python_files = crate::pipeline::collect_python_files(paths, &excluded)?; - let dir_configs = crate::pipeline::resolve_dir_configs(&python_files, &config); - - let mut fixed_count: usize = 0; - let mut files_fixed: usize = 0; - let mut had_unfixable_errors = false; - - for path in python_files { - let file_config = crate::pipeline::config_for_path(&dir_configs, &path, &config); - match fix_single_file(&path, allowed_rules, &file_config) { - Ok(count) => { - fixed_count += count; - if count > 0 { - files_fixed += 1; - } - } - Err(err) => { - warn!(path, %err, "error processing file"); - had_unfixable_errors = true; - } - } - } - - Ok(FixSummary { - fixed_count, - files_fixed, - had_unfixable_errors, - }) -} - -/// Analyse a single file and apply fixes matching the allowed rules. -/// -/// Returns the number of fixes applied. -fn fix_single_file( - path: &str, - allowed_rules: &[&str], - config: &basilisk_config::BasiliskConfig, -) -> Result { - let source = std::fs::read_to_string(path).map_err(|e| format!("{path}: {e}"))?; - let uri = Url::from_file_path( - std::path::Path::new(path) - .canonicalize() - .map_err(|e| format!("{path}: {e}"))?, - ) - .map_err(|()| format!("{path}: cannot convert to file URI"))?; - - let parsed = basilisk_parser::parse_source(source.clone(), path.to_owned()) - .map_err(|e| e.to_string())?; - let resolved = basilisk_resolver::resolve(&parsed).map_err(|e| e.to_string())?; - let checker_diags = basilisk_checker::check_with_config(&resolved, config); - - let lsp_diags: Vec<_> = checker_diags - .iter() - .map(|d| basilisk_lsp::workspace_analysis::bsk_to_lsp(d, &source)) - .collect(); - - let Some(action) = basilisk_lsp::code_actions::mass_fix::fix_filtered_in_file( - &uri, - &lsp_diags, - &source, - allowed_rules, - ) else { - return Ok(0); - }; - - let edits = action - .edit - .and_then(|ws| ws.changes) - .and_then(|mut map| map.remove(&uri)) - .unwrap_or_default(); - - if edits.is_empty() { - return Ok(0); - } - - let edit_count = edits.len(); - let fixed_source = apply_text_edits(&source, &edits); - std::fs::write(path, fixed_source).map_err(|e| format!("{path}: {e}"))?; - - info!(path, edit_count, "applied fixes"); - Ok(edit_count) -} - -/// Apply LSP text edits to source text. -/// -/// Edits are sorted by position descending (bottom-to-top) so that earlier -/// offsets remain valid as later text is modified. -fn apply_text_edits(source: &str, edits: &[TextEdit]) -> String { - let mut indexed: Vec<_> = edits - .iter() - .map(|edit| { - let start = basilisk_lsp::util::position_to_byte_offset(source, edit.range.start); - let end = basilisk_lsp::util::position_to_byte_offset(source, edit.range.end); - (start, end, &edit.new_text) - }) - .collect(); - - // Sort descending by start offset so we can apply from the end. - indexed.sort_by(|a, b| b.0.cmp(&a.0).then(b.1.cmp(&a.1))); - - let mut result = source.to_owned(); - for (start, end, new_text) in indexed { - let clamped_start = start.min(result.len()); - let clamped_end = end.min(result.len()); - result.replace_range(clamped_start..clamped_end, new_text); - } - - result -} - -#[cfg(test)] -#[expect( - clippy::expect_used, - reason = "test-only code: expect acceptable in unit tests" -)] -mod tests { - use super::*; - use tower_lsp::lsp_types::{Position, Range}; - - /// Write `source` to a uniquely-named temp `.py` file inside an isolated - /// project dir that ships a `pyproject.toml` opting into the annotation - /// house rules. `fix` targets those rules (`BSK-0001`/`BSK-0002`/ - /// `BSK-0005`/`BSK-0050`), which are OFF by default — a real user enables - /// them in configuration, so the test project does too. The command loads - /// that config from disk exactly as it would in production. No modes; this - /// is configuration. See [CHKARCH-CONFIGURATION-ONLY]. - fn write_temp(name: &str, source: &str) -> (std::path::PathBuf, String) { - // Per-process dir name (same pattern as `stage_project` in - // cli_binary_tests): a stray watcher or leftover harness process from - // a previous run must never touch this run's fixture files. - let dir = std::env::temp_dir().join(format!("{name}.{}.proj", std::process::id())); - std::fs::create_dir_all(&dir).expect("create temp project dir"); - std::fs::write( - dir.join("pyproject.toml"), - "[tool.basilisk.rules]\n\"BSK-0001\" = \"error\"\n\"BSK-0002\" = \"error\"\n\"BSK-0005\" = \"error\"\n\"BSK-0050\" = \"warning\"\n", - ) - .expect("write pyproject.toml"); - let py = dir.join(name); - std::fs::write(&py, source).expect("write temp file"); - let path = py.to_string_lossy().into_owned(); - (py, path) - } - - /// Remove the isolated project dir created by [`write_temp`]. - fn cleanup(py: &std::path::Path) { - if let Some(dir) = py.parent() { - let _ = std::fs::remove_dir_all(dir); - } - } - - /// Run fix, read back the file, clean up, and return `(exit_code, content)`. - fn fix_and_read( - path_str: &str, - py: &std::path::Path, - include_unsafe: bool, - rules: &[String], - ) -> (u8, String) { - let code = run_fix(&[path_str.to_owned()], include_unsafe, rules); - let content = std::fs::read_to_string(py).expect("read back"); - cleanup(py); - (code, content) - } - - #[test] - fn apply_text_edits_empty_edits() { - let source = "hello world"; - let result = apply_text_edits(source, &[]); - assert_eq!(result, "hello world"); - } - - #[test] - fn apply_text_edits_single_insert() { - let source = "x = 42\n"; - let edits = vec![TextEdit { - range: Range::new(Position::new(0, 1), Position::new(0, 1)), - new_text: ": int".to_owned(), - }]; - assert_eq!(apply_text_edits(source, &edits), "x: int = 42\n"); - } - - #[test] - fn apply_text_edits_single_delete() { - let source = "x: int = 42\n"; - let edits = vec![TextEdit { - range: Range::new(Position::new(0, 1), Position::new(0, 7)), - new_text: String::new(), - }]; - assert_eq!(apply_text_edits(source, &edits), "x= 42\n"); - } - - #[test] - fn apply_text_edits_multiple_non_overlapping() { - let source = "x = 1\ny = 2\n"; - let edits = vec![ - TextEdit { - range: Range::new(Position::new(0, 1), Position::new(0, 1)), - new_text: ": int".to_owned(), - }, - TextEdit { - range: Range::new(Position::new(1, 1), Position::new(1, 1)), - new_text: ": int".to_owned(), - }, - ]; - assert_eq!(apply_text_edits(source, &edits), "x: int = 1\ny: int = 2\n"); - } - - #[test] - fn pluralise_returns_empty_for_one() { - assert_eq!(pluralise(1), ""); - } - - #[test] - fn pluralise_returns_s_for_zero() { - assert_eq!(pluralise(0), "s"); - } - - #[test] - fn pluralise_returns_s_for_many() { - assert_eq!(pluralise(5), "s"); - } - - #[test] - fn run_fix_nonexistent_path_returns_three() { - assert_eq!(run_fix(&["/no/such/path.py".to_owned()], false, &[]), 3); - } - - #[test] - fn run_fix_clean_code_returns_zero() { - let (py, path) = write_temp( - "basilisk_test_fix_clean.py", - "def greet(name: str) -> str:\n return name\n", - ); - let code = run_fix(&[path], false, &[]); - cleanup(&py); - assert_eq!(code, 0, "clean code must return 0"); - } - - #[test] - fn run_fix_applies_fixes_to_file() { - let (py, path) = write_temp("basilisk_test_fix_apply.py", "x: int = 42\n"); - let (code, fixed) = fix_and_read(&path, &py, false, &[]); - assert_eq!(code, 0, "fixable code must return 0"); - assert_eq!(fixed, "x = 42\n", "redundant annotation should be removed"); - } - - #[test] - fn run_fix_with_specific_rule_only_fixes_that_rule() { - let (py, path) = write_temp("basilisk_test_fix_specific_rule.py", "x: int = 42\n"); - let (code, fixed) = fix_and_read(&path, &py, false, &["BSK-0050".to_owned()]); - assert_eq!(code, 0); - assert_eq!( - fixed, "x = 42\n", - "BSK-0050 fix should be applied when specified" - ); - } - - #[test] - fn run_fix_with_unmatched_rule_does_not_fix() { - let (py, path) = write_temp("basilisk_test_fix_unmatched_rule.py", "x: int = 42\n"); - let (code, fixed) = fix_and_read(&path, &py, false, &["BSK-0001".to_owned()]); - assert_eq!(code, 0); - assert_eq!( - fixed, "x: int = 42\n", - "file unchanged when rule does not match" - ); - } - - #[test] - fn run_fix_with_rules_all_applies_all_rules() { - let (py, path) = write_temp("basilisk_test_fix_rules_all.py", "x: int = 42\n"); - let (code, fixed) = fix_and_read(&path, &py, false, &["all".to_owned()]); - assert_eq!(code, 0); - assert_eq!(fixed, "x = 42\n", "--rules all should apply all fixes"); - } - - #[test] - fn run_fix_empty_rules_applies_all_safe_rules() { - let (py, path) = write_temp("basilisk_test_fix_default_safe.py", "x: int = 42\n"); - let (code, fixed) = fix_and_read(&path, &py, false, &[]); - assert_eq!(code, 0); - assert_eq!( - fixed, "x = 42\n", - "default (safe) rules should fix BSK-0050" - ); - } - - #[test] - fn resolve_rules_empty_safe() { - let result = resolve_rules(false, &[]); - let expected: Vec = SAFE_FIXABLE_RULES - .iter() - .map(|s| (*s).to_string()) - .collect(); - assert_eq!(result, expected); - } - - #[test] - fn resolve_rules_empty_unsafe() { - let result = resolve_rules(true, &[]); - let expected: Vec = ALL_FIXABLE_RULES.iter().map(|s| (*s).to_string()).collect(); - assert_eq!(result, expected); - } - - #[test] - fn resolve_rules_all_keyword() { - let result = resolve_rules(false, &["ALL".to_owned()]); - let expected: Vec = ALL_FIXABLE_RULES.iter().map(|s| (*s).to_string()).collect(); - assert_eq!(result, expected); - } - - #[test] - fn resolve_rules_specific_list() { - let input = vec!["BSK-0001".to_owned(), "BSK-0050".to_owned()]; - assert_eq!(resolve_rules(false, &input), input); - } - - // ── New e2e tests ──────────────────────────────────────────────────── - - #[test] - fn run_fix_applies_e0001_missing_param_annotation() { - let (py, path) = write_temp("basilisk_test_fix_e0001.py", "def foo(x):\n pass\n"); - let (code, fixed) = fix_and_read(&path, &py, false, &["BSK-0001".to_owned()]); - assert_eq!(code, 0, "BSK-0001 fix must return 0"); - assert!(fixed.contains("def foo(x: Any)"), "got: {fixed}"); - } - - #[test] - fn run_fix_applies_e0002_missing_return_annotation() { - // The returned method call is not inferable, so BSK-0002 fires and the - // fix inserts the honest `-> Any` placeholder ([TYPEINF-FUNC-RETURN]). - // A `pass` body would infer `-> None` and leave nothing to fix. - let (py, path) = write_temp( - "basilisk_test_fix_e0002.py", - "def foo(x: int):\n return x.bit_length()\n", - ); - let (code, fixed) = fix_and_read(&path, &py, false, &["BSK-0002".to_owned()]); - assert_eq!(code, 0, "BSK-0002 fix must return 0"); - assert_eq!( - fixed, - "def foo(x: int) -> Any:\n return x.bit_length()\n" - ); - } - - #[test] - fn run_fix_applies_e0005_missing_attribute_annotation() { - let (py, path) = write_temp("basilisk_test_fix_e0005.py", "class Foo:\n bar = []\n"); - let (code, fixed) = fix_and_read(&path, &py, false, &["BSK-0005".to_owned()]); - assert_eq!(code, 0, "BSK-0005 fix must return 0"); - assert!(fixed.contains("bar: Any = []"), "got: {fixed}"); - } - - #[test] - fn run_fix_applies_multiple_rules_in_one_file() { - // `x` has no default to infer from (BSK-0001) and `return x` is not - // inferable (BSK-0002); `y: int = 42` is redundant (BSK-0050). - let (py, path) = write_temp( - "basilisk_test_fix_multi_rules.py", - "def foo(x):\n return x\n\ny: int = 42\n", - ); - let (code, fixed) = fix_and_read(&path, &py, false, &[]); - assert_eq!(code, 0); - assert!( - fixed.contains("x: Any"), - "BSK-0001 not applied, got: {fixed}" - ); - assert!( - fixed.contains("-> Any"), - "BSK-0002 not applied, got: {fixed}" - ); - assert!( - fixed.contains("y = 42"), - "BSK-0050 not applied, got: {fixed}" - ); - } - - #[test] - fn run_fix_directory_traversal() { - // Per-process dir name — see `write_temp` for the rationale. - let dir = std::env::temp_dir().join(format!( - "basilisk_test_fix_dir_traversal.{}", - std::process::id() - )); - let _ = std::fs::create_dir_all(&dir); - std::fs::write( - dir.join("pyproject.toml"), - "[tool.basilisk.rules]\n\"BSK-0050\" = \"warning\"\n", - ) - .expect("write pyproject.toml"); - let file_a = dir.join("a_fix.py"); - let file_b = dir.join("b_fix.py"); - std::fs::write(&file_a, "x: int = 42\n").expect("write a"); - std::fs::write(&file_b, "y: str = \"hello\"\n").expect("write b"); - let code = run_fix( - &[dir.to_string_lossy().into_owned()], - false, - &["BSK-0050".to_owned()], - ); - let fixed_a = std::fs::read_to_string(&file_a).expect("read a"); - let fixed_b = std::fs::read_to_string(&file_b).expect("read b"); - let _ = std::fs::remove_dir_all(&dir); - assert_eq!(code, 0); - assert_eq!(fixed_a, "x = 42\n", "BSK-0050 not applied to first file"); - assert_eq!( - fixed_b, "y = \"hello\"\n", - "BSK-0050 not applied to second file" - ); - } - - #[test] - fn run_fix_is_idempotent() { - let (py, path) = write_temp("basilisk_test_fix_idempotent.py", "x: int = 42\n"); - let first = run_fix(std::slice::from_ref(&path), false, &[]); - let after_first = std::fs::read_to_string(&py).expect("read after first"); - assert_eq!(first, 0); - assert_eq!(after_first, "x = 42\n"); - - let second = run_fix(&[path], false, &[]); - let after_second = std::fs::read_to_string(&py).expect("read after second"); - cleanup(&py); - assert_eq!(second, 0); - assert_eq!(after_second, "x = 42\n", "second pass should be a no-op"); - } - - #[test] - fn run_fix_with_unsafe_flag() { - let (py, path) = write_temp("basilisk_test_fix_unsafe.py", "x: int = 42\n"); - let (code, fixed) = fix_and_read(&path, &py, true, &[]); - assert_eq!(code, 0); - assert_eq!( - fixed, "x = 42\n", - "include_unsafe=true should apply BSK-0050" - ); - } - - #[test] - fn run_fix_preserves_surrounding_content() { - let source = "# This is a comment\n\nx: int = 42\n\n\ - # Another comment\ndef greet(name: str) -> str:\n\ - \x20 \"\"\"Say hello.\"\"\"\n return f\"Hello, {name}\"\n"; - let (py, path) = write_temp("basilisk_test_fix_preserves.py", source); - let (code, fixed) = fix_and_read(&path, &py, false, &[]); - assert_eq!(code, 0); - assert!( - fixed.contains("# This is a comment"), - "leading comment lost" - ); - assert!(fixed.contains("# Another comment"), "middle comment lost"); - assert!(fixed.contains("\"\"\"Say hello.\"\"\""), "docstring lost"); - assert!( - fixed.contains("def greet(name: str) -> str:"), - "clean fn changed" - ); - assert!(fixed.contains("x = 42"), "BSK-0050 fix not applied"); - } - - #[test] - fn run_fix_no_fixable_diagnostics_leaves_file_unchanged() { - let source = "def foo(x: int) -> int:\n return \"hello\"\n"; - let (py, path) = write_temp("basilisk_test_fix_unfixable_diags.py", source); - let (code, fixed) = fix_and_read(&path, &py, false, &[]); - assert_eq!(code, 0, "unfixable diagnostics should not cause errors"); - assert_eq!(fixed, source, "file must be unchanged when no fixes apply"); - } -} diff --git a/crates/basilisk-cli/src/format.rs b/crates/basilisk-cli/src/format.rs deleted file mode 100644 index 65e97d14c..000000000 --- a/crates/basilisk-cli/src/format.rs +++ /dev/null @@ -1,140 +0,0 @@ -//! Implements [LSPFMT-CLIENTS] and [CHKARCH-CLI-COMMANDS]. See -//! docs/specs/LSP-FORMATTING-SPEC.md#LSPFMT-CLIENTS -//! `basilisk format` subcommand — format Python files in place, or verify -//! them with `--check`, using the embedded Ruff formatter. -//! -//! Same engine, same style source as LSP `textDocument/formatting` -//! ([LSPFMT-ENGINE]): for identical input and configuration the output bytes -//! are identical. No `ruff` executable is ever spawned ([LSPFMT-DECISION]). - -use basilisk_lsp::config::{FormatStyle, FormatterEngine}; -use basilisk_lsp::formatting::{format_document, EMBEDDED_RUFF_FORMATTER_VERSION}; -use tracing::warn; - -use crate::pipeline::pluralise; - -/// Run the format subcommand. -/// -/// Exit codes: -/// - `0` — write mode completed, or check mode found every file formatted -/// - `1` — check mode found unformatted files, or a file failed to parse -/// - `3` — internal error (path collection, config discovery) -pub(crate) fn run_format(paths: &[String], check: bool) -> u8 { - let config_root = crate::pipeline::first_path_dir(paths); - let workspace = basilisk_lsp::config::load_config(&config_root); - if workspace.formatter == FormatterEngine::Disabled { - // [LSPFMT-CONFIG]: `"none"` disables formatting; mirror the LSP, - // which stops advertising the formatting capabilities. - println!("Formatter is disabled (formatter = \"none\"); nothing to do."); - return 0; - } - match collect_and_format(paths, &workspace.format_style, check) { - Ok(summary) => summarise(&summary, check), - Err(err) => { - tracing::error!(%err, "internal error"); - 3 - } - } -} - -/// Outcome of formatting one file. -enum FileOutcome { - /// The file was rewritten (write mode) or would be (check mode). - Changed, - /// The file is already formatted. - Clean, -} - -/// Result of a whole format run. -#[derive(Default)] -struct FormatSummary { - /// Files rewritten (write mode) or needing a rewrite (check mode). - changed: usize, - /// Files already formatted. - unchanged: usize, - /// Files that could not be read or parsed. - failures: usize, -} - -/// Collect Python files under `paths` and format each one. -/// -/// Path collection honours the same `[tool.basilisk]` `exclude` semantics as -/// `check` and `fix` ([CHKARCH-CONFIG-EXCLUDE]). -fn collect_and_format( - paths: &[String], - style: &FormatStyle, - check: bool, -) -> Result { - let config_root = crate::pipeline::first_path_dir(paths); - let config = basilisk_config::load_basilisk_config(&config_root); - let excluded = crate::pipeline::excluded_dirs_and_log(&config, &config_root); - let python_files = crate::pipeline::collect_python_files(paths, &excluded)?; - - let mut summary = FormatSummary::default(); - for path in python_files { - match format_single_file(&path, style, check) { - Ok(FileOutcome::Changed) => summary.changed += 1, - Ok(FileOutcome::Clean) => summary.unchanged += 1, - Err(err) => { - warn!(path, %err, "cannot format file"); - summary.failures += 1; - } - } - } - Ok(summary) -} - -/// Format one file: rewrite it in write mode, report it in check mode. -fn format_single_file(path: &str, style: &FormatStyle, check: bool) -> Result { - let source = std::fs::read_to_string(path).map_err(|e| e.to_string())?; - let Some(formatted) = formatted_text(&source, style) else { - // `format_document` returns `None` for already-formatted AND for - // unparseable sources; parse to tell them apart. Like `ruff format`, - // invalid syntax is refused, never rewritten. - return match basilisk_parser::parse_source(source, path.to_owned()) { - Ok(_) => Ok(FileOutcome::Clean), - Err(err) => Err(err.to_string()), - }; - }; - if check { - println!("Would reformat: {path}"); - return Ok(FileOutcome::Changed); - } - std::fs::write(path, formatted).map_err(|e| e.to_string())?; - Ok(FileOutcome::Changed) -} - -/// The full formatted text, or `None` when the source is already formatted -/// or does not parse ([LSPFMT-ENGINE] pure passthrough). -fn formatted_text(source: &str, style: &FormatStyle) -> Option { - format_document(source, style)? - .into_iter() - .next() - .map(|edit| edit.new_text) -} - -/// Print the run summary and derive the exit code. -/// -/// The summary names the engine and version — the CLI face of the -/// provenance contract ([LSPFMT-PROVENANCE]). -fn summarise(summary: &FormatSummary, check: bool) -> u8 { - let changed = summary.changed; - let verb = if check { - format!("{changed} file{} would be reformatted", pluralise(changed)) - } else { - format!("Reformatted {changed} file{}", pluralise(changed)) - }; - println!( - "{verb}, {} already formatted (embedded Ruff {EMBEDDED_RUFF_FORMATTER_VERSION}).", - summary.unchanged - ); - if summary.failures > 0 { - println!( - "{} file{} failed to parse and {} left unchanged.", - summary.failures, - pluralise(summary.failures), - if summary.failures == 1 { "was" } else { "were" } - ); - } - u8::from(summary.failures > 0 || (check && changed > 0)) -} diff --git a/crates/basilisk-cli/src/import_search.rs b/crates/basilisk-cli/src/import_search.rs deleted file mode 100644 index f4b4677fd..000000000 --- a/crates/basilisk-cli/src/import_search.rs +++ /dev/null @@ -1,92 +0,0 @@ -//! CLI import-search setup fast paths. -//! -//! Implements [ANALYSIS-CROSSLSP-IMPORT]. Search-path discovery probes Python, -//! uv metadata, and nested projects. None of those paths can affect a resolved -//! module with no import statements, so a proven no-import batch keeps only its -//! roots and avoids that fixed setup cost. - -use std::path::PathBuf; - -use basilisk_lsp::import_resolver::ImportSearchPaths; - -const IMPORT_KEYWORD: &[u8] = b"import"; - -/// Return `true` unless every source file proves it contains no Python import -/// keyword. -/// -/// The byte search is deliberately conservative. Strings, comments, and names -/// such as `important` can produce a false positive and take the full discovery -/// path. They cannot produce a false negative: Python's `import` keyword is -/// always the literal lowercase ASCII token. An unreadable file also fails open -/// so the ordinary analysis path retains its existing error behaviour. -pub(crate) fn files_might_import(paths: &[String]) -> bool { - paths.iter().any(|path| match std::fs::read(path) { - Ok(source) => source_might_import(&source), - Err(_) => true, - }) -} - -fn source_might_import(source: &[u8]) -> bool { - source - .windows(IMPORT_KEYWORD.len()) - .any(|window| window == IMPORT_KEYWORD) -} - -/// Build the complete search-path value required by the resolver when there -/// are no imports to resolve. Roots are retained for API consistency; all -/// import-only fields are empty by proof from [`files_might_import`]. -pub(crate) fn roots_only(roots: Vec) -> ImportSearchPaths { - ImportSearchPaths { - roots, - extra_paths: Vec::new(), - stub_paths: Vec::new(), - workspace_members: Vec::new(), - site_packages: None, - registry: None, - typeshed_snapshot: None, - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn import_statements_take_full_discovery_path() { - assert!(source_might_import(b"import package\n")); - assert!(source_might_import(b"from package import symbol\n")); - } - - #[test] - fn import_free_source_takes_roots_only_path() { - assert!(!source_might_import(b"value: int = 42\n")); - } - - #[test] - fn false_positives_are_conservative() { - assert!(source_might_import(b"important = 'not syntax'\n")); - assert!(source_might_import(b"# import mentioned in a comment\n")); - } - - #[test] - fn unreadable_source_fails_open() { - assert!(files_might_import(&[format!( - "/path/that/does/not/exist/{}", - std::process::id() - )])); - } - - #[test] - fn roots_only_retains_roots_and_empties_import_fields() { - let root = PathBuf::from("/workspace"); - let paths = roots_only(vec![root.clone()]); - - assert_eq!(paths.roots, vec![root]); - assert!(paths.extra_paths.is_empty()); - assert!(paths.stub_paths.is_empty()); - assert!(paths.workspace_members.is_empty()); - assert!(paths.site_packages.is_none()); - assert!(paths.registry.is_none()); - assert!(paths.typeshed_snapshot.is_none()); - } -} diff --git a/crates/basilisk-cli/src/main.rs b/crates/basilisk-cli/src/main.rs index 063641789..73e7d3b06 100644 --- a/crates/basilisk-cli/src/main.rs +++ b/crates/basilisk-cli/src/main.rs @@ -1,196 +1,44 @@ -//! Implements [CHKARCH-CLI]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-CLI -//! Basilisk CLI entry point. +//! Implements [WITHDRAWAL-INERT]. See +//! docs/specs/DOCS-WITHDRAWAL-MESSAGING-SPEC.md#WITHDRAWAL-INERT //! -//! Usage: -//! ``` -//! basilisk check [paths...] -//! basilisk check [paths...] --output json -//! basilisk analyze [paths...] -//! basilisk format [paths...] [--check] -//! ``` - +//! Basilisk's type checker is inert. It parses no arguments, reads no file, +//! starts no server, and checks nothing. Every invocation prints the approved +//! notice to stderr and exits `4`, so a pipeline that still calls Basilisk +//! breaks loudly instead of reading a clean run into a checker that was +//! producing incorrect results. `--version` is the sole exception: package +//! managers and installed editor extensions probe it, and a hang would hide +//! the notice rather than deliver it. + +use std::io::Write as _; use std::process::ExitCode; -use clap::{Parser, Subcommand}; -use colored::Colorize as _; use shipwright::{dispatch, BuildInfo, VersionSpec}; use shipwright_manifest::{ExecutableKind, Language}; -use tracing::error; - -use crate::output::{ - render_diagnostics, render_diagnostics_json, ColorMode, JsonFailure, OutputFormat, -}; -use crate::pipeline::{collect_and_check, pluralise, DiagnosticScope, PipelineError}; - -mod adopt; -mod cache_check; -mod fix; -mod format; -mod import_search; -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}; - -/// Basilisk — strict-by-default Python type checker. -/// -/// No escape hatches. Every parameter typed. Every return declared. -#[derive(Parser)] -#[command(name = "basilisk", version, about, long_about = None)] -struct Cli { - #[command(subcommand)] - command: Command, -} -/// Transport protocol for the LSP server. -#[derive(Clone, Debug, clap::ValueEnum)] -enum Transport { - /// JSON-RPC over standard input/output (default). - Stdio, - /// JSON-RPC over WebSocket. - Ws, -} - -/// The paths/`--output`/`--color`/`--cache*` surface shared verbatim by -/// `check` and `analyze` — the two commands run the identical pipeline and -/// differ only in diagnostic scope ([CHKARCH-COMMANDS]). -#[derive(clap::Args)] -struct CheckArgs { - /// Paths to check. Directories are traversed recursively for `.py` - /// files. Defaults to the configured `[tool.basilisk] include` roots, - /// else the current directory. - paths: Vec, - /// Output format: text (default, human-readable) or json (machine-readable). - #[arg(long, default_value = "text")] - output: OutputFormat, - /// When to use terminal colours: auto (default), always, or never. - #[arg(long, default_value = "auto")] - color: ColorMode, - /// Enable the opt-in result cache for this run, whatever - /// `[tool.basilisk] cache` says: unchanged files are served from a - /// persistent cache. A hit is returned only when the file, every file - /// it reads, the config, and the checker version are unchanged. - #[arg(long)] - cache: bool, - /// Disable the result cache for this run, whatever `[tool.basilisk] cache` - /// says. Wins over `--cache` when both are given ([CHKCACHE-CONFIG]). - #[arg(long)] - no_cache: bool, - /// Override the cache directory for this run (default: the project's - /// `[tool.basilisk] cache-dir`, else `/.basilisk/cache/check`). - #[arg(long, value_name = "DIR")] - cache_dir: Option, - /// Print cache hit/miss counts to stderr after checking. - #[arg(long)] - cache_stats: bool, -} +/// The approved notice, verbatim. Generated from the messaging spec by +/// `scripts/gen_withdrawal_copy.py` ([WITHDRAWAL-INERT-TEXT]) and drift-gated +/// in CI, so this binary can never print its own version of the statement. +const NOTICE: &str = include_str!("withdrawal_notice.txt"); -// Implements [CHKARCH-CLI-COMMANDS]: the `check`/`analyze` core commands -// ([CHKARCH-COMMANDS]) plus `format`/`fix`/`adopt`/`unadopt`/`lsp`/`stubs`. -// See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-CLI-COMMANDS -#[derive(Subcommand)] -enum Command { - /// Type check one or more files or directories — the PEP typing spec, - /// always. Emits only `pep`-tagged rules ([CHKARCH-COMMANDS]). - Check { - #[command(flatten)] - args: CheckArgs, - }, - /// Run the opt-in analysis layer — every rule *not* tagged `pep`, fired - /// only when configuration selects it ([CHKARCH-COMMANDS]). - Analyze { - #[command(flatten)] - args: CheckArgs, - }, - /// Format Python files with the embedded Ruff formatter — the same - /// engine and style configuration as LSP formatting ([LSPFMT-CLIENTS]). - Format { - /// Paths to format. Directories are traversed recursively for `.py` files. - #[arg(default_value = ".")] - paths: Vec, - /// Report files that would change without rewriting them. - #[arg(long)] - check: bool, - }, - /// Apply autofixes to one or more files or directories. - Fix { - /// Paths to fix. Directories are traversed recursively for `.py` - /// files. Defaults to the configured `[tool.basilisk] include` roots, - /// else the current directory. - paths: Vec, - /// Include unsafe (heuristic) fixes alongside safe fixes. - #[arg(long)] - r#unsafe: bool, - /// Comma-separated list of rule codes to fix (e.g. BSK-0001,BSK-0003). - /// If omitted, all safe rules are applied. Use `--rules all` for all rules. - #[arg(long, value_delimiter = ',')] - rules: Vec, - }, - /// Adopt current error debt — demote firing error codes to folder-level - /// warning entries for gradual migration ([AUTOFIX-ADOPTION]). - Adopt { - /// Paths to adopt. Directories are traversed recursively for `.py` files. - #[arg(default_value = ".")] - paths: Vec, - /// Show adoption status instead of adopting. - #[arg(long)] - status: bool, - }, - /// Un-adopt — delete the folder-level warning entries, restoring the - /// ancestor severity ([AUTOFIX-ADOPTION]). - Unadopt { - /// Paths to un-adopt. Directories are traversed recursively for `.py` files. - #[arg(default_value = ".")] - paths: Vec, - }, - /// Start the Basilisk Language Server. - Lsp { - /// Transport protocol: stdio (default) or ws (WebSocket). - #[arg(long, default_value = "stdio")] - transport: Transport, - /// Port for WebSocket transport (ignored for stdio). - #[arg(long, default_value_t = 8765)] - port: u16, - }, - /// Serve read-only Basilisk status tools over Model Context Protocol stdio. - Mcp { - /// Workspace whose project configuration selects the typeshed source. - #[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)] - action: stubs::StubAction, - }, - /// Generate a package stub using Pyright's compatibility spelling. - #[command(name = "createstub", long_flag = "createstub")] - CreateStub(stubs::CreateStubArgs), -} +/// `4` — unlisted ([CHKARCH-CLI-EXITCODES]). Distinct from `1` ("error +/// diagnostics found", which would be one more incorrect result) and from `2` +/// and `3`, so a consumer can tell "Basilisk is gone" from "Basilisk failed". +const EXIT_UNLISTED: u8 = 4; -/// Handle `--version` / `--version --json` via the Shipwright contract emitter. +/// Answer `--version` / `--version --json` through the Shipwright contract. /// -/// Returns `true` when a version flag was handled and `main` should exit 0. -/// Build-time metadata is supplied by `build.rs`. +/// Returns `true` when a version flag was handled and `main` should exit 0. The +/// capability list is empty and the kind is `Cli`: this binary is no longer a +/// language server, an MCP server, a debug adapter, or a profiler, and saying +/// otherwise to a tool that reads the contract would be a false claim. fn handle_version(args: &[String]) -> bool { let spec = VersionSpec { name: "basilisk", version: env!("CARGO_PKG_VERSION"), - kind: ExecutableKind::Lsp, + kind: ExecutableKind::Cli, language: Language::Rust, product: Some("basilisk"), - capabilities: &["cli", "lsp", "mcp", "dap", "profiler", "test-explorer"], + capabilities: &[], build: BuildInfo { git_sha: option_env!("SHIPWRIGHT_GIT_SHA"), git_dirty: option_env!("SHIPWRIGHT_GIT_DIRTY").map(|s| s == "true"), @@ -199,33 +47,9 @@ fn handle_version(args: &[String]) -> bool { toolchain: option_env!("SHIPWRIGHT_TOOLCHAIN"), }, }; - match dispatch(args, &mut std::io::stdout(), &spec) { - Ok(handled) => { - // [LSPFMT-PROVENANCE]: the human-readable `--version` also lists - // the embedded formatter engine. The `--json` payload stays a - // pure Shipwright contract, so machine consumers are unaffected. - if handled && !args.iter().any(|a| a == "--json") { - let _ = std::io::Write::write_all( - &mut std::io::stdout(), - format!( - "Ruff formatter: {}\n", - basilisk_lsp::formatting::EMBEDDED_RUFF_FORMATTER_VERSION - ) - .as_bytes(), - ); - } - handled - } - Err(err) => { - let _ = std::io::Write::write_all( - &mut std::io::stderr(), - format!("basilisk: --version emission failed: {err}\n").as_bytes(), - ); - // Don't swallow the error silently; surface to the user but - // don't continue normal execution either. - true - } - } + // A failed emission falls through to the notice below rather than being + // reported: there is no successful outcome left for this binary to have. + dispatch(args, &mut std::io::stdout(), &spec).unwrap_or(false) } fn main() -> ExitCode { @@ -233,791 +57,54 @@ fn main() -> ExitCode { if handle_version(&args) { return ExitCode::SUCCESS; } - - // Initialize tracing. Controlled via BASILISK_LOG env var (defaults to info). - // Examples: BASILISK_LOG=debug, BASILISK_LOG=basilisk_lsp::debug=trace - // - // Only colourise when stderr is an interactive terminal. When the binary - // runs as a subprocess (e.g. the LSP launched by the VS Code extension) - // stderr is a pipe, and raw ANSI escapes would otherwise render as garbage - // in the editor's output channel (issue #23). - let tracing = tracing_subscriber::fmt() - .with_ansi(std::io::IsTerminal::is_terminal(&std::io::stderr())) - .with_writer(std::io::stderr); - if std::env::var_os("BASILISK_LOG").is_some() { - tracing - .with_env_filter( - tracing_subscriber::EnvFilter::try_from_env("BASILISK_LOG") - .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("warn")), - ) - .init(); - } else { - // The default path needs only warnings/errors. Avoid constructing and - // parsing an EnvFilter on every short-lived CLI check. - tracing.with_max_level(tracing::Level::WARN).init(); - } - - let cli = Cli::parse(); - - // Command dispatch runs on an analysis-sized stack: `check`/`analyze`/ - // `fix`/`adopt` walk the AST recursively and overflow the default - // main-thread stack (~8 MiB on macOS/Linux, ~1 MiB on Windows) on deeply - // chained expressions in generated code. Implements [LSPARCH-ARCH-STACK] - // (GitHub #278). - let exit_code = - match basilisk_lsp::runtime::run_with_analysis_stack("basilisk-cli", move || { - run_command(cli.command) - }) { - Ok(code) => code, - Err(err) => { - error!(%err, "analysis thread failed"); - // 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) -} - -/// Dispatch the parsed subcommand. Returns the process exit code. -fn run_command(command: Command) -> u8 { - match command { - // [CHKARCH-COMMANDS]: identical pipeline, different edge filter. - Command::Check { args } => run_scoped_check(&args, DiagnosticScope::Check), - Command::Analyze { args } => run_scoped_check(&args, DiagnosticScope::Analyze), - Command::Format { paths, check } => format::run_format(&paths, check), - Command::Fix { - paths, - r#unsafe: include_unsafe, - rules, - } => fix::run_fix(&paths, include_unsafe, &rules), - Command::Adopt { paths, status } => { - if status { - adopt::run_adopt_status(&paths) - } else { - adopt::run_adopt(&paths) - } - } - Command::Unadopt { paths } => adopt::run_unadopt(&paths), - Command::Lsp { transport, port } => match transport { - Transport::Stdio => match basilisk_lsp::run_server() { - Ok(()) => 0, - Err(err) => { - error!(%err, "failed to start LSP server (stdio)"); - 1 - } - }, - Transport::Ws => match basilisk_lsp::run_server_ws_blocking(port) { - Ok(()) => 0, - Err(err) => { - error!(%err, "failed to start LSP server (ws)"); - 1 - } - }, - }, - Command::Mcp { workspace } => match mcp::run(&workspace) { - Ok(()) => 0, - Err(err) => { - error!(%err, "MCP server failed"); - 1 - } - }, - Command::Typeshed { action } => typeshed_cli::run(action), - Command::Stubs { action } => stubs::run(action), - Command::CreateStub(args) => stubs::run_create_stub(args), - } -} - -/// Run the `check`/`analyze` pipeline and render its outcome. -/// -/// Implements [CHKARCH-CLI-EXITCODES]. Exit codes: -/// - `0` — clean, no errors -/// - `1` — error diagnostics found -/// - `2` — invalid configuration (a `pep` rule resolved to `disabled`, -/// [CHKARCH-CONFIG-MODEL]) -/// - `3` — internal error, or a terminal typeshed source failure (`NO SOURCE`) -fn run_scoped_check(args: &CheckArgs, scope: DiagnosticScope) -> u8 { - args.color.apply(); - let cache = cache_check::CacheOptions { - enabled: cache_check::CacheOverride::from_flags(args.cache, args.no_cache), - dir: args.cache_dir.clone(), - stats: args.cache_stats, - }; - let mut stats = cache_check::CacheStats::default(); - let result = collect_and_check(&args.paths, &cache, &mut stats, scope); - if cache.stats { - stats.report(); - } - match result { - // Implements [CHKARCH-CLI-OUTPUT]: the human-readable text default and - // machine-readable JSON. The spec's `sarif`/`junit` formats are not - // implemented (see report). - Ok(outcome) => { - let diagnostic_exit = render_outcome(&outcome, args.output); - for failure in &outcome.failures { - error!(path = %failure.path, error = %failure.message, "error processing file"); - } - if outcome.failures.is_empty() { - diagnostic_exit - } else { - 3 - } - } - Err(PipelineError::Config(message)) => { - error!(%message, "configuration error"); - 2 - } - Err(PipelineError::NoSource(message)) => { - // The message IS the spec's `NO SOURCE` status line with its - // recovery command — print it verbatim, not branded as an - // internal bug ([STUBRES-TYPESHED-OFFLINE]). - error!(%message); - 3 - } - Err(PipelineError::Internal(message)) => { - error!(%message, "internal error"); - 3 - } - } -} - -/// Tell the user which rules their configuration selected that this command -/// never evaluated, and where to see them. -/// -/// Implements [CHKARCH-CLI-SCOPE-NOTICE] (GitHub #334). `check` drops every -/// analyze-scope diagnostic at the edge ([CHKARCH-COMMANDS]), so without this -/// line a project that grades eight rule tags `error` reads "All checked. No -/// issues found." while none of those rules ever ran. Text only: the JSON -/// contract machine consumers parse is unchanged. -fn print_scope_notice(unrun_selected_rules: usize) { - if unrun_selected_rules == 0 { - return; - } - println!( - "{}", - format!( - "Note: your configuration selects {unrun_selected_rules} rule{} that `check` \ - never runs — they are not PEP typing-spec rules. Run `basilisk analyze` to \ - evaluate them.", - pluralise(unrun_selected_rules), - ) - .yellow() - ); -} - -/// Render diagnostics in the requested format; `1` when errors exist, else `0`. -fn render_outcome(outcome: &pipeline::CheckOutcome, format: OutputFormat) -> u8 { - match format { - OutputFormat::Json => { - let failures: Vec> = outcome - .failures - .iter() - .map(|failure| JsonFailure { - path: &failure.path, - message: &failure.message, - }) - .collect(); - render_diagnostics_json(&outcome.diagnostics, &outcome.sources, &failures); - let error_count = outcome - .diagnostics - .iter() - .filter(|d| d.severity == basilisk_checker::Severity::Error) - .count(); - u8::from(error_count > 0) - } - OutputFormat::Text => { - let error_count = render_diagnostics(&outcome.diagnostics, &outcome.sources); - let total = outcome.diagnostics.len(); - let exit_code = if total == 0 && outcome.failures.is_empty() { - println!("{}", "All checked. No issues found.".green().bold()); - 0 - } else if total == 0 { - 0 - } else { - let summary = format!( - "Found {} diagnostic{} ({} error{}).", - total, - pluralise(total), - error_count, - pluralise(error_count), - ); - if error_count > 0 { - println!("{}", summary.red().bold()); - } else { - println!("{}", summary.yellow().bold()); - } - u8::from(error_count > 0) - }; - print_scope_notice(outcome.unrun_selected_rules); - exit_code - } - } + // The one deliberate direct write to stderr in the codebase (CLAUDE.md): + // `tracing` would prefix, filter, and colourise the statement, and + // BASILISK_LOG could suppress it entirely. Stdout stays empty so + // `--output json > report.json` yields an empty file rather than prose a + // consumer might parse as findings. + let _ = std::io::stderr().write_all(NOTICE.as_bytes()); + ExitCode::from(EXIT_UNLISTED) } #[cfg(test)] -#[expect( - clippy::expect_used, - clippy::panic, - reason = "test-only CLI contract assertions fail loudly with explicit messages" -)] mod tests { use super::*; - /// Unique temp dir for tests that need an isolated project root. - fn unique_project_dir(prefix: &str) -> std::path::PathBuf { - use std::sync::atomic::{AtomicU64, Ordering}; - static CTR: AtomicU64 = AtomicU64::new(0); - let n = CTR.fetch_add(1, Ordering::Relaxed); - std::env::temp_dir().join(format!("{prefix}_{}_{n}", std::process::id())) - } - - /// `CheckArgs` for a plain, uncached run over `paths`. - fn plain_args(paths: Vec, output: OutputFormat) -> CheckArgs { - CheckArgs { - paths, - output, - color: ColorMode::Never, - cache: false, - no_cache: false, - cache_dir: None, - cache_stats: false, - } - } - - /// [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_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", - "typeshed", - "download", - "--commit", - "83c2518a9e6abbda0c44592c3483de459198f887", - ]) - .expect("download --commit must parse"); - let Command::Typeshed { - action: typeshed_cli::TypeshedAction::Download { commit, .. }, - } = exact.command - else { - panic!("expected typeshed download"); - }; - assert_eq!( - commit.as_deref(), - Some("83c2518a9e6abbda0c44592c3483de459198f887") - ); - - // `--package` acquires a PyPI wheel pinned by SHA-256 and is mutually - // exclusive with `--commit` ([STUBRES-TYPESHED-PYPI]). - let package = Cli::try_parse_from([ - "basilisk", - "typeshed", - "download", - "--package", - "types-stdlib@sha256:1111111111111111111111111111111111111111111111111111111111111111", - ]) - .expect("download --package must parse"); - let Command::Typeshed { - action: - typeshed_cli::TypeshedAction::Download { - commit, package, .. - }, - } = package.command - else { - panic!("expected typeshed download"); - }; - assert!(commit.is_none(), "--package excludes --commit"); - assert_eq!( - package.as_deref(), - Some("types-stdlib@sha256:1111111111111111111111111111111111111111111111111111111111111111") - ); - - // The two source flags are mutually exclusive. - let conflict = Cli::try_parse_from([ - "basilisk", - "typeshed", - "download", - "--commit", - "83c2518a9e6abbda0c44592c3483de459198f887", - "--package", - "types-stdlib@sha256:1111111111111111111111111111111111111111111111111111111111111111", - ]); - assert!( - conflict.is_err(), - "--commit and --package must be mutually exclusive" - ); - } - - /// An isolated project that opts the annotation house rule in, holding - /// one file that violates it. Returns the dir and the file path. - fn house_rule_project(prefix: &str) -> Result<(std::path::PathBuf, String), std::io::Error> { - let dir = unique_project_dir(prefix); - std::fs::create_dir_all(&dir)?; - std::fs::write( - dir.join("pyproject.toml"), - b"[tool.basilisk.rules]\n\"BSK-0001\" = \"error\"\n", - )?; - let py = dir.join("bad.py"); - std::fs::write(&py, b"def foo(x) -> None:\n pass\n")?; - Ok((dir, py.to_string_lossy().into_owned())) - } - - // ── run_scoped_check exit codes ([CHKARCH-CLI-EXITCODES]) ────────────── - - /// [CHKARCH-COMMANDS]: an analyze-scope error (configured house rule) - /// makes `analyze` exit 1 — in both output formats. + /// The notice is the spec's text, not a paraphrase, and it tells the reader + /// the three things they must act on: it is unlisted, it checks nothing, + /// and the failure is not about their code. #[test] - fn analyze_bad_code_returns_one() -> Result<(), Box> { - let (dir, path) = house_rule_project("basilisk_test_rc_analyze_bad")?; - let json = run_scoped_check( - &plain_args(vec![path.clone()], OutputFormat::Json), - DiagnosticScope::Analyze, - ); - let text = run_scoped_check( - &plain_args(vec![path], OutputFormat::Text), - DiagnosticScope::Analyze, - ); - let _ = std::fs::remove_dir_all(&dir); - assert_eq!(json, 1, "analyze-scope errors must exit 1 (Json)"); - assert_eq!(text, 1, "analyze-scope errors must exit 1 (Text)"); - Ok(()) + fn notice_carries_the_approved_statement() { + assert!(NOTICE.starts_with("Basilisk is unlisted.")); + assert!(NOTICE.contains("checks nothing")); + assert!(NOTICE.contains("This command failed on purpose.")); + assert!(NOTICE.contains("https://github.com/python/typing/pull/2330")); + assert!(NOTICE.ends_with("basilisk-conformance-apology\n")); } - /// [CHKARCH-COMMANDS]: `check` never sees house diagnostics, even when - /// configuration selects them — the same file exits 0 under check. + /// Only a version flag is answered. Every other argument shape — including + /// the ones clap used to own, like `--help` — falls through to the notice. #[test] - fn check_ignores_configured_house_rules() -> Result<(), Box> { - let (dir, path) = house_rule_project("basilisk_test_rc_check_scope")?; - let code = run_scoped_check( - &plain_args(vec![path], OutputFormat::Json), - DiagnosticScope::Check, - ); - let _ = std::fs::remove_dir_all(&dir); - assert_eq!(code, 0, "check must not exit 1 on analyze-scope debt"); - Ok(()) - } - - /// A pep-scope error (`return "x"` from `-> int`) makes `check` exit 1 - /// in both formats. [CHKARCH-COMMANDS] - #[test] - fn check_pep_error_returns_one() -> Result<(), Box> { - let dir = unique_project_dir("basilisk_test_rc_check_pep"); - std::fs::create_dir_all(&dir)?; - let py = dir.join("bad.py"); - std::fs::write(&py, b"def foo() -> int:\n return \"x\"\n")?; - let path = py.to_string_lossy().into_owned(); - let json = run_scoped_check( - &plain_args(vec![path.clone()], OutputFormat::Json), - DiagnosticScope::Check, - ); - let text = run_scoped_check( - &plain_args(vec![path], OutputFormat::Text), - DiagnosticScope::Check, - ); - let _ = std::fs::remove_dir_all(&dir); - assert_eq!(json, 1, "pep errors must make check exit 1 (Json)"); - assert_eq!(text, 1, "pep errors must make check exit 1 (Text)"); - Ok(()) - } - - /// Clean code exits 0 under both commands and formats. - #[test] - fn clean_code_returns_zero() -> Result<(), Box> { - let dir = std::env::temp_dir(); - let py = dir.join("basilisk_test_rc_clean.py"); - std::fs::write(&py, b"def greet(name: str) -> str:\n return name\n")?; - let path = py.to_string_lossy().into_owned(); - for scope in [DiagnosticScope::Check, DiagnosticScope::Analyze] { - for output in [OutputFormat::Text, OutputFormat::Json] { - assert_eq!( - run_scoped_check(&plain_args(vec![path.clone()], output), scope), - 0, - "clean code must exit 0 ({scope:?}, {output:?})" - ); - } - } - let _ = std::fs::remove_file(&py); - Ok(()) - } - - /// [CHKARCH-CONFIG-MODEL] / [CHKARCH-CLI-EXITCODES]: a config that - /// resolves a pep rule to `disabled` is a configuration error — exit 2, - /// for both commands, before any checking. - #[test] - fn pep_disable_config_returns_two() -> Result<(), Box> { - let dir = unique_project_dir("basilisk_test_rc_pep_disable"); - std::fs::create_dir_all(&dir)?; - std::fs::write( - dir.join("pyproject.toml"), - b"[tool.basilisk.rules]\n\"imports_unresolved\" = \"disabled\"\n", - )?; - let py = dir.join("m.py"); - std::fs::write(&py, b"x: int = 1\n")?; - let path = py.to_string_lossy().into_owned(); - for scope in [DiagnosticScope::Check, DiagnosticScope::Analyze] { - assert_eq!( - run_scoped_check(&plain_args(vec![path.clone()], OutputFormat::Json), scope), - 2, - "pep-disable config must exit 2 ({scope:?})" - ); - } - let _ = std::fs::remove_dir_all(&dir); - Ok(()) - } - - /// Internal error path: nonexistent path must return 3. - #[test] - fn nonexistent_path_returns_three() { - let code = run_scoped_check( - &plain_args(vec!["/no/such/path.py".to_owned()], OutputFormat::Text), - DiagnosticScope::Check, - ); - assert_eq!(code, 3, "nonexistent path must exit 3"); - } - - /// Warnings-only code must return 0 (no errors) in both formats: - /// an inline `# type: warning[...]` demotion of a pep error. - #[test] - fn warnings_only_returns_zero() -> Result<(), Box> { - let dir = std::env::temp_dir(); - let py = dir.join("basilisk_test_rc_warn.py"); - std::fs::write( - &py, - b"import basilisk_no_such_module_xyz # type: warning[imports_unresolved]\n", - )?; - let path = py.to_string_lossy().into_owned(); - for output in [OutputFormat::Text, OutputFormat::Json] { - assert_eq!( - run_scoped_check( - &plain_args(vec![path.clone()], output), - DiagnosticScope::Check - ), - 0, - "warnings-only code must exit 0 ({output:?})" - ); - } - let _ = std::fs::remove_file(&py); - Ok(()) - } - - // ── stubs subcommand ───────────────────────────────────────────────── - // - // The `basilisk stubs` subsystem (run_stubs, cache_stub, - // find_package_source) is exercised in-process here. Driving it directly - // — rather than through a spawned binary — keeps its coverage independent - // of subprocess profile merging, which is unreliable across platforms. - // Implements [STUBRES-AUTOGEN] on the CLI surface. - - /// `find_package_source` returns `None` for a package that cannot be - /// imported (the querying subprocess exits non-zero). - #[test] - fn find_package_source_returns_none_for_unknown_package() { - let result = find_package_source( - "basilisk_definitely_not_installed_pkg", - std::path::Path::new("python3"), - ); - assert!(result.is_none(), "unknown package must resolve to None"); - } - - /// `find_package_source` resolves an installed stdlib **package** to its - /// `__init__.py` — exercising the success path (subprocess ok, dir parse, - /// `__init__.py` exists). `json` is a package in every supported `CPython`. - #[test] - fn find_package_source_resolves_stdlib_package() { - let result = find_package_source("json", std::path::Path::new("python3")); - // Skip silently only if no USABLE interpreter is on PATH; otherwise the - // success branch must resolve `json/__init__.py`. `output().is_ok()` - // alone is not enough: the Windows Store `python3` execution alias - // spawns successfully but only prints an install hint and exits - // non-zero, so the guard must require the interpreter to actually run. - let usable = std::process::Command::new("python3") - .arg("--version") - .output() - .is_ok_and(|out| out.status.success()); - if usable { - assert!( - result.is_some_and(|p| p.ends_with("__init__.py")), - "the `json` stdlib package must resolve to its __init__.py" - ); - } - } - - /// Package names are data, never Python source: a crafted name must not run - /// code, while a valid dotted module must resolve to that module's own file. - #[test] - fn find_package_source_rejects_injection_and_resolves_dotted_module( - ) -> Result<(), Box> { - if !std::process::Command::new("python3") - .arg("--version") - .output() - .is_ok_and(|output| output.status.success()) - { - return Ok(()); + fn only_version_flags_are_handled() { + assert!(handle_version(&["--version".to_owned()])); + for args in [ + vec![], + vec!["check".to_owned()], + vec!["--help".to_owned()], + vec!["--not-a-flag".to_owned()], + ] { + assert!(!handle_version(&args), "must not be handled: {args:?}"); } - - let sentinel = unique_project_dir("basilisk_stub_source_injection").with_extension("txt"); - let _ = std::fs::remove_file(&sentinel); - let sentinel_literal = format!("{:?}", sentinel.to_string_lossy()); - let malicious = format!("os; open({sentinel_literal}, 'w').write('executed') #"); - - assert!( - find_package_source(&malicious, std::path::Path::new("python3")).is_none(), - "an invalid module name must be rejected" - ); - let injection_ran = sentinel.exists(); - let _ = std::fs::remove_file(&sentinel); - assert!( - !injection_ran, - "the package name must never execute as Python code" - ); - - let source = find_package_source("xml.etree.ElementTree", std::path::Path::new("python3")) - .ok_or("dotted stdlib module did not resolve")?; - assert_eq!( - source.file_name(), - Some(std::ffi::OsStr::new("ElementTree.py")), - "a dotted module must resolve to its own source, not the top-level package" - ); - Ok(()) - } - - /// `cache_stub` writes the stub and returns `true` on success. - #[test] - fn cache_stub_writes_and_returns_true() -> Result<(), Box> { - use basilisk_stubs::generate::{GeneratedStub, StubGenMode}; - let dir = unique_project_dir("basilisk_cli_cache_stub_ok"); - std::fs::create_dir_all(&dir)?; - let stub = GeneratedStub { - module_name: "widget".to_owned(), - pyi_content: "def f() -> int: ...\n".to_owned(), - mode: StubGenMode::Hybrid, - }; - let ok = cache_stub(&dir, "widget", &stub); - let _ = std::fs::remove_dir_all(&dir); - assert!(ok, "cache_stub must succeed writing to a writable dir"); - Ok(()) - } - - /// `cache_stub` returns `false` when the cache directory cannot be created - /// because a regular file sits where a parent directory is required. - #[test] - fn cache_stub_returns_false_when_dir_uncreatable() -> Result<(), Box> { - use basilisk_stubs::generate::{GeneratedStub, StubGenMode}; - let base = unique_project_dir("basilisk_cli_cache_stub_fail"); - std::fs::create_dir_all(&base)?; - // A regular file where a directory component is required downstream. - let blocker = base.join("blocker"); - std::fs::write(&blocker, b"not a dir")?; - let stub = GeneratedStub { - module_name: "widget".to_owned(), - pyi_content: "x: int\n".to_owned(), - mode: StubGenMode::Ast, - }; - // cache_dir nested under the regular file → `create_dir_all` must fail. - let ok = cache_stub(&blocker.join("nested"), "widget", &stub); - let _ = std::fs::remove_dir_all(&base); - assert!( - !ok, - "cache_stub must return false when the cache dir is uncreatable" - ); - Ok(()) - } - - /// `run_stubs(Status)` always reports without error (exit 0), whether or - /// not any stubs are cached. Exercises the `Status` dispatch arm. - #[test] - fn run_stubs_status_returns_zero() { - assert_eq!( - run_stubs(StubAction::Status), - 0, - "stubs status must return 0" - ); - } - - /// `run_stubs(Generate { .. })` dispatches to generation; with no packages - /// it returns 1. Exercises the `Generate` dispatch arm end to end. - #[test] - fn run_stubs_generate_dispatch_no_packages_returns_one() { - let action = StubAction::Generate { - packages: Vec::new(), - all: false, - mode: StubGenModeArg::Ast, - python: "python3".to_owned(), - }; - assert_eq!( - run_stubs(action), - 1, - "generate with no packages must return 1" - ); - } - - // ── run_command dispatch ───────────────────────────────────────────── - // - // `run_command` is the parsed-subcommand dispatcher `main` delegates to on - // the analysis stack. Driving each arm in-process — rather than only - // through the spawned binary — keeps the dispatch covered independently - // of subprocess profile merging. The `Lsp` arm is excluded on purpose: it - // blocks on a running server. - - /// A temp project holding one clean, fully-annotated module. Returns the - /// directory (to clean up) and the module's path. - fn clean_project( - prefix: &str, - ) -> Result<(std::path::PathBuf, String), Box> { - let dir = unique_project_dir(prefix); - std::fs::create_dir_all(&dir)?; - // Anchor the project root at `dir` with a `pyproject.toml` marker. - // Without one, `find_project_root` (which recognises only - // `pyproject.toml`/`uv.lock`) walks past the temp dir and falls back - // to the process cwd. - std::fs::write( - dir.join("pyproject.toml"), - b"[project]\nname = \"fixture\"\nversion = \"0.0.0\"\n", - )?; - let py = dir.join("m.py"); - std::fs::write(&py, b"def greet(name: str) -> str:\n return name\n")?; - let path = py.to_string_lossy().into_owned(); - Ok((dir, path)) - } - - /// `run_command(Check)` (text) on clean code returns 0 and applies colour - /// mode; `run_command(Analyze)` mirrors it ([CHKARCH-COMMANDS]). - #[test] - fn run_command_check_and_analyze_text_return_zero() -> Result<(), Box> { - let (dir, py) = clean_project("rc_check_text")?; - let check = run_command(Command::Check { - args: plain_args(vec![py.clone()], OutputFormat::Text), - }); - let analyze = run_command(Command::Analyze { - args: plain_args(vec![py], OutputFormat::Text), - }); - let _ = std::fs::remove_dir_all(&dir); - assert_eq!(check, 0, "clean check (text) must return 0"); - assert_eq!(analyze, 0, "clean analyze (text) must return 0"); - Ok(()) - } - - /// `run_command(Check)` (json) on clean code returns 0. - #[test] - fn run_command_check_json_returns_zero() -> Result<(), Box> { - let (dir, py) = clean_project("rc_check_json")?; - let code = run_command(Command::Check { - args: CheckArgs { - paths: vec![py], - output: OutputFormat::Json, - color: ColorMode::Always, - cache: false, - no_cache: false, - cache_dir: None, - cache_stats: false, - }, - }); - let _ = std::fs::remove_dir_all(&dir); - assert_eq!(code, 0, "clean check (json) must return 0"); - Ok(()) - } - - /// `run_command(Check)` with the opt-in cache + stats exercises the cache - /// context build, the cached check path, and the stats report. - #[test] - fn run_command_check_with_cache_and_stats_returns_zero( - ) -> Result<(), Box> { - let (dir, py) = clean_project("rc_check_cache")?; - let cache_dir = dir.join("cache"); - let code = run_command(Command::Check { - args: CheckArgs { - paths: vec![py], - output: OutputFormat::Text, - color: ColorMode::Auto, - cache: true, - no_cache: false, - cache_dir: Some(cache_dir), - cache_stats: true, - }, - }); - let _ = std::fs::remove_dir_all(&dir); - assert_eq!(code, 0, "cached clean check must return 0"); - Ok(()) - } - - /// `run_command(Fix)` on clean code returns 0 (nothing to fix). - #[test] - fn run_command_fix_returns_zero() -> Result<(), Box> { - let (dir, py) = clean_project("rc_fix")?; - let code = run_command(Command::Fix { - paths: vec![py], - r#unsafe: false, - rules: Vec::new(), - }); - let _ = std::fs::remove_dir_all(&dir); - assert_eq!(code, 0, "fixing clean code must return 0"); - Ok(()) - } - - /// `run_command(Adopt)` and `run_command(Adopt { status })` both succeed on - /// a clean project — exercising both the adopt and status dispatch branch. - /// [AUTOFIX-ADOPTION] - #[test] - fn run_command_adopt_and_status_return_zero() -> Result<(), Box> { - let (dir, py) = clean_project("rc_adopt")?; - let adopt = run_command(Command::Adopt { - paths: vec![py.clone()], - status: false, - }); - let status = run_command(Command::Adopt { - paths: vec![py], - status: true, - }); - let _ = std::fs::remove_dir_all(&dir); - assert_eq!(adopt, 0, "adopting clean code must return 0"); - assert_eq!(status, 0, "adopt --status must return 0"); - Ok(()) - } - - /// `run_command(Unadopt)` on a clean project returns 0. [AUTOFIX-ADOPTION] - #[test] - fn run_command_unadopt_returns_zero() -> Result<(), Box> { - let (dir, py) = clean_project("rc_unadopt")?; - let code = run_command(Command::Unadopt { paths: vec![py] }); - let _ = std::fs::remove_dir_all(&dir); - assert_eq!(code, 0, "unadopt on a clean project must return 0"); - Ok(()) } - /// `run_command(Stubs { Status })` reports without error. + /// Exit `4` is neither success nor "errors found": a build that still calls + /// Basilisk must fail, and must not read the failure as a code finding. + /// The four codes it must not collide with are the checker's own + /// ([CHKARCH-CLI-EXITCODES]). #[test] - fn run_command_stubs_status_returns_zero() { - assert_eq!( - run_command(Command::Stubs { - action: StubAction::Status, - }), - 0, - "stubs status via run_command must return 0" - ); + fn unlisted_exit_code_is_four_and_not_a_diagnostic_code() { + assert_eq!(EXIT_UNLISTED, 4); + let checker_codes = [0_u8, 1, 2, 3]; + assert!(!checker_codes.contains(&EXIT_UNLISTED)); } } diff --git a/crates/basilisk-cli/src/mcp.rs b/crates/basilisk-cli/src/mcp.rs deleted file mode 100644 index 343c25ad5..000000000 --- a/crates/basilisk-cli/src/mcp.rs +++ /dev/null @@ -1,415 +0,0 @@ -//! Implements [MCP-TYPESHED-STATUS]. See -//! docs/specs/CHECKER-MCP-SPEC.md#MCP-TYPESHED-STATUS -//! -//! Minimal Model Context Protocol server over stdio. The transport is kept -//! deliberately small: one read-only tool reports the exact typeshed status -//! produced by the shared acquisition subsystem. JSON-RPC messages are one -//! UTF-8 JSON value per line; stdout is reserved exclusively for responses. - -use std::path::Path; -use std::sync::OnceLock; - -use serde_json::{json, Value}; - -const PROTOCOL_VERSION: &str = "2025-11-25"; -const STATUS_TOOL: &str = "basilisk_typeshed_status"; -const MAX_MESSAGE_BYTES: usize = 1_048_576; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum Lifecycle { - AwaitingInitialize, - AwaitingInitialized, - Ready, -} - -#[derive(Debug)] -enum IncomingLine { - Json(String), - InvalidUtf8, - TooLarge, -} - -/// Run the MCP server on process stdin/stdout for `workspace`. -/// -/// # Errors -/// -/// Returns a descriptive error when the transport cannot be read or written. -/// Acquisition failures are reported as MCP tool errors so the stdio session -/// remains valid for subsequent protocol requests. -pub(crate) fn run(workspace: &Path) -> Result<(), String> { - let input = std::io::stdin(); - let output = std::io::stdout(); - let status = OnceLock::new(); - run_transport(input.lock(), output.lock(), || { - status - .get_or_init(|| status_for_workspace(workspace)) - .clone() - }) -} - -/// Serve requests using injected streams and status provider. -/// -/// The seam makes protocol behavior hermetic while production still consumes -/// the same runtime status object as the CLI and LSP. -fn run_transport(mut reader: R, mut writer: W, status: F) -> Result<(), String> -where - R: std::io::BufRead, - W: std::io::Write, - F: Fn() -> Result, -{ - let mut lifecycle = Lifecycle::AwaitingInitialize; - while let Some(line) = read_line_limited(&mut reader)? { - let response = match line { - IncomingLine::Json(line) => handle_line(&line, &mut lifecycle, &status), - IncomingLine::InvalidUtf8 => Some(error_response( - Value::Null, - -32700, - "MCP message is not valid UTF-8", - )), - IncomingLine::TooLarge => Some(error_response( - Value::Null, - -32600, - "MCP message exceeds 1 MiB", - )), - }; - if let Some(response) = response { - serde_json::to_writer(&mut writer, &response) - .map_err(|error| format!("failed to encode MCP response: {error}"))?; - writer - .write_all(b"\n") - .and_then(|()| writer.flush()) - .map_err(|error| format!("failed to write MCP stdout: {error}"))?; - } - } - Ok(()) -} - -fn read_line_limited(reader: &mut R) -> Result, String> { - let mut bytes = Vec::new(); - let mut too_large = false; - loop { - let available = reader - .fill_buf() - .map_err(|error| format!("failed to read MCP stdin: {error}"))?; - if available.is_empty() { - if bytes.is_empty() && !too_large { - return Ok(None); - } - break; - } - let newline = available.iter().position(|byte| *byte == b'\n'); - let consumed = newline.map_or(available.len(), |position| position + 1); - if !too_large { - // Keep at most one byte beyond the content limit. That byte is - // either the permitted newline or proof that the line is too big. - let remaining = (MAX_MESSAGE_BYTES + 1).saturating_sub(bytes.len()); - let copied = consumed.min(remaining); - let prefix = available - .get(..copied) - .ok_or_else(|| "MCP input prefix exceeded buffered input".to_owned())?; - bytes.extend_from_slice(prefix); - too_large = copied < consumed; - } - reader.consume(consumed); - if newline.is_some() { - break; - } - } - - if bytes.last() == Some(&b'\n') { - let _ = bytes.pop(); - } - if bytes.last() == Some(&b'\r') { - let _ = bytes.pop(); - } - if too_large || bytes.len() > MAX_MESSAGE_BYTES { - return Ok(Some(IncomingLine::TooLarge)); - } - match String::from_utf8(bytes) { - Ok(line) => Ok(Some(IncomingLine::Json(line))), - Err(_) => Ok(Some(IncomingLine::InvalidUtf8)), - } -} - -fn handle_line(line: &str, lifecycle: &mut Lifecycle, status: &F) -> Option -where - F: Fn() -> Result, -{ - let request: Value = match serde_json::from_str(line) { - Ok(value) => value, - Err(error) => { - return Some(error_response( - Value::Null, - -32700, - &format!("invalid JSON: {error}"), - )); - } - }; - handle_message(&request, lifecycle, status) -} - -fn handle_message(request: &Value, lifecycle: &mut Lifecycle, status: &F) -> Option -where - F: Fn() -> Result, -{ - let Some(object) = request.as_object() else { - return Some(error_response( - Value::Null, - -32600, - "request must be an object", - )); - }; - let id = object.get("id").cloned(); - if id - .as_ref() - .is_some_and(|id| !(id.is_string() || id.is_number() || id.is_null())) - { - return Some(error_response(Value::Null, -32600, "invalid request id")); - } - let Some(method) = object.get("method").and_then(Value::as_str) else { - let response_id = id.clone().map_or(Value::Null, std::convert::identity); - return Some(error_response( - response_id, - -32600, - "invalid JSON-RPC request", - )); - }; - if object.get("jsonrpc").and_then(Value::as_str) != Some("2.0") { - let response_id = id.clone().map_or(Value::Null, std::convert::identity); - return Some(error_response( - response_id, - -32600, - "invalid JSON-RPC request", - )); - } - let Some(id) = id else { - handle_notification(method, lifecycle); - return None; - }; - match method { - "initialize" => Some(initialize_response(id, object.get("params"), lifecycle)), - _ if *lifecycle != Lifecycle::Ready => { - Some(error_response(id, -32002, "server is not initialized")) - } - "ping" => Some(success_response(id, json!({}))), - "tools/list" => Some(success_response(id, tools_result())), - "tools/call" => Some(call_tool(id, object.get("params"), status)), - _ => Some(error_response(id, -32601, "method not found")), - } -} - -fn handle_notification(method: &str, lifecycle: &mut Lifecycle) { - if method == "notifications/initialized" && *lifecycle == Lifecycle::AwaitingInitialized { - *lifecycle = Lifecycle::Ready; - } -} - -fn initialize_response(id: Value, params: Option<&Value>, lifecycle: &mut Lifecycle) -> Value { - if *lifecycle != Lifecycle::AwaitingInitialize { - return error_response(id, -32600, "server is already initialized"); - } - let requested = params - .and_then(|params| params.get("protocolVersion")) - .and_then(Value::as_str); - if requested.is_none() { - return error_response(id, -32602, "protocolVersion is required"); - } - // If the client requests an unsupported version, MCP requires the server - // to return a version it does support so the client can decide whether to - // continue or disconnect. - *lifecycle = Lifecycle::AwaitingInitialized; - success_response( - id, - json!({ - "protocolVersion": PROTOCOL_VERSION, - "capabilities": { "tools": { "listChanged": false } }, - "serverInfo": { - "name": "basilisk", - "title": "Basilisk Type Checker", - "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 status warnings." - }), - ) -} - -fn tools_result() -> Value { - json!({ - "tools": [{ - "name": STATUS_TOOL, - "title": "Typeshed source status", - "description": "Return the active typeshed source, exact commit/tree identities, licensing state, and ordered warnings.", - "inputSchema": { - "type": "object", - "additionalProperties": false - }, - "outputSchema": status_schema(), - "annotations": { - "readOnlyHint": true, - "destructiveHint": false, - "idempotentHint": 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" } - }] - }) -} - -fn status_schema() -> Value { - json!({ - "type": "object", - "properties": { - "active_source": { - "type": "string", - "enum": ["custom", "exact-commit", "bundled"] - }, - "commit_identity": { - "anyOf": [ - { "type": "string", "pattern": "^[0-9a-f]{40}$" }, - { "type": "null" } - ] - }, - "tree_identity": { - "anyOf": [ - { "type": "string", "pattern": "^[0-9a-f]{40}$" }, - { "type": "null" } - ] - }, - "license_status": { - "type": "string", - "enum": ["approved", "changed", "not supplied"] - }, - "license_reference": { "type": ["string", "null"] }, - "warnings": { - "type": "array", - "items": { - "type": "object", - "properties": { - "code": { "type": "string" }, - "message": { "type": "string" }, - "docs_url": { "type": "string" } - }, - "required": ["code", "message", "docs_url"], - "additionalProperties": false - } - } - }, - "required": [ - "active_source", "commit_identity", "tree_identity", - "license_status", "license_reference", "warnings" - ], - "additionalProperties": false - }) -} - -fn call_tool(id: Value, params: Option<&Value>, status: &F) -> Value -where - F: Fn() -> Result, -{ - let name = params - .and_then(|params| params.get("name")) - .and_then(Value::as_str); - if name != Some(STATUS_TOOL) || !empty_arguments(params) { - return error_response(id, -32602, "unknown tool or invalid arguments"); - } - match status() { - Ok(document) => match serde_json::to_string(&document) { - Ok(text) => success_response( - id, - json!({ - "content": [{ "type": "text", "text": text }], - "structuredContent": document, - "isError": false - }), - ), - Err(error) => error_response(id, -32603, &format!("status encoding failed: {error}")), - }, - Err(error) => success_response( - id, - json!({ - "content": [{ "type": "text", "text": error }], - "isError": true - }), - ), - } -} - -fn empty_arguments(params: Option<&Value>) -> bool { - params - .and_then(|params| params.get("arguments")) - .is_none_or(|arguments| arguments.as_object().is_some_and(serde_json::Map::is_empty)) -} - -fn success_response(id: Value, result: Value) -> Value { - Value::Object(serde_json::Map::from_iter([ - ("jsonrpc".to_owned(), Value::String("2.0".to_owned())), - ("id".to_owned(), id), - ("result".to_owned(), result), - ])) -} - -fn error_response(id: Value, code: i64, message: &str) -> Value { - let error = serde_json::Map::from_iter([ - ("code".to_owned(), Value::Number(code.into())), - ("message".to_owned(), Value::String(message.to_owned())), - ]); - Value::Object(serde_json::Map::from_iter([ - ("jsonrpc".to_owned(), Value::String("2.0".to_owned())), - ("id".to_owned(), id), - ("error".to_owned(), Value::Object(error)), - ])) -} - -/// Resolve the shared runtime status for the MCP tool. -/// -/// This adapter is intentionally the only acquisition dependency in the MCP -/// transport; CLI/LSP/MCP therefore serialize one status model and preserve -/// its warning order. [STUBRES-TYPESHED-WARN] -fn status_for_workspace(workspace: &Path) -> Result { - let mut config = basilisk_lsp::config::load_analysis_config(workspace); - basilisk_lsp::config::apply_uv_typeshed_override(&mut config, workspace); - let request = basilisk_lsp::config::typeshed_request(&config)?; - 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 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 warnings: Vec = status - .warnings - .iter() - .map(|warning| { - json!({ - "code": warning.code.as_str(), - "message": warning.message.as_str(), - "docs_url": warning.docs_url.as_str(), - }) - }) - .collect(); - let commit = status.commit.map(|identity| identity.to_hex()); - let tree = status.tree.map(|identity| identity.to_hex()); - json!({ - "active_source": status.active_source.as_str(), - "commit_identity": commit.as_deref(), - "tree_identity": tree.as_deref(), - "license_status": license_status, - "license_reference": status.license_reference.as_deref(), - "warnings": warnings - }) -} - -#[cfg(test)] -#[path = "mcp/tests.rs"] -mod tests; diff --git a/crates/basilisk-cli/src/mcp/tests.rs b/crates/basilisk-cli/src/mcp/tests.rs deleted file mode 100644 index 70bb060a1..000000000 --- a/crates/basilisk-cli/src/mcp/tests.rs +++ /dev/null @@ -1,445 +0,0 @@ -use super::*; - -fn status() -> Value { - json!({ - "active_source": "bundled", - "commit_identity": "0123456789012345678901234567890123456789", - "tree_identity": "abcdefabcdefabcdefabcdefabcdefabcdefabcd", - "license_status": "approved", - "license_reference": "typeshed://LICENSE", - "warnings": [ - { "code": "typeshed_source_unpinned", "message": "Pin a commit to make this reproducible", "docs_url": "https://www.basilisk-python.dev/errors/typeshed_source_unpinned" }, - { "code": "typeshed_source_license_changed", "message": "Basilisk update/review required", "docs_url": "https://www.basilisk-python.dev/errors/typeshed_source_license_changed" }, - { "code": "typeshed_source_user_managed", "message": "Folder supplies its own license", "docs_url": "https://www.basilisk-python.dev/errors/typeshed_source_user_managed" } - ] - }) -} - -fn exchange(messages: &[Value]) -> Result, String> { - exchange_with_status(messages, || Ok(status())) -} - -fn exchange_with_status(messages: &[Value], status: F) -> Result, String> -where - F: Fn() -> Result, -{ - let input = messages - .iter() - .map(serde_json::to_string) - .collect::, _>>() - .map_err(|error| error.to_string())? - .join("\n"); - let mut output = Vec::new(); - run_transport(std::io::Cursor::new(input), &mut output, status)?; - 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() -} - -#[test] -fn lifecycle_lists_and_calls_structured_status() -> Result<(), String> { - let responses = exchange(&[ - json!({"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":PROTOCOL_VERSION,"capabilities":{},"clientInfo":{"name":"test","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":STATUS_TOOL,"arguments":{}}}), - ])?; - assert_eq!(responses.len(), 3); - assert_eq!( - responses - .first() - .and_then(|value| value.pointer("/result/protocolVersion")) - .and_then(Value::as_str), - Some(PROTOCOL_VERSION) - ); - assert_eq!( - responses - .get(1) - .and_then(|value| value.pointer("/result/tools/0/name")) - .and_then(Value::as_str), - Some(STATUS_TOOL) - ); - let call = responses - .get(2) - .ok_or_else(|| "tool response missing".to_owned())?; - let document = call - .pointer("/result/structuredContent") - .ok_or_else(|| "structured status missing".to_owned())?; - let text = call - .pointer("/result/content/0/text") - .and_then(Value::as_str) - .ok_or_else(|| "text status missing".to_owned())?; - let text_document: Value = serde_json::from_str(text).map_err(|error| error.to_string())?; - assert_eq!(text_document, *document); - let warnings = document - .get("warnings") - .and_then(Value::as_array) - .ok_or_else(|| "structured warnings missing".to_owned())?; - assert_eq!( - warnings - .first() - .and_then(|warning| warning.get("code")) - .and_then(Value::as_str), - Some("typeshed_source_unpinned") - ); - assert_eq!( - warnings - .first() - .and_then(|warning| warning.get("docs_url")) - .and_then(Value::as_str), - Some("https://www.basilisk-python.dev/errors/typeshed_source_unpinned") - ); - assert_eq!( - warnings - .get(1) - .and_then(|warning| warning.get("code")) - .and_then(Value::as_str), - Some("typeshed_source_license_changed") - ); - assert_eq!( - warnings - .get(2) - .and_then(|warning| warning.get("code")) - .and_then(Value::as_str), - Some("typeshed_source_user_managed") - ); - Ok(()) -} - -#[test] -fn tool_contract_declares_closed_output_and_honest_annotations() { - let result = tools_result(); - assert_eq!( - result - .pointer("/tools/0/inputSchema/additionalProperties") - .and_then(Value::as_bool), - Some(false) - ); - assert_eq!( - result - .pointer("/tools/0/outputSchema/additionalProperties") - .and_then(Value::as_bool), - Some(false) - ); - assert_eq!( - result - .pointer("/tools/0/outputSchema/properties/commit_identity/anyOf/0/pattern") - .and_then(Value::as_str), - Some("^[0-9a-f]{40}$") - ); - assert_eq!( - result - .pointer("/tools/0/outputSchema/properties/active_source/enum/0") - .and_then(Value::as_str), - Some("custom") - ); - assert_eq!( - result - .pointer("/tools/0/outputSchema/properties/license_status/enum/2") - .and_then(Value::as_str), - 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 - .pointer("/tools/0/annotations/readOnlyHint") - .and_then(Value::as_bool), - Some(true) - ); - assert_eq!( - result - .pointer("/tools/0/annotations/openWorldHint") - .and_then(Value::as_bool), - Some(false), - "status resolution is offline by construction [STUBRES-TYPESHED-OFFLINE] — \ - the tool must declare itself closed-world" - ); -} - -#[test] -fn acquisition_failure_is_a_tool_error_without_partial_status() -> Result<(), String> { - let responses = exchange_with_status( - &[ - json!({"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":PROTOCOL_VERSION}}), - json!({"jsonrpc":"2.0","method":"notifications/initialized"}), - json!({"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":STATUS_TOOL,"arguments":{}}}), - ], - || Err("custom typeshed failed without fallback".to_owned()), - )?; - let call = responses - .get(1) - .ok_or_else(|| "tool error response missing".to_owned())?; - assert_eq!( - call.pointer("/result/isError").and_then(Value::as_bool), - Some(true) - ); - assert!(call.pointer("/result/structuredContent").is_none()); - assert!(call.pointer("/error").is_none()); - Ok(()) -} - -#[test] -fn shared_custom_status_projects_to_the_closed_mcp_envelope() { - use basilisk_stubs::typeshed::source::{ - LicenseStatus, SourceKind, StatusWarning, TypeshedStatus, - }; - use basilisk_stubs::typeshed::warning::{TypeshedWarning, UnpinnedKind}; - - let shared = TypeshedStatus { - active_source: SourceKind::Custom, - commit: None, - tree: None, - license_status: LicenseStatus::NotSupplied, - license_reference: None, - warnings: StatusWarning::list(&[ - TypeshedWarning::UserManaged, - TypeshedWarning::Unpinned(UnpinnedKind::CustomFolder), - ]), - }; - let document = status_document(&shared); - assert_eq!( - document.get("active_source").and_then(Value::as_str), - Some("custom") - ); - assert_eq!( - document.get("license_status").and_then(Value::as_str), - Some("not supplied") - ); - 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!( - document.pointer("/warnings/0/code").and_then(Value::as_str), - Some("typeshed_source_unpinned") - ); - assert_eq!( - document - .pointer("/warnings/0/docs_url") - .and_then(Value::as_str), - Some("https://www.basilisk-python.dev/errors/typeshed_source_unpinned") - ); -} - -#[test] -fn shared_oid_type_rejects_truncated_git_identity() { - assert_eq!( - basilisk_stubs::typeshed::gittree::Oid::from_hex("83c2518").ok(), - None, - "a truncated SHA is never a valid object identity" - ); -} - -#[test] -fn malformed_and_pre_initialization_requests_are_protocol_errors() -> Result<(), String> { - let mut lifecycle = Lifecycle::AwaitingInitialize; - let parse = handle_line("not-json", &mut lifecycle, &|| Ok(status())) - .ok_or_else(|| "parse error response missing".to_owned())?; - assert_eq!( - parse.pointer("/error/code").and_then(Value::as_i64), - Some(-32700) - ); - let early = handle_line( - &serde_json::to_string(&json!({"jsonrpc":"2.0","id":"early","method":"tools/list"})) - .map_err(|error| error.to_string())?, - &mut lifecycle, - &|| Ok(status()), - ) - .ok_or_else(|| "initialization error response missing".to_owned())?; - assert_eq!( - early.pointer("/error/code").and_then(Value::as_i64), - Some(-32002) - ); - Ok(()) -} - -#[test] -fn negotiation_and_notification_order_follow_lifecycle() -> Result<(), String> { - let mut lifecycle = Lifecycle::AwaitingInitialize; - let _ = handle_line( - r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#, - &mut lifecycle, - &|| Ok(status()), - ); - assert_eq!(lifecycle, Lifecycle::AwaitingInitialize); - - let response = handle_line( - r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2099-01-01"}}"#, - &mut lifecycle, - &|| Ok(status()), - ) - .ok_or_else(|| "initialize response missing".to_owned())?; - assert_eq!( - response - .pointer("/result/protocolVersion") - .and_then(Value::as_str), - Some(PROTOCOL_VERSION) - ); - assert_eq!(lifecycle, Lifecycle::AwaitingInitialized); - 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]; - input.push(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(-32600) - ); - assert_eq!( - responses - .get(1) - .and_then(|response| response.pointer("/result/protocolVersion")) - .and_then(Value::as_str), - Some(PROTOCOL_VERSION) - ); - Ok(()) -} diff --git a/crates/basilisk-cli/src/output/json.rs b/crates/basilisk-cli/src/output/json.rs deleted file mode 100644 index d795622da..000000000 --- a/crates/basilisk-cli/src/output/json.rs +++ /dev/null @@ -1,120 +0,0 @@ -//! Implements [CHKARCH-CLI]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-CLI -//! Machine-readable JSON output for diagnostics. -//! -//! JSON output is a flat array consumed by the VS Code extension: -//! ```json -//! [ -//! { -//! "code": "BSK-0001", -//! "severity": "error", -//! "message": "Missing parameter type annotation for `x`", -//! "path": "src/utils.py", -//! "line": 1, -//! "col": 9, -//! "end_line": 1, -//! "end_col": 10 -//! } -//! ] -//! ``` -//! -//! Implements [CHKARCH-CLI-OUTPUT-FAILURES]. See -//! docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-CLI-OUTPUT-FAILURES -//! A file the run could not read at all is reported in the same array with a -//! `null` code, because no rule produced it. Leaving it out rendered `[]` — the -//! answer a clean file gets — for a file that was never checked, so every -//! consumer that reads the report rather than the exit status was told a file -//! with a syntax error had no problems. - -use serde::Serialize; - -use basilisk_checker::Diagnostic; - -use super::FileSource; - -/// Serialisable form of a single diagnostic for JSON output. -#[derive(Serialize)] -pub(super) struct JsonDiagnostic<'a> { - /// The diagnostic error/warning code (e.g. `BSK-0001`), or `None` for a - /// file the run could not analyse — no rule ran, so none can be named. - pub(super) code: Option<&'a str>, - /// Severity string: `"error"`, `"warning"`, `"info"`, or `"safety violation"`. - pub(super) severity: &'a str, - /// Human-readable diagnostic message. - pub(super) message: &'a str, - /// Path to the file containing the diagnostic. - pub(super) path: &'a str, - /// 1-based line number of the start of the span. - pub(super) line: usize, - /// 1-based column number of the start of the span. - pub(super) col: usize, - /// 1-based line number of the end of the span. - pub(super) end_line: usize, - /// 1-based column number of the end of the span (exclusive). - pub(super) end_col: usize, -} - -/// A file the run could not analyse at all, rendered alongside the diagnostics. -pub struct JsonFailure<'a> { - /// Path of the file that could not be analysed. - pub path: &'a str, - /// Why it could not be analysed, as the parser or reader reported it. - pub message: &'a str, -} - -/// Render every diagnostic, and every file that failed outright, to stdout. -pub fn render_diagnostics_json( - diagnostics: &[Diagnostic], - sources: &[FileSource], - failures: &[JsonFailure<'_>], -) { - // One line index per source, reused for every diagnostic in that file — the - // span→line/col conversions become O(log n) instead of prefix rescans. - let indexes = super::SourceIndexes::new(sources); - let items: Vec> = diagnostics - .iter() - .map(|d| { - let index = indexes.for_path(&d.path).map(|(_, index)| index); - let (line, col) = index.map_or((1, 1), |index| index.line_col(d.span.start_usize())); - let (end_line, end_col) = - index.map_or((line, col + 1), |index| index.line_col(d.span.end_usize())); - JsonDiagnostic { - code: Some(d.code.code), - severity: match d.severity { - basilisk_checker::Severity::Error => "error", - basilisk_checker::Severity::Warning => "warning", - basilisk_checker::Severity::Info => "info", - basilisk_checker::Severity::SafetyViolation => "safety violation", - }, - message: &d.message, - path: &d.path, - line, - col, - end_line, - end_col, - } - }) - .chain(failures.iter().map(failure_entry)) - .collect(); - - match serde_json::to_string_pretty(&items) { - Ok(json) => println!("{json}"), - Err(e) => eprintln!("basilisk: failed to serialize diagnostics: {e}"), - } -} - -/// One unanalysable file as a JSON entry. -/// -/// The location is the start of the file: the failure is about the file as a -/// whole, and the parser's own message carries whatever position it knows. -pub(super) fn failure_entry<'a>(failure: &'a JsonFailure<'a>) -> JsonDiagnostic<'a> { - JsonDiagnostic { - code: None, - severity: "error", - message: failure.message, - path: failure.path, - line: 1, - col: 1, - end_line: 1, - end_col: 1, - } -} diff --git a/crates/basilisk-cli/src/output/mod.rs b/crates/basilisk-cli/src/output/mod.rs deleted file mode 100644 index 37ce52ca3..000000000 --- a/crates/basilisk-cli/src/output/mod.rs +++ /dev/null @@ -1,1012 +0,0 @@ -//! Diagnostic output rendering — rustc-style text and machine-readable JSON. -//! -//! Text example: -//! ```text -//! error[BSK-0001]: Missing parameter type annotation for `data` -//! --> src/utils.py:14:5 -//! | -//! 14 | def process(data): -//! | ^^^^ parameter `data` has no type annotation -//! | -//! = help: Add a type annotation: `data: ` -//! = note: In Basilisk, all function parameters require explicit types -//! = see: https://www.basilisk-python.dev/errors/BSK-0001 -//! ``` -//! -//! JSON output is a flat array consumed by the VS Code extension: -//! ```json -//! [ -//! { -//! "code": "BSK-0001", -//! "severity": "error", -//! "message": "Missing parameter type annotation for `x`", -//! "path": "src/utils.py", -//! "line": 1, -//! "col": 9, -//! "end_line": 1, -//! "end_col": 10 -//! } -//! ] -//! ``` - -use clap::ValueEnum; - -mod json; -mod text; - -pub use json::{render_diagnostics_json, JsonFailure}; -pub use text::render_diagnostics; - -/// Output format for the `check` subcommand. -/// -/// Implements [CHKARCH-CLI-OUTPUT]: only the `text` (default) and `json` -/// variants exist. The spec also lists `sarif` and `junit`; those are not yet -/// implemented (see report). -/// See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-CLI-OUTPUT -#[derive(Clone, Copy, Debug, ValueEnum)] -pub enum OutputFormat { - /// Human-readable rustc-style text (default). - Text, - /// Machine-readable JSON array consumed by the VS Code extension. - Json, -} - -/// Terminal colour mode. -#[derive(Clone, Copy, Debug, ValueEnum)] -pub enum ColorMode { - /// Detect automatically (colours when stdout is a terminal). - Auto, - /// Always emit ANSI colour codes. - Always, - /// Never emit ANSI colour codes. - Never, -} - -impl ColorMode { - /// Configure the `colored` crate based on the chosen mode. - pub fn apply(self) { - match self { - Self::Auto => {} // `colored` auto-detects by default - Self::Always => colored::control::set_override(true), - Self::Never => colored::control::set_override(false), - } - } -} - -/// Associates a file path with its source text for span-to-line-col mapping. -pub struct FileSource { - /// The file path. - pub path: String, - /// The full source text. - pub text: String, -} - -/// One precomputed [`LineIndex`](basilisk_common::text::LineIndex) per source -/// file, so a batch of diagnostics resolves its byte spans to `(line, col)` in -/// O(log n) instead of rescanning the source from the top for every span. -/// -/// Rendering a file with many diagnostics used to be O(diagnostics · length) — -/// each `--> path:line:col` and each snippet rescanned the whole prefix. Both -/// the text and JSON renderers now build this once and share it. -pub(super) struct SourceIndexes<'a> { - entries: Vec<(&'a FileSource, basilisk_common::text::LineIndex)>, -} - -impl<'a> SourceIndexes<'a> { - /// Build a line index for every source file up front (one O(n) pass each). - pub(super) fn new(sources: &'a [FileSource]) -> Self { - Self { - entries: sources - .iter() - .map(|source| (source, basilisk_common::text::LineIndex::new(&source.text))) - .collect(), - } - } - - /// Source text plus its line index for the file `path` belongs to, if known. - /// - /// Linear scan over the file list, matching the renderers' prior lookup — the - /// win is in the per-span conversion, not this (files-per-run is small). - pub(super) fn for_path(&self, path: &str) -> Option<(&str, &basilisk_common::text::LineIndex)> { - self.entries - .iter() - .find(|(source, _)| source.path == path) - .map(|(source, index)| (source.text.as_str(), index)) - } -} - -#[cfg(test)] -#[expect( - clippy::indexing_slicing, - reason = "test-only code: indexing acceptable in unit tests" -)] -mod tests { - use super::*; - use json::JsonDiagnostic; - use text::{byte_offset_to_line_col, format_one, format_snippet}; - - use basilisk_checker::Diagnostic; - use basilisk_checker::{ErrorCode, Severity}; - use basilisk_resolver::Span; - - /// Format one diagnostic against a fresh line index for `text`. - /// - /// The production renderer builds the index once per file and threads - /// `(text, &LineIndex)` into `format_one`; this wrapper rebuilds it per call - /// so the focused formatting tests stay terse. - fn render_one(diag: &Diagnostic, text: &str) -> String { - let index = basilisk_common::text::LineIndex::new(text); - format_one(diag, Some((text, &index))) - } - - /// Format a snippet for a span against a fresh line index for `text`. - fn render_snippet(text: &str, start: usize, end: usize, severity: Severity) -> String { - let index = basilisk_common::text::LineIndex::new(text); - format_snippet(text, &index, start, end, severity) - } - - // ── ANSI escape sequences produced by the `colored` crate ──────────────── - - /// Bold red (used for error severity labels and underlines). - const BOLD_RED: &str = "\x1b[1;31m"; - /// Bold yellow (used for warning severity labels and underlines). - const BOLD_YELLOW: &str = "\x1b[1;33m"; - /// Bold blue (used for info labels, line numbers, pipes, arrows). - const BOLD_BLUE: &str = "\x1b[1;34m"; - /// Bold cyan (used for help/note/see annotation labels). - const BOLD_CYAN: &str = "\x1b[1;36m"; - /// Bold (used for error codes and messages). - const BOLD: &str = "\x1b[1m"; - /// ANSI reset sequence. - const RESET: &str = "\x1b[0m"; - - /// Force colours on for the duration of a test. - /// - /// `colored` uses a global atomic; we force it to `true` so that - /// `format_one` / `format_snippet` always emit ANSI codes regardless - /// of whether the test runner's stdout is a TTY. - fn force_colors() { - colored::control::set_override(true); - } - - fn make_diag(help: Option<&str>, note: Option<&str>) -> Diagnostic { - make_diag_with_severity(Severity::Error, help, note) - } - - fn make_diag_with_severity( - severity: Severity, - help: Option<&str>, - note: Option<&str>, - ) -> Diagnostic { - Diagnostic { - code: ErrorCode { - code: "BSK-0001", - docs_url: "https://www.basilisk-python.dev/errors/BSK-0001", - }, - severity, - message: "missing annotation for `x`".to_owned(), - span: Span { start: 8, end: 9 }, - path: "test.py".to_owned(), - help: help.map(|value| value.to_owned().into()), - note: note.map(|value| value.to_owned().into()), - provenance: None, - } - } - - #[test] - fn render_diagnostics_counts_only_errors() { - let diag = make_diag(Some("add a type"), Some("all params need types")); - let sources = vec![FileSource { - path: "test.py".to_owned(), - text: "def foo(x): pass".to_owned(), - }]; - let count = render_diagnostics(&[diag], &sources); - assert_eq!(count, 1); - } - - #[test] - fn render_diagnostics_does_not_count_warnings_as_errors() { - let warning = make_diag_with_severity(Severity::Warning, None, None); - let error = make_diag(None, None); - let sources = vec![FileSource { - path: "test.py".to_owned(), - text: "def foo(x): pass".to_owned(), - }]; - let count = render_diagnostics(&[warning, error], &sources); - assert_eq!(count, 1, "only errors should be counted, not warnings"); - } - - // ── format_one: colour assertions ──────────────────────────────────────── - - #[test] - fn format_one_error_header_is_bold_red() { - force_colors(); - let diag = make_diag(Some("help"), Some("note")); - let out = render_one(&diag, "def foo(x): pass"); - assert!( - out.contains(&format!("{BOLD_RED}error{RESET}")), - "error label must be bold red, got:\n{out}" - ); - } - - #[test] - fn format_one_error_code_is_bold() { - force_colors(); - let diag = make_diag(None, None); - let out = render_one(&diag, "def foo(x): pass"); - assert!( - out.contains(&format!("{BOLD}[BSK-0001]{RESET}")), - "error code must be bold, got:\n{out}" - ); - } - - #[test] - fn format_one_message_is_bold() { - force_colors(); - let diag = make_diag(None, None); - let out = render_one(&diag, "def foo(x): pass"); - assert!( - out.contains(&format!("{BOLD}missing annotation for `x`{RESET}")), - "message must be bold, got:\n{out}" - ); - } - - #[test] - fn format_one_arrow_is_bold_blue() { - force_colors(); - let diag = make_diag(None, None); - let out = render_one(&diag, "def foo(x): pass"); - assert!( - out.contains(&format!("{BOLD_BLUE}-->{RESET}")), - "arrow must be bold blue, got:\n{out}" - ); - } - - #[test] - fn format_one_help_label_is_bold_cyan() { - force_colors(); - let diag = make_diag(Some("add a type"), None); - let out = render_one(&diag, "def foo(x): pass"); - assert!( - out.contains(&format!("{BOLD_CYAN}help{RESET}")), - "help label must be bold cyan, got:\n{out}" - ); - } - - #[test] - fn format_one_note_label_is_bold_cyan() { - force_colors(); - let diag = make_diag(None, Some("all params need types")); - let out = render_one(&diag, "def foo(x): pass"); - assert!( - out.contains(&format!("{BOLD_CYAN}note{RESET}")), - "note label must be bold cyan, got:\n{out}" - ); - } - - #[test] - fn format_one_see_label_is_bold_cyan() { - force_colors(); - let diag = make_diag(None, None); - let out = render_one(&diag, "def foo(x): pass"); - assert!( - out.contains(&format!("{BOLD_CYAN}see{RESET}")), - "see label must be bold cyan, got:\n{out}" - ); - } - - #[test] - fn format_one_equals_sign_is_bold_blue() { - force_colors(); - let diag = make_diag(Some("help"), None); - let out = render_one(&diag, "def foo(x): pass"); - assert!( - out.contains(&format!("{BOLD_BLUE}={RESET}")), - "equals sign must be bold blue, got:\n{out}" - ); - } - - #[test] - fn format_one_warning_header_is_bold_yellow() { - force_colors(); - let diag = make_diag_with_severity(Severity::Warning, None, None); - let out = render_one(&diag, "def foo(x): pass"); - assert!( - out.contains(&format!("{BOLD_YELLOW}warning{RESET}")), - "warning label must be bold yellow, got:\n{out}" - ); - } - - #[test] - fn format_one_info_header_is_bold_blue() { - force_colors(); - let diag = make_diag_with_severity(Severity::Info, None, None); - let out = render_one(&diag, "def foo(x): pass"); - assert!( - out.contains(&format!("{BOLD_BLUE}info{RESET}")), - "info label must be bold blue, got:\n{out}" - ); - } - - #[test] - fn format_one_safety_violation_header_is_bold_red() { - force_colors(); - let diag = make_diag_with_severity(Severity::SafetyViolation, None, None); - let out = render_one(&diag, "def foo(x): pass"); - assert!( - out.contains(&format!("{BOLD_RED}safety violation{RESET}")), - "safety violation label must be bold red, got:\n{out}" - ); - } - - #[test] - fn format_one_without_source_falls_back_to_path() { - force_colors(); - let diag = make_diag(Some("help"), Some("note")); - let out = format_one(&diag, None); - assert!( - out.contains("test.py"), - "must fall back to path when source is None" - ); - // No snippet section when source is missing. - assert!( - !out.contains("def foo"), - "must not contain source snippet when source is None" - ); - } - - #[test] - fn format_one_without_help_omits_help_line() { - force_colors(); - let diag = make_diag(None, Some("note text")); - let out = render_one(&diag, "def foo(x): pass"); - assert!( - !out.contains("help"), - "must omit help line when help is None" - ); - assert!(out.contains("note text"), "must include note text"); - } - - #[test] - fn format_one_without_note_omits_note_line() { - force_colors(); - let diag = make_diag(Some("help text"), None); - let out = render_one(&diag, "def foo(x): pass"); - assert!(out.contains("help text"), "must include help text"); - assert!( - !out.contains("note"), - "must omit note line when note is None" - ); - } - - #[test] - fn format_one_without_help_or_note() { - force_colors(); - let diag = make_diag(None, None); - let out = render_one(&diag, "def foo(x): pass"); - assert!(!out.contains("help"), "must omit help when None"); - assert!(!out.contains("note"), "must omit note when None"); - // Still must contain the see URL. - assert!(out.contains("BSK-0001"), "must contain error code"); - assert!(out.contains("basilisk-python.dev"), "must contain docs URL"); - } - - // ── format_snippet: colour assertions ──────────────────────────────────── - - /// Format the standard sample snippet under forced colour mode. - /// All `format_snippet_*_is_*` tests share this fixture. - fn snippet_sample(severity: Severity) -> String { - force_colors(); - render_snippet("def foo(x): pass", 8, 9, severity) - } - - fn assert_contains_colour(out: &str, expected: &str, label: &str) { - assert!( - out.contains(expected), - "{label} must contain {expected:?}, got:\n{out}" - ); - } - - #[test] - fn format_snippet_pipe_is_bold_blue() { - let out = snippet_sample(Severity::Error); - assert_contains_colour(&out, &format!("{BOLD_BLUE}|{RESET}"), "pipe"); - } - - #[test] - fn format_snippet_line_number_is_bold_blue() { - let out = snippet_sample(Severity::Error); - assert_contains_colour(&out, &format!("{BOLD_BLUE}1{RESET}"), "line number"); - } - - #[test] - fn format_snippet_error_underline_is_bold_red() { - let out = snippet_sample(Severity::Error); - assert_contains_colour(&out, &format!("{BOLD_RED}^{RESET}"), "error underline"); - } - - #[test] - fn format_snippet_warning_underline_is_bold_yellow() { - let out = snippet_sample(Severity::Warning); - assert_contains_colour(&out, &format!("{BOLD_YELLOW}^{RESET}"), "warning underline"); - } - - #[test] - fn format_snippet_info_underline_is_bold_blue() { - let out = snippet_sample(Severity::Info); - assert_contains_colour(&out, &format!("{BOLD_BLUE}^{RESET}"), "info underline"); - } - - #[test] - fn format_snippet_safety_violation_underline_is_bold_red() { - let out = snippet_sample(Severity::SafetyViolation); - assert_contains_colour( - &out, - &format!("{BOLD_RED}^{RESET}"), - "safety violation underline", - ); - } - - #[test] - fn format_snippet_multi_char_underline_length() { - force_colors(); - // span covers "foo" at bytes 4..7 → 3 carets - let out = render_snippet("def foo(x): pass", 4, 7, Severity::Error); - assert_contains_colour(&out, &format!("{BOLD_RED}^^^{RESET}"), "3-caret underline"); - } - - #[test] - fn format_snippet_contains_source_line() { - force_colors(); - let out = render_snippet("def foo(x): pass", 8, 9, Severity::Error); - assert!( - out.contains("def foo(x): pass"), - "snippet must contain the source line, got:\n{out}" - ); - } - - #[test] - fn format_snippet_on_second_line() { - force_colors(); - let source = "def foo(): pass\ndef bar(x): pass"; - let out = render_snippet(source, 20, 23, Severity::Error); - // Line 2 contains "def bar(x): pass", line number should be 2. - assert!( - out.contains(&format!("{BOLD_BLUE}2{RESET}")), - "second-line snippet must show line number 2, got:\n{out}" - ); - assert!( - out.contains("def bar(x): pass"), - "must contain second source line, got:\n{out}" - ); - } - - // ── format_one: structural content assertions ──────────────────────────── - - #[test] - fn format_one_contains_file_location() { - force_colors(); - let diag = make_diag(None, None); - let out = render_one(&diag, "def foo(x): pass"); - assert!( - out.contains("test.py:1:9"), - "must contain file:line:col location, got:\n{out}" - ); - } - - #[test] - fn format_one_contains_source_snippet() { - force_colors(); - let diag = make_diag(None, None); - let out = render_one(&diag, "def foo(x): pass"); - assert!( - out.contains("def foo(x): pass"), - "must contain source snippet, got:\n{out}" - ); - } - - #[test] - fn format_one_contains_docs_url() { - force_colors(); - let diag = make_diag(None, None); - let out = render_one(&diag, "def foo(x): pass"); - assert!( - out.contains("https://www.basilisk-python.dev/errors/BSK-0001"), - "must contain docs URL, got:\n{out}" - ); - } - - // ── byte_offset_to_line_col ────────────────────────────────────────────── - - #[test] - fn byte_offset_to_line_col_second_line() { - let source = "def foo(): pass\ndef bar(x): pass"; - // byte 16 is the 'd' starting "def bar" - let (line, col) = byte_offset_to_line_col(source, 16); - assert_eq!(line, 2); - assert_eq!(col, 1); - } - - // ── JSON output ─────────────────────────────────────────────────────────── - - #[test] - fn json_produces_valid_array_with_correct_fields() -> Result<(), Box> { - let source = "def foo(x): pass"; - // byte 8 = 'x', so line=1, col=9 (1-based) - let (line, col) = byte_offset_to_line_col(source, 8); - let (end_line, end_col) = byte_offset_to_line_col(source, 9); - let item = JsonDiagnostic { - code: Some("BSK-0001"), - severity: "error", - message: "missing annotation for `x`", - path: "test.py", - line, - col, - end_line, - end_col, - }; - let json = serde_json::to_string(&item)?; - assert!(json.contains("BSK-0001")); - assert!(json.contains("\"line\":1")); - assert!(json.contains("\"col\":9")); - assert!(json.contains("\"end_line\":1")); - assert!(json.contains("\"end_col\":10")); - Ok(()) - } - - #[test] - fn json_empty_diagnostics_produces_empty_array() -> Result<(), Box> { - let items: Vec> = vec![]; - let json = serde_json::to_string(&items)?; - assert_eq!(json, "[]"); - Ok(()) - } - - #[test] - fn render_diagnostics_json_smoke_test() { - let diag = make_diag(None, None); - let sources = vec![FileSource { - path: "test.py".to_owned(), - text: "def foo(x): pass".to_owned(), - }]; - // Just verify it doesn't panic. - render_diagnostics_json(&[diag], &sources, &[]); - } - - #[test] - fn render_diagnostics_json_empty_is_safe() { - render_diagnostics_json(&[], &[], &[]); - } - - #[test] - fn render_diagnostics_json_warning_severity() { - let diag = make_diag_with_severity(Severity::Warning, None, None); - let sources = vec![FileSource { - path: "test.py".to_owned(), - text: "def foo(x): pass".to_owned(), - }]; - render_diagnostics_json(&[diag], &sources, &[]); - } - - #[test] - fn render_diagnostics_json_info_severity() { - let diag = make_diag_with_severity(Severity::Info, None, None); - let sources = vec![FileSource { - path: "test.py".to_owned(), - text: "def foo(x): pass".to_owned(), - }]; - render_diagnostics_json(&[diag], &sources, &[]); - } - - #[test] - fn render_diagnostics_json_safety_violation_severity() { - let diag = make_diag_with_severity(Severity::SafetyViolation, None, None); - let sources = vec![FileSource { - path: "test.py".to_owned(), - text: "def foo(x): pass".to_owned(), - }]; - render_diagnostics_json(&[diag], &sources, &[]); - } - - #[test] - fn json_severity_warning() -> Result<(), Box> { - let source = "def foo(x): pass"; - let (line, col) = byte_offset_to_line_col(source, 8); - let (end_line, end_col) = byte_offset_to_line_col(source, 9); - let item = JsonDiagnostic { - code: Some("BSK-0001"), - severity: "warning", - message: "test warning", - path: "test.py", - line, - col, - end_line, - end_col, - }; - let json = serde_json::to_string(&item)?; - assert!(json.contains("\"warning\"")); - Ok(()) - } - - /// A file the run could not analyse is serialised with an explicit `null` - /// code. It must not be omitted — a consumer that reads the key's absence - /// as "no entry here" would drop the only report an unparseable file gets. - #[test] - fn json_failure_entry_serialises_an_explicit_null_code( - ) -> Result<(), Box> { - let item = JsonDiagnostic { - code: None, - severity: "error", - message: "syntax error: Expected `:`, found newline", - path: "broken.py", - line: 1, - col: 1, - end_line: 1, - end_col: 1, - }; - let json = serde_json::to_string(&item)?; - assert!( - json.contains("\"code\":null"), - "the code key must be present and null: {json}" - ); - assert!( - !json.contains("BSK-"), - "no rule ran, so no code may be claimed: {json}" - ); - assert!( - json.contains("\"severity\":\"error\""), - "a failed file is an error: {json}" - ); - assert!( - json.contains("broken.py"), - "the entry must name the file that failed: {json}" - ); - assert!( - json.contains("syntax error"), - "the entry must say why it failed: {json}" - ); - Ok(()) - } - - /// The failure entries are appended to the diagnostics, not substituted for - /// them: a run that both found problems and failed a file reports both. - #[test] - fn render_diagnostics_json_appends_failures_after_diagnostics() { - let diagnostic = Diagnostic { - code: ErrorCode { - code: "BSK-0001", - docs_url: "https://www.basilisk-python.dev/errors/BSK-0001", - }, - severity: Severity::Error, - message: "missing annotation".to_owned(), - span: Span { start: 0, end: 3 }, - path: "ok.py".to_owned(), - help: None, - note: None, - provenance: None, - }; - let sources = vec![FileSource { - path: "ok.py".to_owned(), - text: "def foo(x): pass".to_owned(), - }]; - let failures = vec![JsonFailure { - path: "broken.py", - message: "syntax error: Expected `:`, found newline", - }]; - // Renders to stdout, so what it wrote cannot be read back here; the - // call proves the mixed report does not panic, and the entry the - // failure half contributes is asserted field by field below. - render_diagnostics_json(&[diagnostic], &sources, &failures); - let entry = json::failure_entry(&failures[0]); - assert_eq!(entry.code, None, "no rule ran, so the entry claims no code"); - assert_eq!( - entry.severity, "error", - "a file that could not be read is an error" - ); - assert_eq!( - entry.path, "broken.py", - "the entry names the file that failed" - ); - assert_eq!( - entry.message, "syntax error: Expected `:`, found newline", - "the parser's own message is carried through verbatim", - ); - assert_eq!(entry.line, 1, "the failure anchors at the first line"); - assert_eq!(entry.col, 1, "the failure anchors at the first column"); - assert_eq!( - entry.end_line, 1, - "the failure spans no further than its anchor" - ); - assert_eq!( - entry.end_col, 1, - "the failure spans no further than its anchor" - ); - } - - // ── render_diagnostics_json: FnValue→() mutant at output.rs:87 ────────── - - /// `render_diagnostics_json` — `FnValue → ()` at line 87. - /// The function must actually produce output for non-empty diagnostics. - /// We verify by checking the JSON serialisation round-trips correctly. - #[test] - fn render_diagnostics_json_produces_correct_item_count() { - let d1 = Diagnostic { - code: ErrorCode { - code: "BSK-0001", - docs_url: "https://www.basilisk-python.dev/errors/BSK-0001", - }, - severity: Severity::Error, - message: "missing annotation".to_owned(), - span: Span { start: 0, end: 3 }, - path: "a.py".to_owned(), - help: None, - note: None, - provenance: None, - }; - let d2 = Diagnostic { - code: ErrorCode { - code: "BSK-0002", - docs_url: "https://www.basilisk-python.dev/errors/BSK-0002", - }, - severity: Severity::Error, - message: "missing return annotation".to_owned(), - span: Span { start: 4, end: 7 }, - path: "a.py".to_owned(), - help: None, - note: None, - provenance: None, - }; - let sources = [FileSource { - path: "a.py".to_owned(), - text: "def foo(x): pass".to_owned(), - }]; - // Can't easily capture stdout, but verify items array construction is correct - // by constructing directly. - let items: Vec> = [&d1, &d2] - .iter() - .map(|d| { - let source = sources - .iter() - .find(|s| s.path == d.path) - .map(|s| s.text.as_str()); - let (line, col) = source.map_or((1, 1), |src| { - byte_offset_to_line_col(src, usize::try_from(d.span.start).unwrap_or(0)) - }); - let (end_line, end_col) = source.map_or((line, col + 1), |src| { - byte_offset_to_line_col(src, usize::try_from(d.span.end).unwrap_or(0)) - }); - JsonDiagnostic { - code: Some(d.code.code), - severity: "error", - message: &d.message, - path: &d.path, - line, - col, - end_line, - end_col, - } - }) - .collect(); - assert_eq!(items.len(), 2, "must produce one item per diagnostic"); - assert_eq!(items[0].code, Some("BSK-0001")); - assert_eq!(items[1].code, Some("BSK-0002")); - } - - // ── render_diagnostics_json: != mutant at output.rs:92 ────────────────── - - /// `!=` mutant at line 92: `sources.iter().find(|s| s.path == d.path)`. - /// If `==` becomes `!=`, wrong source is matched → wrong line/col. - /// Test that the right source file is used for offset resolution. - #[test] - fn render_diagnostics_json_matches_correct_source_file() { - let diag = Diagnostic { - code: ErrorCode { - code: "BSK-0001", - docs_url: "https://www.basilisk-python.dev/errors/BSK-0001", - }, - severity: Severity::Error, - message: "test".to_owned(), - span: Span { start: 0, end: 1 }, - path: "b.py".to_owned(), - help: None, - note: None, - provenance: None, - }; - let sources = [ - FileSource { - path: "a.py".to_owned(), - text: "aaaa\nbbbb".to_owned(), - }, - FileSource { - path: "b.py".to_owned(), - text: "x = 1\n".to_owned(), - }, - ]; - let source = sources - .iter() - .find(|s| s.path == diag.path) - .map(|s| s.text.as_str()); - let (line, col) = source.map_or((1, 1), |src| byte_offset_to_line_col(src, 0)); - // b.py offset 0 → line 1, col 1 - assert_eq!(line, 1); - assert_eq!(col, 1); - } - - // ── render_diagnostics_json: - / * mutants at output.rs:97 ────────────── - - /// `BinaryOperator` `-`/`*` mutants at line 97 in `render_diagnostics_json`. - /// Line 97 computes end position: `byte_offset_to_line_col(src, d.span.end as usize)`. - /// We verify `end_col` > col for a span that crosses characters. - #[test] - fn render_diagnostics_json_end_position_after_start() { - let source = "def foo(x): pass"; - // span covers "foo" at bytes 4..7 - let (start_line, start_col) = byte_offset_to_line_col(source, 4); - let (end_line, end_col) = byte_offset_to_line_col(source, 7); - assert_eq!(start_line, 1); - assert_eq!(end_line, 1); - assert!(end_col > start_col, "end_col must be after start_col"); - } - - // ── byte_offset_to_line_col: - → / mutant at output.rs:158 ──────────── - - /// The column formula is: `(clamped - pos - 1) + 1` where pos is the last '\n'. - /// `/` mutant replaces `-` with `/` in `clamped - pos - 1`. - /// e.g. with clamped=8, pos=5: correct = 8-5-1+1 = 3; mutant = 8/5-1+1 = 1+1 = 2 (wrong). - /// Assert the exact column value to kill this mutant. - #[test] - fn byte_offset_to_line_col_column_arithmetic_exact() { - // "hello\nworld" — "world" starts at byte 6 - // At byte 8 ('r'): line=2, col=3 (1-based: w=1, o=2, r=3) - let source = "hello\nworld"; - let (line, col) = byte_offset_to_line_col(source, 8); - assert_eq!(line, 2, "byte 8 must be line 2"); - assert_eq!(col, 3, "byte 8 ('r') must be col 3"); - } - - /// Further column test: first char of second line must be col 1. - #[test] - fn byte_offset_to_line_col_first_char_of_second_line() { - let source = "hello\nworld"; - // byte 6 is 'w' — first char of line 2 - let (line, col) = byte_offset_to_line_col(source, 6); - assert_eq!(line, 2); - assert_eq!(col, 1, "first char of line must be col 1"); - } - - /// Multi-line: byte 12 is 'l' in "line3" (3rd line, 1st char). - #[test] - fn byte_offset_to_line_col_multi_line_correct() { - let source = "line1\nline2\nline3"; - // "line3" starts at byte 12 - let (line, col) = byte_offset_to_line_col(source, 12); - assert_eq!(line, 3, "byte 12 must be line 3"); - assert_eq!(col, 1, "byte 12 must be col 1"); - } - - /// Last char of first line (just before '\n'). - #[test] - fn byte_offset_to_line_col_last_char_first_line() { - // "hello\nworld": byte 4 is 'o' (5th char of first line) - let source = "hello\nworld"; - let (line, col) = byte_offset_to_line_col(source, 4); - assert_eq!(line, 1); - assert_eq!(col, 5, "byte 4 ('o') must be col 5"); - } - - /// Offset past end is clamped — doesn't panic. - #[test] - fn byte_offset_to_line_col_offset_beyond_end() { - let source = "abc"; - let (line, col) = byte_offset_to_line_col(source, 9999); - assert_eq!(line, 1); - assert_eq!(col, 4, "clamped to len=3, col=4 (1-based after last char)"); - } - - // ── format_snippet: structural / mutant tests ──────────────────────────── - - /// `BinaryOperator` `-`/`*` mutants at `line_start = rfind('\n').map_or(0, |p| p + 1)`. - /// The `+ 1` skips the newline byte. Without it, `line_start` points at '\n' itself. - #[test] - fn format_snippet_line_start_skips_newline() { - force_colors(); - let source = "hello\nworld"; - let out = render_snippet(source, 8, 10, Severity::Error); - // Must contain "world" (the source line), not "\nworld". - assert!( - out.contains("world"), - "snippet must contain source line, got:\n{out}" - ); - // Underline must be 2 carets for a 2-byte span. - assert!( - out.contains(&format!("{BOLD_RED}^^{RESET}")), - "underline must be 2 carets (bold red), got:\n{out}" - ); - } - - /// `col_start = start - line_start`. If this becomes `start + line_start`, - /// `col_start` would be huge and the underline position would be wrong. - #[test] - fn format_snippet_col_start_no_overflow() { - force_colors(); - let source = "abcdef\nghijkl"; - let out = render_snippet(source, 9, 12, Severity::Error); - // Must contain the source line. - assert!(out.contains("ghijkl"), "must contain source line"); - // Underline must be 3 carets for "ijk". - assert!( - out.contains(&format!("{BOLD_RED}^^^{RESET}")), - "underline must be 3 carets, got:\n{out}" - ); - } - - /// `col_end = (end - line_start).min(len)`. The span 12..14 extends past - /// the end of "ghijkl" (6 chars at `line_start=7`), so `col_end` is clamped - /// to line length. `underline_len` = 6 - 5 = 1. - #[test] - fn format_snippet_col_end_no_overflow() { - force_colors(); - let source = "abcdef\nghijkl"; - let out = render_snippet(source, 12, 14, Severity::Error); - assert!(out.contains("ghijkl"), "must contain source line"); - assert!( - out.contains(&format!("{BOLD_RED}^{RESET}")), - "underline must be clamped to 1 caret, got:\n{out}" - ); - } - - /// Strip ANSI escape sequences (`ESC [ ... m`) so column positions can be - /// measured on the plain text. - fn strip_ansi(text: &str) -> String { - let mut out = String::new(); - let mut chars = text.chars(); - while let Some(c) = chars.next() { - if c == '\x1b' { - for next in chars.by_ref() { - if next == 'm' { - break; - } - } - } else { - out.push(c); - } - } - out - } - - /// Issue #279: every gutter row must place its `|` in the same column as - /// the source row's `|`, otherwise the caret underline renders shifted - /// relative to the source text. Span 8..9 covers `x`, so the caret must - /// sit exactly beneath it. - #[test] - fn format_snippet_gutter_pipes_align_with_source_row() { - force_colors(); - let out = strip_ansi(&render_snippet("def foo(x): pass", 8, 9, Severity::Error)); - assert_eq!( - out, " |\n1 | def foo(x): pass\n | ^\n |\n", - "gutter rows must align with the source row (issue #279)" - ); - } - - /// Verify `format_snippet` produces correct underline length. - #[test] - fn format_snippet_arithmetic_properties() { - let source = "hello world\n"; - // span covers "world" at bytes 6..11 - // line_start = 0 (no newline before), col_start = 6, col_end = 11, underline = 5 - let start = 6usize; - let end = 11usize; - let line_start = source[..start].rfind('\n').map_or(0, |p| p + 1); - let line_text = source[line_start..].lines().next().unwrap_or(""); - let col_start = start - line_start; - let col_end = (end - line_start).min(line_text.len()); - let underline_len = col_end.saturating_sub(col_start).max(1); - assert_eq!(line_start, 0); - assert_eq!(col_start, 6); - assert_eq!(col_end, 11); - assert_eq!(underline_len, 5, "underline for 'world' must be 5 chars"); - } - - // ColorMode::Never / Always are tested via subprocess in cli_binary_tests.rs - // because `colored` uses a global atomic that races with parallel unit tests. -} diff --git a/crates/basilisk-cli/src/output/text.rs b/crates/basilisk-cli/src/output/text.rs deleted file mode 100644 index 11b0d6df5..000000000 --- a/crates/basilisk-cli/src/output/text.rs +++ /dev/null @@ -1,277 +0,0 @@ -//! Rustc-style text rendering for diagnostics with terminal colours. -//! -//! Example output (without ANSI codes): -//! ```text -//! error[BSK-0001]: Missing parameter type annotation for `data` -//! --> src/utils.py:14:5 -//! | -//! 14 | def process(data): -//! | ^^^^ parameter `data` has no type annotation -//! | -//! = help: Add a type annotation: `data: ` -//! = note: In Basilisk, all function parameters require explicit types -//! = see: https://www.basilisk-python.dev/errors/BSK-0001 -//! ``` - -use std::fmt::Write as _; - -use basilisk_checker::{Diagnostic, Severity}; -use colored::Colorize as _; - -use super::FileSource; - -/// Render all diagnostics to stdout in rustc style. -/// -/// Returns the count of error-severity diagnostics. -pub fn render_diagnostics(diagnostics: &[Diagnostic], sources: &[FileSource]) -> usize { - use std::io::Write; - - // Precompute one line index per source; every diagnostic then converts its - // span to line/col in O(log n) instead of rescanning the source prefix. - let indexes = super::SourceIndexes::new(sources); - let colorize = colored::control::SHOULD_COLORIZE.should_colorize(); - let count = diagnostics - .iter() - .filter(|diagnostic| diagnostic.severity == Severity::Error) - .count(); - - let mut stdout = std::io::stdout().lock(); - if colorize { - // Keep terminal output incremental so a large project starts showing - // useful diagnostics immediately. - let mut out = std::io::BufWriter::new(stdout); - for diagnostic in diagnostics { - let rendered = format_one(diagnostic, indexes.for_path(&diagnostic.path)); - let _ = out.write_all(rendered.as_bytes()); - } - let _ = out.flush(); - } else { - // Pipes, CI, editors, and benchmark runs are the overwhelmingly common - // high-volume path. Build their plain render once and write it in one - // operation instead of feeding the 8 KiB BufWriter once per diagnostic. - // Cap only the initial reservation; String can still grow for genuinely - // large output without an attacker-controlled eager allocation. - let initial_capacity = diagnostics.len().saturating_mul(384).min(8 * 1024 * 1024); - let mut rendered = String::with_capacity(initial_capacity); - for diagnostic in diagnostics { - format_one_plain_into( - &mut rendered, - diagnostic, - indexes.for_path(&diagnostic.path), - ); - } - let _ = stdout.write_all(rendered.as_bytes()); - let _ = stdout.flush(); - } - - count -} - -/// Format directly into a reusable buffer when ANSI colour is disabled. -/// -/// CLI output is normally piped in editor, CI, and benchmark use. Avoiding the -/// temporary coloured strings and per-diagnostic output allocation keeps that -/// common path proportional to bytes written, even for error-dense files. -fn format_one_plain_into( - out: &mut String, - diag: &Diagnostic, - source: Option<(&str, &basilisk_common::text::LineIndex)>, -) { - let _ = writeln!( - out, - "{}[{}]: {}", - diag.severity, diag.code.code, diag.message - ); - - if let Some((_, index)) = source { - let (line, col) = index.line_col(diag.span.start_usize()); - let _ = writeln!(out, " --> {}:{line}:{col}", diag.path); - } else { - let _ = writeln!(out, " --> {}", diag.path); - } - - if let Some((src, index)) = source { - format_snippet_plain_into( - out, - src, - index, - diag.span.start_usize(), - diag.span.end_usize(), - ); - } - - if let Some(help) = &diag.help { - let _ = writeln!(out, " = help: {help}"); - } - if let Some(note) = &diag.note { - let _ = writeln!(out, " = note: {note}"); - } - let _ = writeln!(out, " = see: {}\n", diag.code.docs_url); -} - -fn format_snippet_plain_into( - out: &mut String, - source: &str, - index: &basilisk_common::text::LineIndex, - start: usize, - end: usize, -) { - let line_num = index.line(start); - let line_start = index.line_start(start); - let line_text = source - .get(line_start..) - .and_then(|tail| tail.lines().next()) - .unwrap_or(""); - let col_start = start - line_start; - let col_end = (end - line_start).min(line_text.len()); - let underline_len = col_end.saturating_sub(col_start).max(1); - let line_num_width = decimal_width(line_num); - - push_repeated(out, ' ', line_num_width); - out.push_str(" |\n"); - let _ = writeln!(out, "{line_num} | {line_text}"); - push_repeated(out, ' ', line_num_width); - out.push_str(" | "); - push_repeated(out, ' ', col_start); - push_repeated(out, '^', underline_len); - out.push('\n'); - push_repeated(out, ' ', line_num_width); - out.push_str(" |\n"); -} - -fn push_repeated(out: &mut String, character: char, count: usize) { - out.extend(std::iter::repeat_n(character, count)); -} - -fn decimal_width(mut value: usize) -> usize { - let mut width = 1; - while value >= 10 { - value /= 10; - width += 1; - } - width -} - -/// Apply the appropriate colour to a severity label. -fn color_severity(severity: Severity, text: &str) -> String { - match severity { - Severity::Error | Severity::SafetyViolation => text.red().bold().to_string(), - Severity::Warning => text.yellow().bold().to_string(), - Severity::Info => text.blue().bold().to_string(), - } -} - -/// Format a single diagnostic as a rustc-style string with ANSI colours. -/// -/// Implements [CHKARCH-DIAGEXP-QUALITY]: emits the rustc-standard layout — -/// `severity[CODE]: message`, `--> path:line:col`, source snippet with caret -/// underline, then `= help:` / `= note:` / `= see:` annotation lines. -/// See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAGEXP-QUALITY -pub(super) fn format_one( - diag: &Diagnostic, - source: Option<(&str, &basilisk_common::text::LineIndex)>, -) -> String { - let mut out = String::new(); - - // Header: error[BSK-0001]: Message - let severity_label = color_severity(diag.severity, &format!("{}", diag.severity)); - let code = format!("[{}]", diag.code.code).bold(); - let message = diag.message.bold(); - let _ = writeln!(out, "{severity_label}{code}: {message}"); - - // Location: --> path:line:col - let location = source.map_or_else( - || diag.path.clone(), - |(_, index)| { - let (line, col) = index.line_col(diag.span.start_usize()); - format!("{}:{}:{}", diag.path, line, col) - }, - ); - - let _ = writeln!(out, " {} {location}", "-->".blue().bold()); - - // Source snippet with underline - if let Some((src, index)) = source { - out.push_str(&format_snippet( - src, - index, - diag.span.start_usize(), - diag.span.end_usize(), - diag.severity, - )); - } - - // Annotations - if let Some(help) = &diag.help { - let _ = writeln!( - out, - " {} {}: {help}", - "=".blue().bold(), - "help".cyan().bold(), - ); - } - if let Some(note) = &diag.note { - let _ = writeln!( - out, - " {} {}: {note}", - "=".blue().bold(), - "note".cyan().bold(), - ); - } - let _ = writeln!( - out, - " {} {}: {}", - "=".blue().bold(), - "see".cyan().bold(), - diag.code.docs_url, - ); - out.push('\n'); - out -} - -/// Convert a byte offset into (1-based line number, 1-based column number). -/// -/// Production rendering builds a [`LineIndex`](basilisk_common::text::LineIndex) -/// once per file and calls its `line_col`; this single-shot wrapper remains only -/// for the focused line/col unit tests below. -#[cfg(test)] -pub(super) fn byte_offset_to_line_col(source: &str, offset: usize) -> (usize, usize) { - basilisk_common::text::line_col(source, offset) -} - -/// Format a source line with a `^^^^` underline for the highlighted span. -pub(super) fn format_snippet( - source: &str, - index: &basilisk_common::text::LineIndex, - start: usize, - end: usize, - severity: Severity, -) -> String { - let line_num = index.line(start); - let line_start = index.line_start(start); - let line_text = source - .get(line_start..) - .and_then(|tail| tail.lines().next()) - .unwrap_or(""); - - let col_start = start - line_start; - let col_end = (end - line_start).min(line_text.len()); - let underline_len = col_end.saturating_sub(col_start).max(1); - - let line_num_width = line_num.to_string().len(); - let pad = " ".repeat(line_num_width); - let pipe = "|".blue().bold(); - let line_num_str = line_num.to_string().blue().bold(); - let underline = color_severity(severity, &"^".repeat(underline_len)); - - let mut out = String::new(); - let _ = writeln!(out, "{pad} {pipe}"); - let _ = writeln!(out, "{line_num_str} {pipe} {line_text}"); - let _ = writeln!( - out, - "{pad} {pipe} {spaces}{underline}", - spaces = " ".repeat(col_start), - ); - let _ = writeln!(out, "{pad} {pipe}"); - out -} diff --git a/crates/basilisk-cli/src/pipeline/mod.rs b/crates/basilisk-cli/src/pipeline/mod.rs deleted file mode 100644 index 30e357c1c..000000000 --- a/crates/basilisk-cli/src/pipeline/mod.rs +++ /dev/null @@ -1,587 +0,0 @@ -//! Implements [CHKARCH-CLI] and [CHKARCH-COMMANDS]. See -//! docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-CLI -//! -//! The shared collect-and-check pipeline behind `basilisk check` and -//! `basilisk analyze`. Both commands run the identical pipeline — file -//! collection, per-directory config discovery, import resolution, caching, -//! `check_with_config` — and differ only in the [`DiagnosticScope`] edge -//! filter applied to the resulting diagnostics ([CHKARCH-COMMANDS]). - -use std::collections::HashSet; - -use tracing::{info, warn}; - -use crate::cache_check; -use crate::output::FileSource; - -mod typeshed; - -pub(crate) use typeshed::build_import_search_paths; -use typeshed::{ - activate_production_typeshed, build_import_search_paths_with_config, load_cli_workspace_config, -}; - -/// Which command's diagnostics to keep at the CLI edge. -/// -/// Implements [CHKARCH-COMMANDS]: one rule universe, partitioned exactly once -/// by provenance tag. A rule is check-scope iff it carries the `pep` tag; -/// everything else is analyze-scope. The checker runs all selected rules; the -/// CLI edge filters by command. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(crate) enum DiagnosticScope { - /// `basilisk check` — `pep`-tagged rules only, always on. - Check, - /// `basilisk analyze` — every rule *not* tagged `pep`, config-selected. - Analyze, - /// Both scopes — `adopt` reads the union at resolved severities - /// ([AUTOFIX-ADOPTION]). - Union, -} - -impl DiagnosticScope { - /// Whether a diagnostic with `code` belongs to this scope. - pub(crate) fn retains(self, code: &str) -> bool { - match self { - Self::Check => basilisk_checker::is_pep_rule(code), - Self::Analyze => !basilisk_checker::is_pep_rule(code), - Self::Union => true, - } - } -} - -/// A pipeline failure, mapped to the [CHKARCH-CLI-EXITCODES] contract. -#[derive(Debug)] -pub(crate) enum PipelineError { - /// Invalid configuration (exit code `2`) — e.g. a config that resolves a - /// `pep` rule to `disabled` ([CHKARCH-CONFIG-MODEL]). - Config(String), - /// A terminal typeshed source failure (exit code `3`) — the configured - /// source is not on this machine or failed verification. The message is - /// the spec's `NO SOURCE` status line with its recovery command, so it - /// reads as a user-actionable failure, not a Basilisk bug - /// ([STUBRES-TYPESHED-OFFLINE]). - NoSource(String), - /// Internal failure (exit code `3`). - Internal(String), -} - -impl std::fmt::Display for PipelineError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::Config(message) => write!(f, "invalid configuration: {message}"), - // Both already carry a fully-formed message: `NoSource` the spec's - // `NO SOURCE` status line with its recovery command, `Internal` the - // underlying failure. Neither takes a prefix. - Self::NoSource(message) | Self::Internal(message) => write!(f, "{message}"), - } - } -} - -#[derive(Debug)] -pub(crate) struct FileAnalysisFailure { - pub(crate) path: String, - pub(crate) message: String, -} - -pub(crate) struct CheckOutcome { - pub(crate) diagnostics: Vec, - pub(crate) sources: Vec, - pub(crate) failures: Vec, - /// How many rules configuration selected that this scope never evaluated - /// — always `0` outside [`DiagnosticScope::Check`] - /// ([CHKARCH-CLI-SCOPE-NOTICE]). - pub(crate) unrun_selected_rules: usize, -} - -/// Resolve the paths a check run walks. Implements [CHKARCH-CONFIG-INCLUDE]: -/// explicit CLI paths win, then the configured `include` roots, then `.`. -pub(crate) fn effective_check_paths( - paths: &[String], - config: &basilisk_config::BasiliskConfig, - config_root: &std::path::Path, -) -> Vec { - if !paths.is_empty() { - return paths.to_vec(); - } - if config.include.is_empty() { - return vec![".".to_owned()]; - } - config - .include - .iter() - .map(|inc| config_root.join(inc).to_string_lossy().into_owned()) - .collect() -} - -/// The per-directory rule configuration of a run, keyed by owning directory -/// ([CHKARCH-CONFIG-DISCOVERY]). -pub(crate) type DirConfigs = - std::collections::BTreeMap>; - -/// The union of the codes `select` reports across the base config and every -/// per-directory config in this run — one file's config never speaks for the -/// whole run ([CHKARCH-CONFIG-DISCOVERY]). -fn codes_across_configs( - dir_configs: &DirConfigs, - base: &basilisk_config::BasiliskConfig, - select: fn(&basilisk_config::BasiliskConfig) -> Vec<&'static str>, -) -> std::collections::BTreeSet<&'static str> { - let mut codes: std::collections::BTreeSet<&'static str> = select(base).into_iter().collect(); - for config in dir_configs.values() { - codes.extend(select(config)); - } - codes -} - -/// The codes an invalid configuration resolves to `disabled` although they -/// are `pep`-tagged, across every per-directory config in this run. -/// -/// Implements [CHKARCH-CONFIG-MODEL]: `disabled` never applies to a `pep` -/// rule — such a configuration is invalid and fails the run before checking. -fn pep_disable_config_error( - dir_configs: &DirConfigs, - base: &basilisk_config::BasiliskConfig, -) -> Option { - let violations = - codes_across_configs(dir_configs, base, basilisk_checker::pep_disable_violations); - if violations.is_empty() { - return None; - } - let codes = violations.into_iter().collect::>().join(", "); - Some(format!( - "configuration resolves PEP typing-spec rules to `disabled`, which is invalid \ - ([CHKARCH-CONFIG-MODEL]): {codes}. PEP rules always run; grade them \ - `error`/`warning`/`info` instead." - )) -} - -/// Collect Python files and check each one under its own discovered config, -/// keeping only diagnostics in `scope` ([CHKARCH-COMMANDS]). -/// -/// # Errors -/// -/// [`PipelineError::Config`] when any discovered configuration invalidly -/// resolves a `pep` rule to `disabled`; [`PipelineError::Internal`] on -/// collection failures (e.g. nonexistent paths). -pub(crate) fn collect_and_check( - paths: &[String], - cache: &cache_check::CacheOptions, - stats: &mut cache_check::CacheStats, - scope: DiagnosticScope, -) -> Result { - collect_and_check_with_typeshed(paths, cache, stats, scope, activate_production_typeshed) -} - -fn collect_and_check_with_typeshed( - paths: &[String], - cache: &cache_check::CacheOptions, - stats: &mut cache_check::CacheStats, - scope: DiagnosticScope, - activate_typeshed: F, -) -> Result -where - F: Fn( - &mut basilisk_lsp::import_resolver::ImportSearchPaths, - &basilisk_lsp::config::WorkspaceConfig, - &basilisk_config::BasiliskConfig, - ) -> Result<(), PipelineError>, -{ - // [CHKARCH-CONFIG-DISCOVERY] The first path only anchors project-level - // concerns (include expansion, version detection, cache location); rule - // config is resolved per checked file below, so diagnostics never depend - // on argument order (GitHub #311). - let config_root = first_path_dir(paths); - // Project metadata can live above the checked path (for example, - // `conformance/tests/case.py` inherits `conformance/pyproject.toml`). - // Resolve the project root before reading target-version evidence so a - // nested invocation observes the same explicit project target as the LSP. - let project_root = find_project_root(&config_root); - let mut config = basilisk_config::load_basilisk_config(&config_root); - // [CHKARCH-VERSION-TARGET] Detect the target version from project files - // when the config does not pin one, matching the LSP (issue #93). - if config.python_version.is_none() { - config.python_version = - basilisk_uv::python_version::resolve_target_python_version(&project_root); - } - - let workspace_config = - load_cli_workspace_config(&project_root, config.python_version.as_deref()); - if config.python_platform.is_none() { - config - .python_platform - .clone_from(&workspace_config.python_platform); - } - - // Activate the typeshed source FIRST: the bundled default resolves its - // snapshot (and prewarms the builtins index) on a background thread, so - // kicking it off before file collection and search-path discovery - // maximises the overlap with the lead-in work. The activation seam only - // populates `typeshed_snapshot`, transplanted into the real search paths - // below. The source-status advisories resolve severity through the same - // project `[tool.basilisk]` tables as any rule - // ([STUBRES-TYPESHED-CONFIG]), so the project-root config is handed in. - let mut typeshed_activation = crate::import_search::roots_only(Vec::new()); - activate_typeshed(&mut typeshed_activation, &workspace_config, &config)?; - - let excluded = excluded_dirs_and_log(&config, &config_root); - - // Implements [CHKARCH-CONFIG-INCLUDE] (issue #37): a no-args run walks - // only the configured include roots, never the whole repository. - let paths = &effective_check_paths(paths, &config, &config_root); - let python_files = collect_python_files(paths, &excluded).map_err(PipelineError::Internal)?; - - // Build import search paths (venv, uv registry, workspace members). - // 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); - let mut search_paths = if crate::import_search::files_might_import(&python_files) { - build_import_search_paths_with_config(roots, &workspace_config) - } else { - crate::import_search::roots_only(roots) - }; - search_paths.typeshed_snapshot = typeshed_activation.typeshed_snapshot.take(); - - // Per-file rule config, memoized per directory ([CHKARCH-CONFIG-DISCOVERY]). - // The cache fingerprint covers every directory's config so a child config - // edit invalidates cached results. - let dir_configs = resolve_dir_configs(&python_files, &config); - - // A config that disables a PEP rule is invalid and fails the run before - // any checking ([CHKARCH-CONFIG-MODEL], [CHKARCH-CLI-EXITCODES] code 2). - if let Some(message) = pep_disable_config_error(&dir_configs, &config) { - return Err(PipelineError::Config(message)); - } - - // [CHKARCH-CLI-SCOPE-NOTICE] (GitHub #334): the edge filter below drops - // every analyze-scope diagnostic from a `check` run. Count the rules - // configuration selected but this scope will never evaluate, so the - // renderer can say so — a silent clean run is indistinguishable from a - // clean project. - let unrun_selected_rules = match scope { - DiagnosticScope::Check => codes_across_configs( - &dir_configs, - &config, - basilisk_checker::analyze_selected_rules, - ) - .len(), - DiagnosticScope::Analyze | DiagnosticScope::Union => 0, - }; - - // [CHKCACHE-CONFIG]: the project-root config carries the standing - // `cache`/`cache-dir` policy; the CLI flags override it for this run only. - let cache_context = - cache_check::build_context(cache, &config, &dir_configs, &search_paths, &project_root); - - let mut all_diagnostics = Vec::new(); - let mut sources = Vec::new(); - let mut failures = Vec::new(); - - for path in python_files { - let file_config = config_for_path(&dir_configs, &path, &config); - let outcome = cache_check::check_file(cache_context.as_ref(), stats, &path, || { - process_file(&path, &search_paths, &file_config) - }); - match outcome { - Ok((diags, source)) => { - // The command's edge filter ([CHKARCH-COMMANDS]). Applied - // after the cache layer so cached entries stay scope-free - // and both commands share them. - all_diagnostics.extend(diags.into_iter().filter(|d| scope.retains(d.code.code))); - sources.push(FileSource { path, text: source }); - } - Err(err) => { - failures.push(FileAnalysisFailure { path, message: err }); - } - } - } - - // A deferred typeshed load that failed must fail the run loudly — inside - // the loop the miss can only surface as unresolved imports. A run that - // never needed the archive (all cache hits) never forces the load and is - // unaffected. - if let Some(error) = search_paths - .typeshed_snapshot - .as_ref() - .and_then(basilisk_lsp::import_resolver::ActiveTypeshed::deferred_error) - { - return Err(PipelineError::Internal(error)); - } - - Ok(CheckOutcome { - diagnostics: all_diagnostics, - sources, - failures, - unrun_selected_rules, - }) -} - -/// Canonical project and checked-directory roots used for import resolution. -pub(crate) fn analysis_roots( - paths: &[String], - project_root: &std::path::Path, -) -> Vec { - let canonical = std::fs::canonicalize(project_root).unwrap_or_else(|_| project_root.into()); - paths.iter().fold(vec![canonical], |mut roots, path| { - let candidate = std::path::Path::new(path); - let directory = if candidate.is_dir() { - candidate.to_path_buf() - } else { - parent_dir_of(path) - }; - if let Ok(absolute) = std::fs::canonicalize(directory) { - if !roots.contains(&absolute) { - roots.push(absolute); - } - } - roots - }) -} - -fn process_file( - path: &str, - search_paths: &basilisk_lsp::import_resolver::ImportSearchPaths, - config: &basilisk_config::BasiliskConfig, -) -> Result<(Vec, String), String> { - let target_version = - basilisk_checker::context::CheckContext::from_config(config).target_version; - let (resolved, source) = resolve_file_imports(path, search_paths, target_version)?; - // Apply the project's `[tool.basilisk]` tables so the CLI and editor - // agree on selection and severity ([CHKARCH-CONFIG-MODEL]). Using `check` - // here would silently drop config. - let diagnostics = basilisk_checker::check_with_config(&resolved, config); - Ok((diagnostics, source)) -} - -/// Parse a source file and resolve its imports through the shared CLI/LSP paths. -pub(crate) fn resolve_file_imports( - path: &str, - search_paths: &basilisk_lsp::import_resolver::ImportSearchPaths, - target_version: Option<(u32, u32)>, -) -> Result<(basilisk_resolver::ResolvedModule, String), String> { - let parsed = basilisk_parser::parse_file(path).map_err(|e| e.to_string())?; - let source = parsed.source.clone(); - let mut resolved = match target_version { - Some(target_version) => basilisk_resolver::resolve_with_target(&parsed, target_version), - None => basilisk_resolver::resolve(&parsed), - } - .map_err(|e| e.to_string())?; - - // Resolve imports against venv/site-packages and uv registry using the same - // routine the LSP uses, so the CLI and editor agree on what resolves and on - // package-dependency metadata (BSK-0011 transitive-import warnings, etc.). - basilisk_lsp::import_resolver::resolve_module_imports(&mut resolved, search_paths); - Ok((resolved, source)) -} - -/// The directory anchoring project-level concerns for a CLI invocation: the -/// first path argument's own directory (or its parent for a file), else cwd. -pub(crate) fn first_path_dir(paths: &[String]) -> std::path::PathBuf { - paths.first().map(std::path::Path::new).map_or_else( - || std::path::PathBuf::from("."), - |p| { - if p.is_dir() { - p.to_path_buf() - } else { - p.parent() - .filter(|parent| !parent.as_os_str().is_empty()) - .unwrap_or_else(|| std::path::Path::new(".")) - .to_path_buf() - } - }, - ) -} - -/// The directory owning `path` (its parent, or `.` for a bare filename). -pub(crate) fn parent_dir_of(path: &str) -> std::path::PathBuf { - std::path::Path::new(path) - .parent() - .filter(|dir| !dir.as_os_str().is_empty()) - .map_or_else( - || std::path::PathBuf::from("."), - std::path::Path::to_path_buf, - ) -} - -/// Resolve the merged rule config for each checked file's directory. -/// -/// Implements [CHKARCH-CONFIG-DISCOVERY] (GitHub #311): every file is checked -/// with the config discovered from its own ancestor chain, so diagnostics are -/// independent of argument order, path spelling, and cwd. Memoized per -/// directory; `fallback` supplies the detected Python version when a -/// directory's chain does not pin one ([CHKARCH-VERSION-TARGET]). -pub(crate) fn resolve_dir_configs( - python_files: &[String], - fallback: &basilisk_config::BasiliskConfig, -) -> DirConfigs { - let mut dir_configs = std::collections::BTreeMap::new(); - for path in python_files { - let _ = dir_configs - .entry(parent_dir_of(path)) - .or_insert_with_key(|dir| { - let mut cfg = basilisk_config::load_basilisk_config(dir); - if cfg.python_version.is_none() { - cfg.python_version.clone_from(&fallback.python_version); - } - if cfg.python_platform.is_none() { - cfg.python_platform.clone_from(&fallback.python_platform); - } - std::sync::Arc::new(cfg) - }); - } - dir_configs -} - -/// The per-directory config for `path`, falling back to `fallback` (only -/// reachable if `path` was not in the file list the map was built from). -pub(crate) fn config_for_path( - dir_configs: &DirConfigs, - path: &str, - fallback: &basilisk_config::BasiliskConfig, -) -> std::sync::Arc { - dir_configs - .get(&parent_dir_of(path)) - .cloned() - .unwrap_or_else(|| std::sync::Arc::new(fallback.clone())) -} - -/// Walk up from `start` to find the project root (directory containing -/// `pyproject.toml` or `uv.lock`). Falls back to cwd, then `start`. -pub(crate) fn find_project_root(start: &std::path::Path) -> std::path::PathBuf { - let abs = std::fs::canonicalize(start).unwrap_or_else(|_| start.to_path_buf()); - let mut current = abs.as_path(); - loop { - if current.join("pyproject.toml").is_file() || current.join("uv.lock").is_file() { - return current.to_path_buf(); - } - match current.parent() { - Some(parent) => current = parent, - None => break, - } - } - // Fallback: cwd, then the original start path. - std::env::current_dir().unwrap_or_else(|_| start.to_path_buf()) -} - -/// Return `"s"` for counts != 1, empty string otherwise. -pub(crate) fn pluralise(count: usize) -> &'static str { - if count == 1 { - "" - } else { - "s" - } -} - -/// Whether `path` is excluded by any configured pattern, matched -/// gitignore-style against the path relative to the walk `root`. -/// -/// Implements [CHKARCH-CONFIG-EXCLUDE]. See -/// docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-CONFIG-EXCLUDE -/// -/// Uses the same [`basilisk_config::path_matches_pattern`] matcher as the LSP -/// workspace scan, so `basilisk check` and the editor agree on what is skipped: -/// bare names (`build`) at any depth, directory globs (`**/generated/**`), -/// and file globs (`*.pb.py`) all work — not just literal directory names. -fn is_excluded_path( - path: &std::path::Path, - root: &std::path::Path, - excluded: &HashSet<&str>, -) -> bool { - let relative = path.strip_prefix(root).unwrap_or(path); - excluded - .iter() - .any(|pattern| basilisk_config::path_matches_pattern(relative, pattern)) -} - -/// Build the excluded-directory set from `config` and log that the config at -/// `config_root` was loaded. Shared setup prologue for the CLI subcommands. -pub(crate) fn excluded_dirs_and_log<'a>( - config: &'a basilisk_config::BasiliskConfig, - config_root: &std::path::Path, -) -> HashSet<&'a str> { - let excluded: HashSet<&str> = config.exclude.iter().map(String::as_str).collect(); - info!( - excluded_dirs = ?config.exclude, - "loaded config from {}", - config_root.display() - ); - excluded -} - -/// `true` for the Python source extensions Basilisk type-checks: `.py` -/// implementation files and `.pyi` stub files (whose overload-definition and -/// `@final`/`@override` rules differ — see `overloads_*`). Stubs were silently -/// dropped before, so a `basilisk check foo.pyi` produced no diagnostics. -fn is_python_source_ext(ext: &std::ffi::OsStr) -> bool { - ext.eq_ignore_ascii_case("py") || ext.eq_ignore_ascii_case("pyi") -} - -pub(crate) fn collect_python_files( - paths: &[String], - excluded: &HashSet<&str>, -) -> Result, String> { - let mut files = Vec::new(); - - for root in paths { - let meta = match std::fs::metadata(root) { - Ok(m) => m, - Err(e) if e.kind() == std::io::ErrorKind::NotFound => { - return Err(format!("cannot access {root}: {e}")); - } - Err(e) => { - warn!(root, %e, "cannot access path"); - continue; - } - }; - - if meta.is_file() { - if std::path::Path::new(root) - .extension() - .is_some_and(is_python_source_ext) - { - files.push(root.clone()); - } - } else { - let root_path = std::path::Path::new(root); - for entry in walkdir::WalkDir::new(root) - .follow_links(false) - .into_iter() - .filter_entry(|e| { - if !e.file_type().is_dir() { - return true; - } - // Never exclude the root entry (depth 0) — the user - // explicitly asked to check this path (often `.`). - if e.depth() == 0 { - return true; - } - let name = e.file_name().to_string_lossy(); - // Hidden directories are always excluded. - if name.starts_with('.') { - return false; - } - // Virtualenvs are pruned structurally by their `pyvenv.cfg` - // marker, whatever `exclude` says ([CHKARCH-CONFIG-EXCLUDE], - // GitHub #341). The depth-0 exemption above still lets an - // explicit `basilisk check ./venv` in. - if basilisk_config::is_virtualenv_dir(e.path()) { - return false; - } - !is_excluded_path(e.path(), root_path, excluded) - }) - .filter_map(Result::ok) - .filter(|e| e.file_type().is_file()) - .filter(|e| e.path().extension().is_some_and(is_python_source_ext)) - // File-level globs (e.g. `*.pb.py`, `**/conftest.py`) are honoured - // here; directory globs are already pruned above before recursing. - .filter(|e| !is_excluded_path(e.path(), root_path, excluded)) - { - files.push(entry.path().to_string_lossy().into_owned()); - } - } - } - - Ok(files) -} - -#[cfg(test)] -mod tests; diff --git a/crates/basilisk-cli/src/pipeline/tests.rs b/crates/basilisk-cli/src/pipeline/tests.rs deleted file mode 100644 index ddf33c165..000000000 --- a/crates/basilisk-cli/src/pipeline/tests.rs +++ /dev/null @@ -1,1016 +0,0 @@ -//! Unit tests for the shared collect-and-check pipeline. -//! -//! Cross-references [CHKARCH-CLI], [CHKARCH-COMMANDS], [CHKARCH-CONFIG-MODEL], -//! and [CHKARCH-CONFIG-DISCOVERY] (GitHub #311); the code under test is -//! `crate::pipeline`. - -use super::*; -use crate::cache_check; - -/// Default excludes for test helpers. -fn test_excludes() -> HashSet<&'static str> { - basilisk_config::DEFAULT_EXCLUDES.iter().copied().collect() -} - -/// Disabled cache options for tests that exercise the plain check pipeline. -fn no_cache() -> cache_check::CacheOptions { - cache_check::CacheOptions { - enabled: cache_check::CacheOverride::ForceOff, - dir: None, - stats: false, - } -} - -/// Run `collect_and_check` with the cache disabled and a throwaway tally. -fn collect_uncached( - paths: &[String], - scope: DiagnosticScope, -) -> Result { - collect_and_check_with_typeshed( - paths, - &no_cache(), - &mut cache_check::CacheStats::default(), - scope, - activate_bundled_typeshed, - ) -} - -fn activate_bundled_typeshed( - search_paths: &mut basilisk_lsp::import_resolver::ImportSearchPaths, - config: &basilisk_lsp::config::WorkspaceConfig, - _rule_config: &basilisk_config::BasiliskConfig, -) -> Result<(), PipelineError> { - let snapshot = basilisk_stubs::typeshed::bundle::bundled_snapshot() - .map(std::sync::Arc::new) - .map_err(|error| PipelineError::Internal(error.to_string()))?; - search_paths.typeshed_snapshot = Some(basilisk_checker::imports::ActiveTypeshed::new( - snapshot, - basilisk_lsp::import_resolver::stub_target_from_config(config), - )); - Ok(()) -} - -/// Unique temp dir for tests that need an isolated project root. -fn unique_project_dir(prefix: &str) -> std::path::PathBuf { - use std::sync::atomic::{AtomicU64, Ordering}; - static CTR: AtomicU64 = AtomicU64::new(0); - let n = CTR.fetch_add(1, Ordering::Relaxed); - std::env::temp_dir().join(format!("{prefix}_{}_{n}", std::process::id())) -} - -/// [STUBRES-TYPESHED-WARN]: ordinary CLI analysis activates the configured -/// production source once and attaches its exact status/target to resolution. -#[test] -fn cli_activation_uses_custom_snapshot_and_target() -> Result<(), Box> { - let project = unique_project_dir("basilisk_cli_custom_typeshed"); - let stdlib = project.join("typeshed").join("stdlib"); - std::fs::create_dir_all(&stdlib)?; - std::fs::write(stdlib.join("VERSIONS"), "sentinel: 3.8-\n")?; - std::fs::write(stdlib.join("sentinel.pyi"), "VALUE: str\n")?; - let config = basilisk_lsp::config::WorkspaceConfig { - typeshed_path: Some(project.join("typeshed")), - python_version: Some("3.12".to_owned()), - python_platform: Some("Linux".to_owned()), - ..basilisk_lsp::config::WorkspaceConfig::default() - }; - let mut search_paths = crate::import_search::roots_only(vec![project.clone()]); - super::typeshed::activate_production_typeshed( - &mut search_paths, - &config, - &basilisk_config::BasiliskConfig::default(), - ) - .map_err(|error| std::io::Error::other(error.to_string()))?; - let active = search_paths - .typeshed_snapshot - .as_ref() - .ok_or("active Typeshed missing")?; - assert_eq!( - active - .snapshot() - .ok_or("snapshot must resolve")? - .status - .active_source, - basilisk_stubs::typeshed::source::SourceKind::Custom - ); - assert_eq!( - active - .snapshot() - .ok_or("snapshot must resolve")? - .status - .warnings - .iter() - .map(|warning| warning.code.as_str()) - .collect::>(), - vec!["typeshed_source_unpinned", "typeshed_source_user_managed"] - ); - assert_eq!( - active.target().map(|target| target.python_version), - Some((3, 12)) - ); - let _ = std::fs::remove_dir_all(project); - Ok(()) -} - -/// [STUBRES-TYPESHED-OFFLINE]: a pinned commit that is not on this machine -/// tanks the run hard — no download, no bundle fallback, no diagnostics. The -/// production activation path itself surfaces the terminal `NO SOURCE` -/// failure and the checked project's config reaches it verbatim. -#[test] -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"), - 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")?; - let path = source.to_string_lossy().into_owned(); - - let result = collect_and_check_with_typeshed( - &[path], - &no_cache(), - &mut cache_check::CacheStats::default(), - DiagnosticScope::Check, - super::typeshed::activate_production_typeshed, - ); - // 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); - let Err(PipelineError::NoSource(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(()) -} - -/// Project-level target evidence applies when the checked file is nested -/// below the project root. The official python/typing suite has exactly this -/// layout: `conformance/pyproject.toml` declares Python 3.12 and fixtures live -/// in `conformance/tests/`. -#[test] -fn nested_file_inherits_project_python_target_for_analysis( -) -> Result<(), Box> { - let project = unique_project_dir("basilisk_cli_nested_python_target"); - let tests = project.join("tests"); - std::fs::create_dir_all(&tests)?; - std::fs::write( - project.join("pyproject.toml"), - "[project]\nname = \"fixture\"\nversion = \"0.0.0\"\nrequires-python = \"==3.12.*\"\n", - )?; - let source = tests.join("fixture.py"); - std::fs::write(&source, "value: int = 1\n")?; - - let result = collect_and_check_with_typeshed( - &[source.to_string_lossy().into_owned()], - &no_cache(), - &mut cache_check::CacheStats::default(), - DiagnosticScope::Check, - |_search_paths, config, _rule_config| { - assert_eq!(config.python_version.as_deref(), Some("3.12")); - Ok(()) - }, - ); - let _ = std::fs::remove_dir_all(project); - let _outcome = - result.map_err(|error| format!("the nested file must analyse cleanly: {error:?}"))?; - Ok(()) -} - -/// The official python/typing layout supplies a project Python version but no -/// `python-platform`. The selected interpreter is concrete environment -/// evidence, so both forms of an impossible platform guard must still narrow. -#[test] -fn nested_file_inherits_selected_interpreter_platform_for_analysis( -) -> Result<(), Box> { - let project = unique_project_dir("basilisk_cli_nested_platform_target"); - let tests = project.join("tests"); - std::fs::create_dir_all(&tests)?; - std::fs::write( - project.join("pyproject.toml"), - "[project]\nname = \"fixture\"\nversion = \"0.0.0\"\nrequires-python = \"==3.12.*\"\n", - )?; - let source = tests.join("directives_version_platform.py"); - std::fs::write( - &source, - concat!( - "import sys\n\n", - "def test():\n", - " if sys.version_info < (3, 8):\n", - " val3 = ''\n", - " else:\n", - " live3 = ''\n", - " use3 = val3\n", - " if sys.platform == 'bogus_platform':\n", - " val6 = ''\n", - " else:\n", - " live6 = ''\n", - " use6 = val6\n", - " if sys.platform != 'bogus_platform':\n", - " live9 = ''\n", - " else:\n", - " val9 = ''\n", - " use9 = val9\n", - " return live3, live6, live9\n", - ), - )?; - - let outcome = collect_uncached( - &[source.to_string_lossy().into_owned()], - DiagnosticScope::Check, - ) - .map_err(|error| error.to_string())?; - let _ = std::fs::remove_dir_all(project); - let messages = outcome - .diagnostics - .iter() - .filter(|diagnostic| diagnostic.code.code == "directives_version_platform") - .map(|diagnostic| diagnostic.message.as_str()) - .collect::>(); - - for dead in ["val3", "val6", "val9"] { - assert!( - messages.iter().any(|message| message.contains(dead)), - "missing dead-branch diagnostic for {dead}: {messages:?}" - ); - } - for live in ["live3", "live6", "live9"] { - assert!( - messages.iter().all(|message| !message.contains(live)), - "selected-interpreter narrowing must not flag live {live}: {messages:?}" - ); - } - Ok(()) -} - -#[test] -fn explicit_all_platform_does_not_narrow_checker_branches() -> Result<(), Box> -{ - let project = unique_project_dir("basilisk_cli_all_platform_target"); - std::fs::create_dir_all(&project)?; - std::fs::write( - project.join("pyproject.toml"), - concat!( - "[project]\nname = \"fixture\"\nversion = \"0.0.0\"\n", - "requires-python = \"==3.12.*\"\n\n", - "[tool.basilisk]\npython-platform = \"All\"\n", - ), - )?; - let source = project.join("platform.py"); - std::fs::write( - &source, - concat!( - "import sys\n\n", - "def test():\n", - " if sys.platform == 'win32':\n", - " windows = ''\n", - " else:\n", - " other = ''\n", - " return windows, other\n", - ), - )?; - - let outcome = collect_uncached( - &[source.to_string_lossy().into_owned()], - DiagnosticScope::Check, - ) - .map_err(|error| error.to_string())?; - let _ = std::fs::remove_dir_all(project); - let platform_diagnostics = outcome - .diagnostics - .iter() - .filter(|diagnostic| diagnostic.code.code == "directives_version_platform") - .collect::>(); - - assert!( - platform_diagnostics.is_empty(), - "cross-platform analysis cannot call either platform branch dead: {platform_diagnostics:#?}" - ); - Ok(()) -} - -#[test] -fn bare_filename_anchors_project_discovery_at_current_directory() { - assert_eq!( - first_path_dir(&["fixture.py".to_owned()]), - std::path::PathBuf::from(".") - ); -} - -#[test] -fn bare_filename_adds_current_directory_to_import_roots() -> Result<(), Box> -{ - let roots = analysis_roots(&["fixture.py".to_owned()], std::path::Path::new("..")); - let current = std::fs::canonicalize(".")?; - assert!(roots.contains(¤t), "{roots:#?}"); - Ok(()) -} - -#[test] -fn configured_target_prunes_inactive_conditional_constructor_fields( -) -> Result<(), Box> { - let project = unique_project_dir("basilisk_cli_conditional_constructor_fields"); - std::fs::create_dir_all(&project)?; - let source = project.join("fixture.py"); - std::fs::write( - &source, - concat!( - "from typing import NamedTuple\n", - "import sys\n\n", - "class ConditionalField(NamedTuple):\n", - " x: int\n", - " if sys.version_info >= (3, 12):\n", - " y: int\n", - " if sys.version_info >= (4, 0):\n", - " z: int\n\n", - "ConditionalField(1, 2)\n", - "ConditionalField(1, 2, 3)\n", - ), - )?; - let search_paths = crate::import_search::roots_only(vec![project.clone()]); - let config = basilisk_config::BasiliskConfig { - python_version: Some("3.12".to_owned()), - ..basilisk_config::BasiliskConfig::default() - }; - let (diagnostics, _) = process_file(&source.to_string_lossy(), &search_paths, &config)?; - let constructor_messages = diagnostics - .iter() - .filter(|diagnostic| diagnostic.code.code == "constructors_call_init") - .map(|diagnostic| diagnostic.message.as_str()) - .collect::>(); - assert_eq!(constructor_messages.len(), 1, "{diagnostics:#?}"); - assert!( - constructor_messages - .first() - .is_some_and(|message| message.contains("accepts at most 2 positional arguments")), - "{diagnostics:#?}" - ); - let _ = std::fs::remove_dir_all(project); - Ok(()) -} - -// ── DiagnosticScope ([CHKARCH-COMMANDS]) ────────────────────────────────── - -/// [CHKARCH-COMMANDS]: the partition is exact — `check` keeps only -/// `pep`-tagged codes, `analyze` keeps only the rest, `union` keeps both. -#[test] -fn diagnostic_scope_partitions_by_pep_tag() { - // `imports_unresolved` is a conformance (pep) rule; `BSK-0001` is a - // Basilisk-original opt-in rule ([CHKTAG-PROVENANCE]). - assert!(basilisk_checker::is_pep_rule("imports_unresolved")); - assert!(!basilisk_checker::is_pep_rule("BSK-0001")); - - assert!(DiagnosticScope::Check.retains("imports_unresolved")); - assert!(!DiagnosticScope::Check.retains("BSK-0001")); - assert!(!DiagnosticScope::Analyze.retains("imports_unresolved")); - assert!(DiagnosticScope::Analyze.retains("BSK-0001")); - assert!(DiagnosticScope::Union.retains("imports_unresolved")); - assert!(DiagnosticScope::Union.retains("BSK-0001")); -} - -/// [CHKARCH-COMMANDS]: every catalog code lands in exactly one command scope. -#[test] -fn every_rule_belongs_to_exactly_one_command() { - for descriptor in basilisk_checker::rule_catalog() { - let check = DiagnosticScope::Check.retains(descriptor.code); - let analyze = DiagnosticScope::Analyze.retains(descriptor.code); - assert!( - check ^ analyze, - "rule {} must belong to exactly one command scope", - descriptor.code - ); - } -} - -// ── collect_python_files ────────────────────────────────────────────────── - -#[test] -fn collect_python_files_returns_err_for_nonexistent_path() { - let result = collect_python_files(&["/no/such/path/ever.py".to_owned()], &test_excludes()); - assert!(result.is_err(), "nonexistent path must return Err"); -} - -#[test] -fn collect_python_files_skips_non_py_file() -> Result<(), Box> { - let dir = std::env::temp_dir(); - let txt = dir.join("basilisk_test_skip.txt"); - std::fs::write(&txt, b"hello")?; - let path = txt.to_string_lossy().into_owned(); - let files = collect_python_files(&[path], &test_excludes())?; - assert!(files.is_empty(), "non-.py file must be skipped"); - let _ = std::fs::remove_file(&txt); - Ok(()) -} - -#[test] -fn collect_python_files_includes_py_file() -> Result<(), Box> { - let dir = std::env::temp_dir(); - let py = dir.join("basilisk_test_include.py"); - std::fs::write(&py, b"x = 1")?; - let path = py.to_string_lossy().into_owned(); - let files = collect_python_files(&[path], &test_excludes())?; - let _ = std::fs::remove_file(&py); - assert_eq!(files.len(), 1, ".py file must be included"); - Ok(()) -} - -#[test] -fn collect_python_files_walks_directory() -> Result<(), Box> { - let base = std::env::temp_dir().join("basilisk_test_walk_dir"); - let _ = std::fs::remove_dir_all(&base); - std::fs::create_dir_all(&base)?; - std::fs::write(base.join("a.py"), b"x = 1")?; - std::fs::write(base.join("b.txt"), b"ignored")?; - let path = base.to_string_lossy().into_owned(); - let files = collect_python_files(&[path], &test_excludes())?; - let _ = std::fs::remove_dir_all(&base); - assert_eq!( - files.len(), - 1, - "directory walk must find exactly one .py file" - ); - Ok(()) -} - -/// `collect_python_files` — `MatchArmGuard → true` mutant: the `NotFound` -/// guard distinguishes "not found" from other I/O errors. The `NotFound` -/// path specifically returns Err (not Ok with an empty list). -#[test] -fn collect_python_files_not_found_returns_err() { - let result = collect_python_files( - &["/absolutely/does/not/exist/file.py".to_owned()], - &test_excludes(), - ); - assert!(result.is_err(), "NotFound path must return Err, not Ok"); -} - -/// Complement: a path that exists but is not .py returns Ok with empty list. -/// This kills the `true` guard mutant: if all errors → Err, this would fail. -#[test] -fn collect_python_files_non_py_existing_file_returns_ok_empty( -) -> Result<(), Box> { - let dir = std::env::temp_dir(); - let txt = dir.join("basilisk_test_guard_complement.txt"); - std::fs::write(&txt, b"hello")?; - let path = txt.to_string_lossy().into_owned(); - let result = collect_python_files(&[path], &test_excludes()); - let _ = std::fs::remove_file(&txt); - assert!(result.is_ok(), "existing non-py file must return Ok"); - assert!(result?.is_empty(), "non-py file must produce empty list"); - Ok(()) -} - -#[test] -fn collect_python_files_skips_excluded_directories() -> Result<(), Box> { - let base = std::env::temp_dir().join("basilisk_test_exclude_dirs"); - let _ = std::fs::remove_dir_all(&base); - std::fs::create_dir_all(&base)?; - - // File in root — should be found. - std::fs::write(base.join("app.py"), b"x = 1")?; - - // Files in default-excluded directories — should be skipped. - for excluded in &["__pycache__", "venv", "site-packages", "node_modules"] { - let sub = base.join(excluded); - std::fs::create_dir_all(&sub)?; - std::fs::write(sub.join("hidden.py"), b"x = 1")?; - } - - // File in a hidden directory — should be skipped. - let hidden = base.join(".hidden"); - std::fs::create_dir_all(&hidden)?; - std::fs::write(hidden.join("secret.py"), b"x = 1")?; - - let path = base.to_string_lossy().into_owned(); - let files = collect_python_files(&[path], &test_excludes())?; - let _ = std::fs::remove_dir_all(&base); - - assert_eq!( - files.len(), - 1, - "only root app.py should be found, got: {files:?}" - ); - Ok(()) -} - -#[test] -fn collect_python_files_respects_custom_excludes() -> Result<(), Box> { - let base = std::env::temp_dir().join("basilisk_test_custom_exclude"); - let _ = std::fs::remove_dir_all(&base); - std::fs::create_dir_all(&base)?; - - std::fs::write(base.join("app.py"), b"x = 1")?; - let sub = base.join("vendor"); - std::fs::create_dir_all(&sub)?; - std::fs::write(sub.join("lib.py"), b"x = 1")?; - - // Custom exclude: only "vendor", not the defaults. - let custom: HashSet<&str> = ["vendor"].into_iter().collect(); - let path = base.to_string_lossy().into_owned(); - let files = collect_python_files(&[path], &custom)?; - let _ = std::fs::remove_dir_all(&base); - - assert_eq!( - files.len(), - 1, - "vendor should be excluded, only app.py found" - ); - Ok(()) -} - -/// Regression: the `basilisk check` CLI ignored user **glob** excludes. -/// `collect_python_files` must honour gitignore-style globs via -/// `basilisk_config::path_matches_pattern`, agreeing with the LSP scan. -/// Implements [CHKARCH-CONFIG-EXCLUDE]. -#[test] -fn collect_python_files_honors_user_glob_excludes() -> Result<(), Box> { - let base = std::env::temp_dir().join(format!( - "basilisk_test_cli_glob_exclude_{}", - std::process::id() - )); - let _ = std::fs::remove_dir_all(&base); - let gen = base.join("src").join("generated"); - std::fs::create_dir_all(&gen)?; - std::fs::write(base.join("app.py"), b"x = 1")?; // real code — must survive - std::fs::write(gen.join("models.py"), b"y = 2")?; // excluded by **/generated/** - std::fs::write(base.join("schema.pb.py"), b"z = 3")?; // excluded by *.pb.py - - let excludes: HashSet<&str> = ["**/generated/**", "*.pb.py"].into_iter().collect(); - let path = base.to_string_lossy().into_owned(); - let files = collect_python_files(&[path], &excludes)?; - let _ = std::fs::remove_dir_all(&base); - - let names: Vec = files.iter().map(|f| f.replace('\\', "/")).collect(); - assert_eq!( - files.len(), - 1, - "only app.py should survive the glob excludes, got: {names:?}" - ); - assert!( - names.iter().any(|f| f.ends_with("/app.py")), - "app.py must still be collected: {names:?}" - ); - assert!( - !names.iter().any(|f| f.contains("generated")), - "**/generated/** must exclude the nested directory: {names:?}" - ); - assert!( - !names.iter().any(|f| f.contains("schema.pb.py")), - "*.pb.py glob must exclude the file: {names:?}" - ); - Ok(()) -} - -/// Regression: `basilisk check .` found zero files because the root -/// entry `.` starts with `.` and was rejected by the hidden-dir filter. -#[test] -fn collect_python_files_hidden_root_dir_still_walked() -> Result<(), Box> { - let base = std::env::temp_dir().join("basilisk_test_hidden_root"); - let _ = std::fs::remove_dir_all(&base); - - let hidden = base.join(".myproject"); - std::fs::create_dir_all(&hidden)?; - std::fs::write(hidden.join("app.py"), b"x = 1")?; - let sub = hidden.join("pkg"); - std::fs::create_dir_all(&sub)?; - std::fs::write(sub.join("mod.py"), b"y = 2")?; - - let path = hidden.to_string_lossy().into_owned(); - let files = collect_python_files(&[path], &test_excludes())?; - let _ = std::fs::remove_dir_all(&base); - - assert_eq!( - files.len(), - 2, - "user-supplied root starting with '.' must still be walked, got: {files:?}" - ); - Ok(()) -} - -// ── collect_and_check ───────────────────────────────────────────────────── - -#[test] -#[cfg(unix)] -fn collect_and_check_handles_unreadable_py_file() -> Result<(), Box> { - use std::os::unix::fs::PermissionsExt; - - let dir = std::env::temp_dir(); - let py = dir.join("basilisk_test_locked.py"); - std::fs::write(&py, b"def foo(): pass")?; - std::fs::set_permissions(&py, std::fs::Permissions::from_mode(0o000))?; - - let path = py.to_string_lossy().into_owned(); - let result = collect_uncached(&[path], DiagnosticScope::Union); - std::fs::set_permissions(&py, std::fs::Permissions::from_mode(0o644))?; - let _ = std::fs::remove_file(&py); - - let outcome = result.map_err(|err| err.to_string())?; - assert!( - outcome.diagnostics.is_empty(), - "unreadable file produces no diagnostics, got: {:#?}", - outcome.diagnostics - ); - assert_eq!( - outcome.failures.len(), - 1, - "unreadable file must be a failure" - ); - Ok(()) -} - -/// [CHKARCH-COMMANDS]: `def foo(x)` violates the annotation house rules -/// (BSK-0001/BSK-0002), which are analyze-scope opt-ins. An opted-in project -/// sees them under the analyze scope — and never under the check scope. -#[test] -fn collect_and_check_scopes_house_rules_to_analyze() -> Result<(), Box> { - let dir = unique_project_dir("basilisk_test_bad_code"); - std::fs::create_dir_all(&dir)?; - std::fs::write( - dir.join("pyproject.toml"), - b"[tool.basilisk.rules]\n\"BSK-0001\" = \"error\"\n\"BSK-0002\" = \"error\"\n", - )?; - let py = dir.join("bad.py"); - std::fs::write(&py, b"def foo(x):\n pass\n")?; - let path = py.to_string_lossy().into_owned(); - let analyze = collect_uncached(std::slice::from_ref(&path), DiagnosticScope::Analyze) - .map_err(|err| err.to_string())?; - let check = collect_uncached(&[path], DiagnosticScope::Check).map_err(|err| err.to_string())?; - let _ = std::fs::remove_dir_all(&dir); - assert!( - analyze - .diagnostics - .iter() - .any(|d| d.code.code == "BSK-0001"), - "analyze scope must fire the configured house rule" - ); - assert!( - check.diagnostics.iter().all(|d| d.code.code != "BSK-0001"), - "check scope must never emit a house rule, even when configured \ - ([CHKARCH-COMMANDS]); got: {:#?}", - check.diagnostics - ); - Ok(()) -} - -/// Regression: `basilisk analyze` must honor `[tool.basilisk.rules]` severity -/// grades from `pyproject.toml`. A project that escalates BSK-0050 to "error" -/// must see it surface as a hard error through the real pipeline. -/// [CHKARCH-CONFIG-MODEL] -#[test] -fn collect_and_check_applies_project_rule_severity_override( -) -> Result<(), Box> { - let dir = unique_project_dir("basilisk_cli_cfg_promote"); - std::fs::create_dir_all(&dir)?; - // An explicit non-disabled severity both selects and grades an - // off-by-default rule. See [CHKARCH-CONFIGURATION-ONLY]. - std::fs::write( - dir.join("pyproject.toml"), - b"[project]\nname = \"x\"\nversion = \"0.1.0\"\n\n\ - [tool.basilisk.rules]\n\"BSK-0050\" = \"error\"\n", - )?; - let py = dir.join("m.py"); - std::fs::write(&py, b"x: int = 42\n")?; - - let path = py.to_string_lossy().into_owned(); - let outcome = - collect_uncached(&[path], DiagnosticScope::Analyze).map_err(|err| err.to_string())?; - let _ = std::fs::remove_dir_all(&dir); - - let w0050: Vec<_> = outcome - .diagnostics - .iter() - .filter(|d| d.code.code == "BSK-0050") - .collect(); - assert!(!w0050.is_empty(), "BSK-0050 must fire under analyze"); - assert!( - w0050 - .iter() - .all(|d| d.severity == basilisk_checker::Severity::Error), - "project config `BSK-0050 = \"error\"` must promote BSK-0050 to error; got {:?}", - w0050.iter().map(|d| d.severity).collect::>() - ); - Ok(()) -} - -// ── pep-disable violations ([CHKARCH-CONFIG-MODEL]) ─────────────────────── - -/// [CHKARCH-CONFIG-MODEL]: a config that resolves a `pep` rule to `disabled` -/// is invalid — the pipeline fails with a configuration error before checking. -#[test] -fn pep_disable_config_fails_the_run() -> Result<(), Box> { - let dir = unique_project_dir("basilisk_cli_pep_disable"); - std::fs::create_dir_all(&dir)?; - std::fs::write( - dir.join("pyproject.toml"), - b"[tool.basilisk.rules]\n\"imports_unresolved\" = \"disabled\"\n", - )?; - let py = dir.join("m.py"); - std::fs::write(&py, b"x: int = 1\n")?; - - let path = py.to_string_lossy().into_owned(); - let check = collect_uncached(std::slice::from_ref(&path), DiagnosticScope::Check); - let analyze = collect_uncached(&[path], DiagnosticScope::Analyze); - let _ = std::fs::remove_dir_all(&dir); - - for (command, result) in [("check", check), ("analyze", analyze)] { - let message = match result { - Err(PipelineError::Config(message)) => message, - Err(PipelineError::Internal(message)) => { - return Err(format!( - "`{command}` must fail with a Config error, got Internal: {message}" - ) - .into()) - } - Err(PipelineError::NoSource(message)) => { - return Err(format!( - "`{command}` must fail with a Config error, got NoSource: {message}" - ) - .into()) - } - Ok(outcome) => { - return Err(format!( - "`{command}` must fail with a Config error, got Ok with {} diagnostics", - outcome.diagnostics.len() - ) - .into()) - } - }; - assert!( - message.contains("imports_unresolved"), - "`{command}` config error must name the offending code, got: {message}" - ); - } - Ok(()) -} - -/// Grading (not disabling) a pep rule remains valid configuration. -/// [CHKARCH-CONFIG-MODEL] -#[test] -fn pep_grade_config_is_valid() -> Result<(), Box> { - let dir = unique_project_dir("basilisk_cli_pep_grade"); - std::fs::create_dir_all(&dir)?; - std::fs::write( - dir.join("pyproject.toml"), - b"[tool.basilisk.rules]\n\"imports_unresolved\" = \"warning\"\n", - )?; - let py = dir.join("m.py"); - std::fs::write(&py, b"x: int = 1\n")?; - - let path = py.to_string_lossy().into_owned(); - let outcome = collect_uncached(&[path], DiagnosticScope::Check); - let _ = std::fs::remove_dir_all(&dir); - assert!( - outcome.is_ok(), - "grading a pep rule to warning must be valid config" - ); - Ok(()) -} - -// ── config discovery (GitHub #311, [CHKARCH-CONFIG-DISCOVERY]) ──────────── - -/// Source that violates the annotation house rules (BSK-0001 on the -/// parameter, BSK-0002 on the return) once those opt-in rules are enabled. -// `x` has no default to infer from (BSK-0001) and `return x` is not inferable -// (BSK-0002) — a `pass` body would infer `-> None` and only fire BSK-0001 -// ([TYPEINF-FUNC-RETURN]). -const UNANNOTATED_FN: &[u8] = b"def foo(x):\n return x\n"; - -/// A `[tool.basilisk.rules]` table enabling the opt-in annotation rules. -const ANNOTATION_RULES_TOML: &[u8] = - b"[tool.basilisk.rules]\n\"BSK-0001\" = \"error\"\n\"BSK-0002\" = \"error\"\n"; - -/// GitHub #311 (headline): `basilisk analyze path/to/file.py` must discover -/// rule config from ancestor directories, so a repo-root `pyproject.toml` -/// governs a file checked by path. -#[test] -fn analyze_file_arg_discovers_config_from_ancestor_directories( -) -> Result<(), Box> { - let root = unique_project_dir("basilisk_cli_cfg_ancestor"); - let child = root.join("child"); - std::fs::create_dir_all(&child)?; - std::fs::write(root.join("pyproject.toml"), ANNOTATION_RULES_TOML)?; - let py = child.join("bad.py"); - std::fs::write(&py, UNANNOTATED_FN)?; - - let outcome = collect_uncached( - &[py.to_string_lossy().into_owned()], - DiagnosticScope::Analyze, - ); - let _ = std::fs::remove_dir_all(&root); - let outcome = outcome.map_err(|err| err.to_string())?; - - let codes: Vec<&str> = outcome.diagnostics.iter().map(|d| d.code.code).collect(); - assert!( - codes.contains(&"BSK-0001"), - "checking child/bad.py by file path must apply the root pyproject.toml \ - (ancestor walk, GitHub #311); got codes: {codes:?}" - ); - Ok(()) -} - -/// GitHub #311 (consequence 3): results must not depend on argument order. -/// With rules in `p/pyproject.toml` and none in `q`, both `analyze p q` and -/// `analyze q p` must flag `p/bad.py` — and never flag `q/bad.py`. -#[test] -fn results_are_independent_of_argument_order() -> Result<(), Box> { - let base = unique_project_dir("basilisk_cli_cfg_order"); - let p = base.join("p"); - let q = base.join("q"); - std::fs::create_dir_all(&p)?; - std::fs::create_dir_all(&q)?; - std::fs::write(p.join("pyproject.toml"), ANNOTATION_RULES_TOML)?; - std::fs::write(p.join("bad.py"), UNANNOTATED_FN)?; - std::fs::write(q.join("bad.py"), UNANNOTATED_FN)?; - - let p_arg = p.to_string_lossy().into_owned(); - let q_arg = q.to_string_lossy().into_owned(); - let p_first = collect_uncached(&[p_arg.clone(), q_arg.clone()], DiagnosticScope::Analyze); - let q_first = collect_uncached(&[q_arg, p_arg], DiagnosticScope::Analyze); - let _ = std::fs::remove_dir_all(&base); - - for (order, outcome) in [ - ("analyze p q", p_first.map_err(|err| err.to_string())?), - ("analyze q p", q_first.map_err(|err| err.to_string())?), - ] { - let e0001_paths: Vec<&str> = outcome - .diagnostics - .iter() - .filter(|d| d.code.code == "BSK-0001") - .map(|d| d.path.as_str()) - .collect(); - assert!( - e0001_paths - .iter() - .any(|path| std::path::Path::new(path).starts_with(&p)), - "`{order}` must apply p's own config to p/bad.py regardless of \ - argument order (GitHub #311); BSK-0001 paths: {e0001_paths:?}" - ); - assert!( - e0001_paths - .iter() - .all(|path| !std::path::Path::new(path).starts_with(&q)), - "`{order}` must NOT leak p's config onto q/bad.py, which has no \ - config anywhere above it (GitHub #311); BSK-0001 paths: {e0001_paths:?}" - ); - } - Ok(()) -} - -/// [CHKARCH-CONFIG-MODEL]: the nearest table that decides a rule wins, per -/// rule. The root enables both annotation rules; the child only disables -/// BSK-0001, so BSK-0002 is still decided by the root and must fire. -#[test] -fn nearest_deciding_table_wins_per_rule() -> Result<(), Box> { - let root = unique_project_dir("basilisk_cli_cfg_nearest"); - let child = root.join("child"); - std::fs::create_dir_all(&child)?; - std::fs::write(root.join("pyproject.toml"), ANNOTATION_RULES_TOML)?; - std::fs::write( - child.join("pyproject.toml"), - b"[tool.basilisk.rules]\n\"BSK-0001\" = \"disabled\"\n", - )?; - let py = child.join("bad.py"); - std::fs::write(&py, UNANNOTATED_FN)?; - - let outcome = collect_uncached( - &[py.to_string_lossy().into_owned()], - DiagnosticScope::Analyze, - ); - let _ = std::fs::remove_dir_all(&root); - let outcome = outcome.map_err(|err| err.to_string())?; - - let codes: Vec<&str> = outcome.diagnostics.iter().map(|d| d.code.code).collect(); - assert!( - codes.contains(&"BSK-0002"), - "the root's rule opt-ins must survive a child table that only decides \ - one rule (nearest-deciding-table, [CHKARCH-CONFIG-MODEL]); got: {codes:?}" - ); - assert!( - !codes.contains(&"BSK-0001"), - "the child table's `BSK-0001 = disabled` must be honored; got: {codes:?}" - ); - Ok(()) -} - -#[test] -fn collect_and_check_returns_no_diagnostics_for_clean_code( -) -> Result<(), Box> { - let dir = std::env::temp_dir(); - let py = dir.join("basilisk_test_clean_code.py"); - std::fs::write(&py, b"def greet(name: str) -> str:\n return name\n")?; - let path = py.to_string_lossy().into_owned(); - let outcome = - collect_uncached(&[path], DiagnosticScope::Union).map_err(|err| err.to_string())?; - let _ = std::fs::remove_file(&py); - assert!( - outcome.diagnostics.is_empty(), - "fully annotated code must produce no diagnostics" - ); - Ok(()) -} - -// ── Self-named external bases (issues #278/#299 family) ────────────────── - -/// `class EnvironBuilder(werkzeug.test.EnvironBuilder)` — flask 3.1.1 -/// `src/flask/testing.py`, hit by the [VSIX-REALWORLD-JOURNEY] flask corpus — -/// records its unresolved external base under the class's own terminal name. -/// On the FULL CLI pipeline (which, unlike the bare parse→resolve→check test -/// harness, first runs `resolve_module_imports`) the base walk must not -/// recurse through the self-referential class-map entry: before the fix this -/// stack-overflowed and aborted the whole `basilisk check` process (and -/// crash-looped the LSP on the same code path). -#[test] -fn self_named_attribute_base_check_does_not_overflow() -> Result<(), Box> { - let dir = unique_project_dir("bsk_selfnamed_attr_base"); - std::fs::create_dir_all(&dir)?; - let py = dir.join("repro.py"); - std::fs::write( - &py, - b"import werkzeug.test\n\n\nclass EnvironBuilder(werkzeug.test.EnvironBuilder):\n pass\n", - )?; - let path = py.to_string_lossy().into_owned(); - let outcome = - collect_uncached(&[path], DiagnosticScope::Check).map_err(|err| err.to_string())?; - let _ = std::fs::remove_dir_all(&dir); - assert!( - outcome.failures.is_empty(), - "self-named external base must analyse cleanly, got failures: {:?}", - outcome.failures - ); - assert_eq!( - outcome.sources.len(), - 1, - "exactly the repro file is checked" - ); - Ok(()) -} - -// ── pluralise ───────────────────────────────────────────────────────────── - -#[test] -fn pluralise_zero_returns_s() { - assert_eq!(pluralise(0), "s"); -} - -#[test] -fn pluralise_one_returns_empty() { - assert_eq!(pluralise(1), ""); -} - -#[test] -fn pluralise_many_returns_s() { - assert_eq!(pluralise(5), "s"); -} - -#[test] -fn pipeline_errors_preserve_the_exit_code_category_in_display() { - assert_eq!( - PipelineError::Config("bad target".to_owned()).to_string(), - "invalid configuration: bad target" - ); - assert_eq!( - PipelineError::Internal("read failed".to_owned()).to_string(), - "read failed" - ); - assert_eq!( - PipelineError::NoSource("NO SOURCE — missing".to_owned()).to_string(), - "NO SOURCE — missing" - ); -} - -#[test] -fn analysis_roots_adds_a_distinct_checked_directory() -> Result<(), Box> { - let project = tempfile::tempdir()?; - let checked = tempfile::tempdir()?; - let source = checked.path().join("module.py"); - std::fs::write(&source, "value: int = 1\n")?; - - let roots = analysis_roots(&[source.to_string_lossy().into_owned()], project.path()); - - assert_eq!(roots.len(), 2); - assert!(roots.contains(&std::fs::canonicalize(checked.path())?)); - Ok(()) -} - -#[test] -fn non_not_found_metadata_errors_are_skipped_without_aborting_other_roots() { - let invalid = "path-with-nul\0.py".to_owned(); - let files = collect_python_files(&[invalid], &test_excludes()); - - assert!(matches!(files, Ok(found) if found.is_empty())); -} diff --git a/crates/basilisk-cli/src/pipeline/typeshed.rs b/crates/basilisk-cli/src/pipeline/typeshed.rs deleted file mode 100644 index 442769364..000000000 --- a/crates/basilisk-cli/src/pipeline/typeshed.rs +++ /dev/null @@ -1,542 +0,0 @@ -//! CLI activation and reporting for [STUBRES-TYPESHED-WARN]. -//! See docs/specs/CHECKER-STUB-RESOLUTION-SPEC.md#STUBRES-TYPESHED-WARN. - -use basilisk_config::{BasiliskConfig, RuleSeverity}; -use tracing::{debug, info, warn}; - -use super::PipelineError; - -/// Load import/typeshed configuration and preserve CLI-only target evidence. -/// -/// The rule configuration loader also consults `.python-version`; the shared -/// LSP import configuration does not. Copying that detected value before -/// activation keeps stdlib filtering aligned with the checker target while an -/// explicit analysis configuration still wins. [STUBRES-TYPESHED-VERSION] -pub(super) fn load_cli_workspace_config( - project_root: &std::path::Path, - detected_python_version: Option<&str>, -) -> basilisk_lsp::config::WorkspaceConfig { - let mut config = basilisk_lsp::config::load_analysis_config(project_root); - if config.python_version.is_none() { - config.python_version = detected_python_version.map(str::to_owned); - } - if config.python_platform.is_none() { - config.python_platform = - basilisk_lsp::debug::python_platform_evidence(config.python_interpreter.as_deref()); - } - // [STUBRES-TYPESHED-PYPI] (issue #312): when no typeshed source is - // configured, auto-resolve a `PyPI` typeshed distribution pin from - // `uv.lock` so a uv-pinned project is reproducible without an explicit - // `typeshed-package` key. No-op for non-uv projects. - basilisk_lsp::config::apply_uv_typeshed_override(&mut config, project_root); - config -} - -/// Build the shared CLI/LSP import search path model for a project. -pub(crate) fn build_import_search_paths( - roots: Vec, - project_root: &std::path::Path, -) -> basilisk_lsp::import_resolver::ImportSearchPaths { - let config = basilisk_lsp::config::load_analysis_config(project_root); - build_import_search_paths_with_config(roots, &config) -} - -pub(super) fn build_import_search_paths_with_config( - roots: Vec, - config: &basilisk_lsp::config::WorkspaceConfig, -) -> basilisk_lsp::import_resolver::ImportSearchPaths { - let registry = build_uv_registry(&roots); - let mut search_paths = - basilisk_lsp::import_resolver::search_paths_from_config(&roots, config, registry); - search_paths.roots = roots; - info!( - site_packages = ?search_paths.site_packages, - has_registry = search_paths.registry.is_some(), - "built import search paths" - ); - 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, - rule_config: &BasiliskConfig, -) -> Result<(), PipelineError> { - let request = basilisk_lsp::config::typeshed_request(config).map_err(PipelineError::Config)?; - let target = basilisk_lsp::import_resolver::stub_target_from_config(config); - if let Some(active) = deferred_bundled_activation(&request, target.clone(), rule_config) { - search_paths.typeshed_snapshot = Some(active); - return Ok(()); - } - let manager = basilisk_stubs::typeshed::runtime::production_manager(request); - let snapshot = manager.snapshot().map_err(|error| match error { - // A terminal source failure (missing/corrupt pin, missing/corrupt - // `PyPI` package) is a user-actionable `NO SOURCE`, not an internal - // bug — the message carries the recovery command - // ([STUBRES-TYPESHED-OFFLINE]). - basilisk_stubs::typeshed::selector::SelectionError::NoSource { .. } - | basilisk_stubs::typeshed::selector::SelectionError::PyPIPackage { .. } - | basilisk_stubs::typeshed::selector::SelectionError::Custom(_) => { - PipelineError::NoSource(error.to_string()) - } - // A backend handing back a source it was not asked for is a Basilisk - // bug, not something the user can fix — the only genuinely internal - // selection failure. - inconsistent @ basilisk_stubs::typeshed::selector::SelectionError::InconsistentIdentity => { - PipelineError::Internal(inconsistent.to_string()) - } - })?; - report_typeshed_status(&snapshot.status, rule_config, &mut std::io::stderr().lock()); - search_paths.typeshed_snapshot = Some(basilisk_checker::imports::ActiveTypeshed::new( - snapshot, target, - )); - Ok(()) -} - -/// Activate a pin of the BUNDLED commit without blocking on the archive: the -/// identity and status are manifest metadata (`bundled_pinned_status`, pinned -/// equal to the selector's status under test), so the banner prints -/// immediately while a background thread decodes the snapshot and prewarms -/// the builtins index. The pipeline lead-in (file collection, config -/// discovery, source parsing) runs concurrently, and a fully cache-hit run -/// never waits for the archive at all. Every other selection — custom trees, -/// non-bundled pins — resolves eagerly, so its verification and error -/// surfacing are unchanged. A deferred load failure is surfaced loudly at the -/// end of the run via `ActiveTypeshed::deferred_error`. -fn deferred_bundled_activation( - request: &basilisk_stubs::typeshed::source::TypeshedRequest, - target: Option, - rule_config: &BasiliskConfig, -) -> Option { - let basilisk_stubs::typeshed::source::SourceSelection::Pinned { commit, explicit } = - &request.selection - else { - return None; - }; - if commit.to_hex() != basilisk_stubs::typeshed::bundle::bundled_commit_sha() { - return None; - } - // Must match `SourceIdentity::Bundled.uri_component()` so fingerprints - // and equality agree with the eager path. - let identity = format!("bundled-{}", commit.to_hex()); - let status = basilisk_stubs::typeshed::bundle::bundled_pinned_status(*explicit).ok()?; - report_typeshed_status(&status, rule_config, &mut std::io::stderr().lock()); - let thread_request = request.clone(); - let thread_target = target.clone(); - let loader = std::thread::spawn(move || { - let manager = basilisk_stubs::typeshed::runtime::production_manager(thread_request); - let snapshot = manager.snapshot().map_err(|error| error.to_string())?; - basilisk_checker::imports::prewarm_builtin_classes(&snapshot, thread_target.as_ref()); - Ok(snapshot) - }); - Some(basilisk_checker::imports::ActiveTypeshed::deferred( - identity, - target, - move || { - loader - .join() - .unwrap_or_else(|_panic| Err("typeshed loader thread panicked".to_owned())) - }, - )) -} - -/// Report the resolved typeshed source status. -/// -/// Two distinct surfaces, never conflated ([STUBRES-TYPESHED-WARN]): -/// * structured **telemetry** at `debug` for the log file / Output Channel — -/// the machine `active_source`/identity fields, never a human banner; -/// * a rustc-style **human banner** on `banner` (stderr in production) — one -/// `[]: ` block per advisory with a `= see:` link to -/// its `/errors/` page, rendered at the severity the project's -/// `[tool.basilisk]` tables resolve for that code and skipped entirely when a -/// table grades it `disabled` ([STUBRES-TYPESHED-CONFIG]). -/// -/// Neither surface is stdout, so these advisories can never enter the JSON -/// diagnostics a conformance run scores ([STUBRES-TYPESHED-WARN]). -fn report_typeshed_status( - status: &basilisk_stubs::typeshed::source::TypeshedStatus, - rule_config: &BasiliskConfig, - banner: &mut impl std::io::Write, -) { - let commit_identity = status - .commit - .map_or_else(|| "not supplied".to_owned(), |identity| identity.to_hex()); - let tree_identity = status - .tree - .map_or_else(|| "not supplied".to_owned(), |identity| identity.to_hex()); - let license_reference = status - .license_reference - .as_deref() - .unwrap_or("not supplied"); - debug!( - active_source = status.active_source.as_str(), - commit_identity, - tree_identity, - license_status = ?status.license_status, - license_reference, - "typeshed source status" - ); - for warning in &status.warnings { - let severity = basilisk_lsp::config::resolve_status_severity( - rule_config, - &warning.code, - warning.severity, - ); - if severity == RuleSeverity::Disabled { - debug!( - warning_code = warning.code, - "typeshed source advisory silenced by config" - ); - continue; - } - let _ = writeln!( - banner, - "{}[{}]: {}", - severity.as_str(), - warning.code, - warning.message - ); - let _ = writeln!(banner, " = see: {}", warning.docs_url); - } -} - -/// Build a uv package registry from workspace roots, if this is a uv project. -fn build_uv_registry( - roots: &[std::path::PathBuf], -) -> Option> { - let uv_info = basilisk_uv::detect_uv_project(roots)?; - - if !uv_info.has_lockfile { - info!( - root = %uv_info.root.display(), - "uv project detected but no uv.lock — skipping registry" - ); - return None; - } - - let lock_path = uv_info.root.join("uv.lock"); - let lock_file = match basilisk_uv::parse_lock_file(&lock_path) { - Ok(lock) => lock, - Err(err) => { - warn!( - path = %lock_path.display(), - %err, - "failed to parse uv.lock — package registry unavailable" - ); - return None; - } - }; - - let deps = basilisk_uv::extract_pyproject_deps(&uv_info.root); - let registry = basilisk_uv::PackageRegistry::from_lock_file(&lock_file, &deps); - - let pkg_count = registry.all_packages().count(); - info!( - root = %uv_info.root.display(), - packages = pkg_count, - direct_deps = deps.len(), - "built uv package registry" - ); - - Some(std::sync::Arc::new(registry)) -} - -#[cfg(test)] -mod tests { - use std::sync::{Arc, Mutex}; - - use super::{build_uv_registry, load_cli_workspace_config, report_typeshed_status}; - - #[derive(Clone, Default)] - struct Capture(Arc>>); - - struct CaptureWriter(Arc>>); - - impl std::io::Write for CaptureWriter { - fn write(&mut self, bytes: &[u8]) -> std::io::Result { - let mut output = self - .0 - .lock() - .map_err(|error| std::io::Error::other(error.to_string()))?; - output.extend_from_slice(bytes); - Ok(bytes.len()) - } - - fn flush(&mut self) -> std::io::Result<()> { - Ok(()) - } - } - - impl<'writer> tracing_subscriber::fmt::MakeWriter<'writer> for Capture { - type Writer = CaptureWriter; - - fn make_writer(&'writer self) -> Self::Writer { - CaptureWriter(Arc::clone(&self.0)) - } - } - - impl Capture { - fn text(&self) -> Result> { - let bytes = self - .0 - .lock() - .map_err(|error| std::io::Error::other(error.to_string()))? - .clone(); - Ok(String::from_utf8(bytes)?) - } - } - - #[test] - fn cli_detected_python_version_fills_missing_analysis_target( - ) -> Result<(), Box> { - let project = tempfile::tempdir()?; - std::fs::write(project.path().join(".python-version"), "3.12\n")?; - let detected = basilisk_uv::python_version::resolve_target_python_version(project.path()); - let config = load_cli_workspace_config(project.path(), detected.as_deref()); - assert_eq!(config.python_version.as_deref(), Some("3.12")); - Ok(()) - } - - #[test] - fn explicit_analysis_target_wins_over_cli_detected_version( - ) -> Result<(), Box> { - let project = tempfile::tempdir()?; - std::fs::write( - project.path().join("pyproject.toml"), - "[tool.basilisk]\npython-version = \"3.10\"\n", - )?; - let config = load_cli_workspace_config(project.path(), Some("3.12")); - assert_eq!(config.python_version.as_deref(), Some("3.10")); - Ok(()) - } - - #[cfg(unix)] - #[test] - fn selected_interpreter_supplies_platform_target_evidence( - ) -> Result<(), Box> { - use std::os::unix::fs::PermissionsExt; - - let project = tempfile::tempdir()?; - let interpreter = project.path().join("python"); - std::fs::write(&interpreter, "#!/bin/sh\nprintf 'fixture-platform\\n'\n")?; - std::fs::set_permissions(&interpreter, std::fs::Permissions::from_mode(0o755))?; - std::fs::write( - project.path().join("pyproject.toml"), - format!("[tool.basilisk]\npython = '{}'\n", interpreter.display()), - )?; - - let config = load_cli_workspace_config(project.path(), None); - - assert_eq!( - config.python_platform.as_deref(), - Some("fixture-platform"), - "an explicitly selected interpreter is real target evidence for sys.platform" - ); - Ok(()) - } - - #[cfg(unix)] - #[test] - fn explicit_all_platform_keeps_cross_platform_target() -> Result<(), Box> - { - use std::os::unix::fs::PermissionsExt; - - let project = tempfile::tempdir()?; - let interpreter = project.path().join("python"); - std::fs::write(&interpreter, "#!/bin/sh\nprintf 'fixture-platform\\n'\n")?; - std::fs::set_permissions(&interpreter, std::fs::Permissions::from_mode(0o755))?; - std::fs::write( - project.path().join("pyproject.toml"), - format!( - "[tool.basilisk]\npython = '{}'\npython-platform = 'All'\n", - interpreter.display() - ), - )?; - - let config = load_cli_workspace_config(project.path(), None); - - assert_eq!(config.python_platform.as_deref(), Some("All")); - Ok(()) - } - - #[test] - fn uv_detection_without_a_lockfile_does_not_build_a_registry( - ) -> Result<(), Box> { - let project = tempfile::tempdir()?; - std::fs::write(project.path().join(".python-version"), "3.13\n")?; - - assert!(build_uv_registry(&[project.path().to_path_buf()]).is_none()); - Ok(()) - } - - #[test] - fn malformed_uv_lockfile_does_not_build_a_partial_registry( - ) -> Result<(), Box> { - let project = tempfile::tempdir()?; - std::fs::write(project.path().join("uv.lock"), "not valid TOML = [")?; - - assert!(build_uv_registry(&[project.path().to_path_buf()]).is_none()); - Ok(()) - } - - #[test] - fn valid_uv_lockfile_builds_the_registry() -> Result<(), Box> { - let project = tempfile::tempdir()?; - std::fs::write( - project.path().join("uv.lock"), - "version = 1\n\n[[package]]\nname = 'example'\nversion = '1.0.0'\n", - )?; - - let registry = build_uv_registry(&[project.path().to_path_buf()]) - .ok_or("valid uv.lock should build a registry")?; - assert_eq!(registry.all_packages().count(), 1); - Ok(()) - } - - fn composed_status( - ) -> Result> { - 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::LicenseChanged, - TypeshedWarning::UserManaged, - TypeshedWarning::Unpinned(UnpinnedKind::CustomFolder), - ]); - Ok(status) - } - - /// [STUBRES-TYPESHED-WARN]: the human banner reads like every other Basilisk - /// diagnostic — `[]: ` plus a `= see:` - /// deep link — in canonical status-table order, NOT `key="VALUE"` telemetry. - #[test] - fn composed_status_warnings_render_as_ordered_banner_diagnostics( - ) -> Result<(), Box> { - let status = composed_status()?; - let config = basilisk_config::BasiliskConfig::default(); - let mut banner = Vec::new(); - report_typeshed_status(&status, &config, &mut banner); - let banner = String::from_utf8(banner)?; - - // Advisory conditions default to `warning`; the elevated license change - // keeps its intrinsic `error` default ([STUBRES-TYPESHED-CONFIG]). - for header in [ - "warning[typeshed_source_unpinned]:", - "warning[typeshed_source_user_managed]:", - "error[typeshed_source_license_changed]:", - ] { - assert!( - banner.contains(header), - "missing banner header `{header}`: {banner}" - ); - } - // Every advisory deep-links to its own /errors/ page. - assert!( - banner - .matches("= see: https://www.basilisk-python.dev/errors/typeshed_source_") - .count() - == 3, - "each advisory must carry its own = see: link: {banner}" - ); - // Canonical status-table order is preserved on the banner. - let unpinned = banner.find("typeshed_source_unpinned"); - let user_managed = banner.find("typeshed_source_user_managed"); - let license = banner.find("typeshed_source_license_changed"); - assert!( - unpinned - .zip(user_managed) - .zip(license) - .is_some_and(|((first, second), third)| first < second && second < third), - "status warnings must stay in canonical order: {banner}" - ); - // The old `key="VALUE"` telemetry spelling must never resurface on the - // human banner. - assert!( - !banner.contains("warning_code=") && !banner.contains("warning_message="), - "banner must not read like CLI-arg telemetry: {banner}" - ); - Ok(()) - } - - /// [STUBRES-TYPESHED-CONFIG]: these advisories are configured exactly like - /// any Basilisk rule — a `[tool.basilisk.rules]` entry raises the severity - /// the banner prints, and grading a code `off` silences it entirely. - #[test] - fn config_tables_set_banner_severity_and_can_silence_advisories( - ) -> Result<(), Box> { - let status = composed_status()?; - let project = tempfile::tempdir()?; - std::fs::write( - project.path().join("pyproject.toml"), - "[tool.basilisk.rules]\n\ - \"typeshed_source_unpinned\" = \"error\"\n\ - \"typeshed_source_user_managed\" = \"off\"\n", - )?; - let config = basilisk_config::load_basilisk_config(project.path()); - - let mut banner = Vec::new(); - report_typeshed_status(&status, &config, &mut banner); - let banner = String::from_utf8(banner)?; - - assert!( - banner.contains("error[typeshed_source_unpinned]:"), - "a `[tool.basilisk.rules]` entry must raise the banner severity: {banner}" - ); - assert!( - !banner.contains("typeshed_source_user_managed"), - "grading a code `off` must silence its advisory: {banner}" - ); - // A code with no entry keeps its intrinsic default (license drift = error). - assert!( - banner.contains("error[typeshed_source_license_changed]:"), - "an elevated advisory keeps its `error` default: {banner}" - ); - Ok(()) - } - - /// [STUBRES-TYPESHED-WARN]: the machine identity fields stay on the - /// structured `debug` telemetry surface (log file / Output Channel), never - /// on the human banner. - #[test] - fn status_reporting_emits_structured_debug_telemetry() -> Result<(), Box> - { - let mut status = basilisk_stubs::typeshed::bundle::bundled_snapshot()?.status; - status.commit = None; - status.tree = None; - status.license_reference = None; - status.warnings.clear(); - let config = basilisk_config::BasiliskConfig::default(); - let capture = Capture::default(); - let subscriber = tracing_subscriber::fmt() - .with_ansi(false) - .without_time() - .with_target(false) - .with_max_level(tracing::Level::DEBUG) - .with_writer(capture.clone()) - .finish(); - - let mut banner = Vec::new(); - tracing::subscriber::with_default(subscriber, || { - report_typeshed_status(&status, &config, &mut banner); - }); - - let telemetry = capture.text()?; - assert!(telemetry.contains("typeshed source status"), "{telemetry}"); - for field in [ - "commit_identity=\"not supplied\"", - "tree_identity=\"not supplied\"", - "license_reference=\"not supplied\"", - ] { - assert!( - telemetry.contains(field), - "missing `{field}` in: {telemetry}" - ); - } - Ok(()) - } -} diff --git a/crates/basilisk-cli/src/stubs.rs b/crates/basilisk-cli/src/stubs.rs deleted file mode 100644 index 4375c84b6..000000000 --- a/crates/basilisk-cli/src/stubs.rs +++ /dev/null @@ -1,426 +0,0 @@ -//! Stub-management CLI implementation for [STUBRES-AUTOGEN]. - -use std::collections::BTreeMap; -use std::path::{Path, PathBuf}; - -use basilisk_resolver::{ImportInfo, ImportResolution}; -use basilisk_stubs::generate::{self, GeneratedStub, StubGenError, StubGenMode}; -use clap::{Args, Subcommand}; -use colored::Colorize as _; -use tracing::info; - -/// Stub management subcommands. -#[derive(Subcommand)] -pub(super) enum StubAction { - /// Generate best-effort `.pyi` stubs for untyped packages. - Generate { - /// Package names to generate stubs for. - packages: Vec, - /// Generate stubs for every untyped import in the project. - #[arg(long, conflicts_with = "packages")] - all: bool, - /// Generation mode: runtime, ast, or hybrid (default). - #[arg(long, default_value = "hybrid")] - mode: StubGenModeArg, - /// Path to the Python interpreter. - #[arg(long, default_value = "python3")] - python: String, - }, - /// Show stub coverage status for the project. - Status, -} - -/// Arguments accepted by the Pyright-compatible `--createstub` alias. -#[derive(Args)] -pub(super) struct CreateStubArgs { - /// Package name to generate a stub for. - package: String, - /// Generation mode: runtime, ast, or hybrid (default). - #[arg(long, default_value = "hybrid")] - mode: StubGenModeArg, - /// Path to the Python interpreter. - #[arg(long, default_value = "python3")] - python: String, -} - -/// CLI-friendly stub generation mode. -#[derive(Clone, Copy, Debug, clap::ValueEnum)] -pub(super) enum StubGenModeArg { - /// Generate through runtime introspection. - Runtime, - /// Generate by parsing package source. - Ast, - /// Prefer runtime introspection and fall back to source parsing. - Hybrid, -} - -impl From for StubGenMode { - fn from(mode: StubGenModeArg) -> Self { - match mode { - StubGenModeArg::Runtime => Self::Runtime, - StubGenModeArg::Ast => Self::Ast, - StubGenModeArg::Hybrid => Self::Hybrid, - } - } -} - -struct GenerationTarget { - module: String, - source_path: Option, -} - -/// Run a nested `stubs` command. -pub(super) fn run(action: StubAction) -> u8 { - match action { - StubAction::Generate { - packages, - all, - mode, - python, - } => run_generate(&packages, all, mode, &python), - StubAction::Status => run_status(), - } -} - -/// Map Pyright's top-level spelling to the named-package generation workflow. -// Implements [STUBRES-AUTOGEN]: `basilisk --createstub X` and -// `basilisk stubs generate X` share one backend and output contract. -pub(super) fn run_create_stub(args: CreateStubArgs) -> u8 { - run_generate(&[args.package], false, args.mode, &args.python) -} - -fn run_generate(packages: &[String], all: bool, mode: StubGenModeArg, python: &str) -> u8 { - let project_root = crate::pipeline::find_project_root(Path::new(".")); - let python_path = Path::new(python); - let targets = match generation_targets(packages, all, python_path, &project_root) { - Ok(targets) => targets, - Err(message) => { - eprintln!("{}: {message}", "error".red()); - return 1; - } - }; - if targets.is_empty() { - println!("No untyped imports found"); - return 0; - } - let cache_dir = project_root.join(generate::cache::DEFAULT_CACHE_DIR); - let mut failed = false; - for target in &targets { - failed |= !generate_target(target, mode.into(), python_path, &cache_dir); - } - u8::from(failed) -} - -fn generation_targets( - packages: &[String], - all: bool, - python_path: &Path, - project_root: &Path, -) -> Result, String> { - if all && !packages.is_empty() { - return Err("--all cannot be combined with package names".to_owned()); - } - if all { - return discover_untyped_imports(project_root); - } - if packages.is_empty() { - return Err("specify package names or use --all".to_owned()); - } - Ok(packages - .iter() - .map(|module| GenerationTarget { - module: module.clone(), - source_path: find_package_source(module, python_path), - }) - .collect()) -} - -// Implements [STUBRES-AUTOGEN]: scan the configured project inputs with the -// same parser, resolver, exclusions, and import search paths as `check`, then -// generate only imports that resolve to untyped site-packages source. -fn discover_untyped_imports(project_root: &Path) -> Result, String> { - let config = basilisk_config::load_basilisk_config(project_root); - let excluded = crate::pipeline::excluded_dirs_and_log(&config, project_root); - let paths = crate::pipeline::effective_check_paths(&[], &config, project_root); - let files = crate::pipeline::collect_python_files(&paths, &excluded)?; - let roots = crate::pipeline::analysis_roots(&paths, project_root); - let search_paths = crate::pipeline::build_import_search_paths(roots, project_root); - let Some(site_packages) = search_paths.site_packages.as_deref() else { - return Ok(Vec::new()); - }; - let mut targets = BTreeMap::new(); - for file in files { - collect_file_targets(&file, &search_paths, site_packages, &mut targets)?; - } - Ok(targets - .into_iter() - .map(|(module, source_path)| GenerationTarget { - module, - source_path: Some(source_path), - }) - .collect()) -} - -fn collect_file_targets( - file: &str, - search_paths: &basilisk_lsp::import_resolver::ImportSearchPaths, - site_packages: &Path, - targets: &mut BTreeMap, -) -> Result<(), String> { - let (resolved, _) = crate::pipeline::resolve_file_imports(file, search_paths, None)?; - resolved - .imports - .iter() - .filter(|import| is_untyped_third_party_import(import, site_packages)) - .filter_map(|import| { - import - .resolved_path - .as_ref() - .map(|path| (import.module.clone(), path.clone())) - }) - .for_each(|(module, path)| { - let _ = targets.entry(module).or_insert(path); - }); - Ok(()) -} - -fn is_untyped_third_party_import(import: &ImportInfo, site_packages: &Path) -> bool { - import.resolution == ImportResolution::SourcePy - && import.resolved_path.as_ref().is_some_and(|path| { - path.starts_with(site_packages) && !basilisk_stubs::has_py_typed_marker(path) - }) -} - -fn generate_target( - target: &GenerationTarget, - mode: StubGenMode, - python_path: &Path, - cache_dir: &Path, -) -> bool { - let result = match target.source_path.as_deref() { - Some(source) => { - info!(module = %target.module, source = %source.display(), "generating stubs"); - generate::generate_stubs(&target.module, source, python_path, mode) - } - None if mode == StubGenMode::Ast => { - eprintln!( - "{} Cannot find source for `{}` — AST mode requires source files", - "✗".red(), - target.module - ); - return false; - } - None => generate::runtime::generate_runtime_stubs(&target.module, python_path), - }; - cache_generation_result(cache_dir, &target.module, result) -} - -fn cache_generation_result( - cache_dir: &Path, - package: &str, - result: Result, -) -> bool { - match result { - Ok(stub) => cache_stub(cache_dir, package, &stub), - Err(error) => { - eprintln!( - "{} Failed to generate stub for `{package}`: {error}", - "✗".red() - ); - false - } - } -} - -/// Cache a generated stub and print the result. -pub(super) fn cache_stub(cache_dir: &Path, package: &str, stub: &GeneratedStub) -> bool { - // A declaration-free stub carries no type information. Writing it would - // report a false "✓ Generated" success AND let the empty `.pyi` satisfy - // BSK-0152 as though the module were typed (GitHub #336). Surface it as a - // warning and write nothing — there is no stub worth caching. - if !stub.has_declarations() { - println!( - "{} `{package}` exposed no introspectable public API — no stub written", - "⚠".yellow() - ); - return true; - } - - let source_hash = generate::cache::hash_source(&stub.pyi_content); - match generate::cache::write_cache(cache_dir, package, &stub.pyi_content, source_hash) { - Ok(path) => { - println!( - "{} Generated stub for `{package}` → {}", - "✓".green(), - path.display() - ); - true - } - Err(error) => { - eprintln!( - "{} Failed to write stub for `{package}`: {error}", - "✗".red() - ); - false - } - } -} - -/// Import a module named by `sys.argv[1]` and print its source path. -const FIND_PACKAGE_SOURCE_SCRIPT: &str = r#" -import importlib -import sys - -module = importlib.import_module(sys.argv[1]) -source = getattr(module, "__file__", None) -if source is None: - raise SystemExit(1) -print(source) -"#; - -fn is_valid_module_name(name: &str) -> bool { - name.split('.').all(|component| { - let mut chars = component.chars(); - chars - .next() - .is_some_and(|first| first == '_' || first.is_ascii_alphabetic()) - && chars.all(|character| character == '_' || character.is_ascii_alphanumeric()) - }) -} - -/// Find the source path for an installed package by querying Python. -pub(super) fn find_package_source(package: &str, python_path: &Path) -> Option { - if !is_valid_module_name(package) { - return None; - } - let output = std::process::Command::new(python_path) - .args(["-c", FIND_PACKAGE_SOURCE_SCRIPT, package]) - .output() - .ok()?; - if !output.status.success() { - return None; - } - let source = PathBuf::from(String::from_utf8_lossy(&output.stdout).trim()); - source.is_file().then_some(source).filter(|path| { - path.extension() - .is_some_and(|extension| extension.eq_ignore_ascii_case("py")) - }) -} - -fn run_status() -> u8 { - let project_root = crate::pipeline::find_project_root(Path::new(".")); - 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) - .into_iter() - .filter_map(Result::ok) - .filter(|entry| entry.file_type().is_file()) - .filter_map(|entry| stub_module_name(entry.path(), cache_dir)) - .collect(); - for module in &modules { - println!(" {} {module}", "✓".green()); - } - if modules.is_empty() { - println!("No generated stubs found"); - } else { - println!( - "\n{} generated stub(s) in {}", - modules.len(), - cache_dir.display() - ); - } - 0 -} - -fn stub_module_name(path: &Path, cache_dir: &Path) -> Option { - (path.extension()? == "pyi").then(|| { - path.strip_prefix(cache_dir) - .unwrap_or(path) - .with_extension("") - .components() - .map(|component| component.as_os_str().to_string_lossy()) - .collect::>() - .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 deleted file mode 100644 index ffc71a6f5..000000000 --- a/crates/basilisk-cli/src/typeshed_cli.rs +++ /dev/null @@ -1,550 +0,0 @@ -//! Implements the [STUBRES-TYPESHED-DOWNLOAD] CLI surface: -//! `basilisk typeshed download [--commit | --package >]`. -//! -//! 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, PackageDownloadPhase, PypiApi, PypiClient, -}; -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 source into the content-addressed - /// store. With no flag this resolves the latest `python/typeshed@main` and - /// writes the resolved SHA as the workspace's `typeshed-commit` pin; - /// `--commit` materialises that exact, already-configured pin and writes - /// no configuration; `--package` acquires a `PyPI` typeshed distribution - /// wheel pinned by SHA-256 and writes no configuration. - Download { - /// Exact full 40-hex commit SHA to download (defaults to latest). - #[arg(long, value_name = "SHA", conflicts_with = "package")] - commit: Option, - /// A `PyPI` typeshed distribution pin `name@sha256:` to download - /// and verify into the store. Mutually exclusive with `--commit`. - #[arg(long, value_name = "SPEC", conflicts_with = "commit")] - package: 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, - package, - workspace, - } => { - if let Some(spec) = package { - run_download_package(&spec, &workspace) - } else { - run_download(commit, &workspace) - } - } - } -} - -fn run_download(commit: Option, workspace: &Path) -> u8 { - let client = GithubClient::new(); - download_action(commit, workspace, &client) -} - -fn run_download_package(spec: &str, workspace: &Path) -> u8 { - let client = PypiClient::new(); - download_package_action(spec, 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), - } -} - -/// The `--package` download with its transport injected, so tests drive the -/// whole surface — config discovery, store resolution, progress — offline. -/// Writes no configuration: the pin (`typeshed-package`) is the caller's -/// contract, exactly like `--commit` ([STUBRES-TYPESHED-DOWNLOAD]). -fn download_package_action(spec: &str, workspace: &Path, api: &dyn PypiApi) -> u8 { - let (name, sha256) = match basilisk_config::parse_typeshed_package(spec) { - Ok(parsed) => parsed, - Err(message) => { - error!(spec, reason = %message, "--package must be name@sha256:<64-hex>"); - return 2; - } - }; - let config = basilisk_lsp::config::load_analysis_config(workspace); - let store = config.typeshed_store_path; - let progress = - |phase: PackageDownloadPhase| println!(" {}", package_phase_label(phase).dimmed()); - println!("Downloading {name}@sha256:{sha256} into the verified store…"); - match basilisk_typeshed_fetch::download_package(&name, &sha256, store, api, &progress) { - Ok(()) => { - println!( - "{} {}@sha256:{sha256} is now available offline", - "ok:".green().bold(), - name - ); - 0 - } - Err(download_error) => { - error!(%download_error, "typeshed download failed; nothing was written"); - 3 - } - } -} - -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), - ]), - }, - cache: basilisk_config::CacheConfigUpdate::default(), - }; - 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", - } -} - -const fn package_phase_label(phase: PackageDownloadPhase) -> &'static str { - match phase { - PackageDownloadPhase::Resolving => "resolving the package index", - PackageDownloadPhase::Verifying => "verifying the wheel against the pin", - PackageDownloadPhase::Writing => "writing the store entry", - } -} - -#[cfg(test)] -mod tests { - use basilisk_typeshed_fetch::testing::{ - fake_repo, fake_wheel, FakeApi, FakePypiApi, Faults, PypiFaults, - }; - - 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()), - package: None, - 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())); - } - - /// [STUBRES-TYPESHED-PYPI]: `basilisk typeshed download --package - /// >` acquires the wheel, verifies it, and writes no - /// configuration (the pin is the caller's contract). - #[test] - fn download_package_materialises_the_wheel_into_the_store( - ) -> Result<(), Box> { - let workspace = tempfile::tempdir()?; - let store = tempfile::tempdir()?; - std::fs::write( - workspace.path().join("pyproject.toml"), - format!( - "[tool.basilisk]\ntypeshed-store-path = \"{}\"\n", - store.path().display() - ), - )?; - let api = FakePypiApi::new(fake_wheel()); - let spec = format!("micropython-stdlib-stubs@sha256:{}", api.sha256); - assert_eq!(download_package_action(&spec, workspace.path(), &api), 0); - assert_eq!( - std::fs::read_dir(store.path())?.count(), - 1, - "exactly one verified store entry must exist" - ); - // No configuration is written for a package download. - let written = std::fs::read_to_string(workspace.path().join("pyproject.toml"))?; - assert!( - !written.contains("typeshed-package"), - "a package download must not write a pin: {written}" - ); - Ok(()) - } - - /// A malformed `--package` spec is a configuration error (exit `2`) before - /// any transport work — the parser the config surface shares validates it. - #[test] - fn a_malformed_package_spec_is_a_configuration_error() -> Result<(), Box> - { - let api = FakePypiApi::new(fake_wheel()); - let workspace = tempfile::tempdir()?; - assert_eq!( - download_package_action("not-a-spec", workspace.path(), &api), - 2 - ); - Ok(()) - } - - /// A download failure is exit `3` and writes nothing. - #[test] - fn a_package_download_failure_writes_nothing() -> Result<(), Box> { - let workspace = tempfile::tempdir()?; - let store = tempfile::tempdir()?; - std::fs::write( - workspace.path().join("pyproject.toml"), - format!( - "[tool.basilisk]\ntypeshed-store-path = \"{}\"\n", - store.path().display() - ), - )?; - let mut api = FakePypiApi::new(fake_wheel()); - api.faults = PypiFaults { - download_fails: true, - ..PypiFaults::default() - }; - let spec = format!("micropython-stdlib-stubs@sha256:{}", api.sha256); - assert_eq!(download_package_action(&spec, workspace.path(), &api), 3); - assert_eq!( - std::fs::read_dir(store.path())?.count(), - 0, - "nothing may be written on failure" - ); - Ok(()) - } - - /// Every `PyPI`-package phase renders a distinct, human-readable label. - #[test] - fn every_package_download_phase_has_a_distinct_label() { - let labels = [ - package_phase_label(PackageDownloadPhase::Resolving), - package_phase_label(PackageDownloadPhase::Verifying), - package_phase_label(PackageDownloadPhase::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/src/withdrawal_notice.txt b/crates/basilisk-cli/src/withdrawal_notice.txt new file mode 100644 index 000000000..b1859fcb2 --- /dev/null +++ b/crates/basilisk-cli/src/withdrawal_notice.txt @@ -0,0 +1,9 @@ +Basilisk is unlisted. Its type checker is inert and checks nothing. + +Basilisk's type checker was producing incorrect results. The code responsible is not isolated to a known set of rules, so we cannot say how many rules are affected. We asked for Basilisk to be removed from the python/typing conformance results, and it has been removed: https://github.com/python/typing/pull/2330 + +A code-quality tool that does not produce correct results is worse than useless. Remove Basilisk from your pipeline, your pre-commit hooks, and your editor. This command failed on purpose. It is not a finding about your code. + +We are not fixing this code. We are rebuilding from the ground up as a new product, shipping only what can be trusted. If type checking ever returns, it will be externally audited before release. + +A full public account: https://www.christianfindlay.com/blog/basilisk-conformance-apology diff --git a/crates/basilisk-cli/tests/cli_binary_tests.rs b/crates/basilisk-cli/tests/cli_binary_tests.rs deleted file mode 100644 index ae578f5c4..000000000 --- a/crates/basilisk-cli/tests/cli_binary_tests.rs +++ /dev/null @@ -1,758 +0,0 @@ -//! Tests for [CHKARCH-CLI] / [CHKARCH-CLI-COMMANDS] / [CHKARCH-COMMANDS]. -//! See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-CLI -#![allow( - clippy::allow_attributes, - clippy::indexing_slicing, - clippy::expect_used, - clippy::unwrap_used, - clippy::panic, - clippy::as_conversions -)] -//! Subprocess tests for the `basilisk` binary. -//! -//! These are the only tests that exercise `main.rs` and `output.rs` — code -//! that is unreachable from library-level integration tests because it lives -//! inside a binary crate. Every test spawns the compiled binary, captures -//! stdout/stderr, and asserts on exit code and output content. -//! -//! `check` and `analyze` share the pipeline and output machinery and differ -//! only in scope ([CHKARCH-COMMANDS]): house-rule (`BSK-…`) diagnostics only -//! ever surface through `analyze`, so the staged-project tests below drive -//! `analyze`; pep diagnostics drive `check`. -//! -//! Exit code contract ([CHKARCH-CLI-EXITCODES]): -//! 0 — clean, no errors -//! 1 — error diagnostics found -//! 2 — invalid configuration -//! 3 — internal error (bad path, I/O failure) - -use std::path::Path; -use std::process::{Command, Output}; - -use serde_json::Value; - -fn binary() -> Command { - Command::new(env!("CARGO_BIN_EXE_basilisk")) -} - -fn fixture(rel: &str) -> String { - Path::new(env!("CARGO_MANIFEST_DIR")) - .join("tests/fixtures") - .join(rel) - .to_string_lossy() - .into_owned() -} - -fn run_check(paths: &[&str]) -> Result> { - run_subcommand("check", paths, &[]) -} - -fn run_check_with_args( - paths: &[&str], - extra_args: &[&str], -) -> Result> { - run_subcommand("check", paths, extra_args) -} - -fn run_subcommand( - subcommand: &str, - paths: &[&str], - extra_args: &[&str], -) -> Result> { - let mut cmd = binary(); - let _ = cmd.arg(subcommand); - for p in paths { - let _ = cmd.arg(p); - } - for a in extra_args { - let _ = cmd.arg(a); - } - Ok(cmd.output()?) -} - -fn stdout(output: &Output) -> String { - String::from_utf8_lossy(&output.stdout).into_owned() -} - -/// Stage `rels` (fixture-relative paths) into a fresh isolated project dir that -/// ships a `pyproject.toml` opting into the annotation house rules. Those rules -/// (`BSK-0001`/`BSK-0002`/…) are analyze-scope and OFF by default — the -/// binary's default config is pure PEP conformance — so a project that wants -/// them enables them in config and these tests do too. Returns the project dir -/// (caller removes it) and the staged absolute paths. No modes; this is -/// configuration. See [CHKARCH-CONFIGURATION-ONLY], [CHKARCH-COMMANDS]. -fn stage_project( - rels: &[&str], -) -> Result<(std::path::PathBuf, Vec), Box> { - use std::sync::atomic::{AtomicU64, Ordering}; - static CTR: AtomicU64 = AtomicU64::new(0); - let n = CTR.fetch_add(1, Ordering::Relaxed); - let dir = std::env::temp_dir().join(format!("bsk_cli_bin_{}_{n}", std::process::id())); - std::fs::create_dir_all(&dir)?; - std::fs::write( - dir.join("pyproject.toml"), - "[tool.basilisk.rules]\n\"BSK-0001\" = \"error\"\n\"BSK-0002\" = \"error\"\n", - )?; - let mut staged = Vec::with_capacity(rels.len()); - for rel in rels { - let name = Path::new(rel) - .file_name() - .ok_or("fixture has no file name")?; - let dest = dir.join(name); - let _ = std::fs::copy(fixture(rel), &dest)?; - staged.push(dest.to_string_lossy().into_owned()); - } - Ok((dir, staged)) -} - -/// `basilisk analyze` over fixtures staged into a house-rules-enabled project -/// — house-rule diagnostics are analyze-scope ([CHKARCH-COMMANDS]). -fn run_analyze_staged(rels: &[&str]) -> Result> { - run_analyze_staged_with_args(rels, &[]) -} - -/// `basilisk analyze ` over fixtures staged into a house-rules-enabled -/// project. -fn run_analyze_staged_with_args( - rels: &[&str], - extra_args: &[&str], -) -> Result> { - let (dir, staged) = stage_project(rels)?; - let staged_refs: Vec<&str> = staged.iter().map(String::as_str).collect(); - let out = run_subcommand("analyze", &staged_refs, extra_args); - let _ = std::fs::remove_dir_all(&dir); - out -} - -// ── Shipwright version contract ───────────────────────────────────────────── - -#[test] -fn version_plain_matches_shipwright_contract() -> Result<(), Box> { - let out = binary().arg("--version").output()?; - assert_eq!(out.status.code(), Some(0), "--version must exit 0"); - // Line 1 is the Shipwright contract; line 2 lists the embedded formatter - // engine ([LSPFMT-PROVENANCE]). - assert_eq!( - stdout(&out).trim(), - concat!("basilisk ", env!("CARGO_PKG_VERSION"), "\nRuff formatter: ").to_owned() - + basilisk_lsp::formatting::EMBEDDED_RUFF_FORMATTER_VERSION, - "--version must emit ' ' then the embedded engine line" - ); - assert!( - out.stderr.is_empty(), - "--version must not emit diagnostics to stderr" - ); - Ok(()) -} - -#[test] -fn version_json_matches_shipwright_contract() -> Result<(), Box> { - let out = binary().args(["--version", "--json"]).output()?; - assert_eq!(out.status.code(), Some(0), "--version --json must exit 0"); - - let value: Value = serde_json::from_slice(&out.stdout)?; - assert_eq!(value["manifestVersion"], 1); - assert_eq!(value["name"], "basilisk"); - assert_eq!(value["version"], env!("CARGO_PKG_VERSION")); - assert_eq!(value["kind"], "lsp"); - assert_eq!(value["language"], "rust"); - assert_eq!(value["product"], "basilisk"); - assert!( - value.get("buildTime").is_some(), - "buildTime must be present" - ); - assert!(value.get("gitDirty").is_some(), "gitDirty must be present"); - assert!( - out.stderr.is_empty(), - "--version --json must not emit diagnostics to stderr" - ); - Ok(()) -} - -// ── Exit codes ─────────────────────────────────────────────────────────────── -// Exercises [CHKARCH-CLI-EXITCODES]: 0 = clean, 1 = errors, 2 = invalid -// configuration (see e2e_scope.rs), 3 = internal error. - -#[test] -fn exit_0_for_clean_file() -> Result<(), Box> { - let out = run_check(&[&fixture("clean/fully_typed_module.py")])?; - assert_eq!(out.status.code(), Some(0), "clean file must exit 0"); - Ok(()) -} - -#[test] -fn exit_1_for_file_with_errors() -> Result<(), Box> { - let out = run_analyze_staged(&["errors/e0001_single_param.py"])?; - assert_eq!(out.status.code(), Some(1), "file with errors must exit 1"); - Ok(()) -} - -#[test] -fn exit_3_for_nonexistent_path() -> Result<(), Box> { - let out = run_check(&["/nonexistent/path/does_not_exist.py"])?; - assert_eq!(out.status.code(), Some(3), "bad path must exit 3"); - Ok(()) -} - -// A file the parser cannot read is the one case where the run has nothing to -// say about the file's contents. `--output json` used to answer that with `[]` -// — byte-for-byte the answer a clean file gets — so every machine consumer -// (CI gate, editor, review bot) read "no problems found" for a file that was -// never checked at all. The exit code was the only signal, and a consumer that -// reads the report rather than the status never saw it. -#[test] -fn json_output_reports_a_file_that_failed_to_parse() -> Result<(), Box> { - let dir = std::env::temp_dir().join(format!("basilisk-json-failure-{}", std::process::id())); - std::fs::create_dir_all(&dir)?; - let malformed = dir.join("src.py"); - std::fs::write(&malformed, b"def hi()\n")?; - let malformed = malformed.to_string_lossy().into_owned(); - - let out = run_check_with_args(&[&malformed], &["--output", "json"])?; - let rendered = stdout(&out); - let _ = std::fs::remove_dir_all(&dir); - - assert_eq!( - out.status.code(), - Some(3), - "an unparseable file must exit 3" - ); - assert_ne!( - rendered.trim(), - "[]", - "JSON must never report an unparseable file the way it reports a clean one" - ); - - let value: Value = serde_json::from_str(&rendered)?; - let items = value - .as_array() - .ok_or("JSON output must stay a flat array of entries")?; - assert_eq!( - items.len(), - 1, - "the failed file must produce exactly one entry" - ); - let entry = items.first().ok_or("entry missing")?; - assert_eq!( - entry["severity"], "error", - "a file that cannot be read is an error" - ); - assert!( - entry["path"] - .as_str() - .is_some_and(|p| p.ends_with("src.py")), - "the entry must name the file that failed: {entry}" - ); - assert!( - entry["message"] - .as_str() - .is_some_and(|m| m.contains("syntax error")), - "the entry must say why the file could not be read: {entry}" - ); - assert!( - entry["code"].is_null(), - "no rule produced this entry, so it must not claim a rule code: {entry}" - ); - assert!( - entry["line"].as_u64().is_some_and(|line| line >= 1), - "the entry must carry a 1-based line: {entry}" - ); - assert!( - entry["col"].as_u64().is_some_and(|col| col >= 1), - "the entry must carry a 1-based column: {entry}" - ); - Ok(()) -} - -// The failure must not cost the run the diagnostics it did produce, and the -// clean peer's entries must stay exactly as they were. -#[test] -fn json_output_keeps_valid_diagnostics_beside_a_parse_failure( -) -> Result<(), Box> { - let (dir, staged) = stage_project(&["errors/e0001_single_param.py"])?; - let malformed = dir.join("malformed.py"); - std::fs::write(&malformed, b"def hi()\n")?; - let valid = staged.first().ok_or("staged diagnostic fixture missing")?; - let malformed = malformed.to_string_lossy().into_owned(); - - let out = run_subcommand("analyze", &[valid, &malformed], &["--output", "json"])?; - let rendered = stdout(&out); - let _ = std::fs::remove_dir_all(&dir); - - assert_eq!(out.status.code(), Some(3), "a parse failure must exit 3"); - let value: Value = serde_json::from_str(&rendered)?; - let items = value.as_array().ok_or("JSON output must stay an array")?; - assert!( - items.len() >= 2, - "both the diagnostic and the failure must appear" - ); - - let coded: Vec<&Value> = items - .iter() - .filter(|item| !item["code"].is_null()) - .collect(); - let failures: Vec<&Value> = items.iter().filter(|item| item["code"].is_null()).collect(); - assert!( - coded.iter().any(|item| item["code"] == "BSK-0001"), - "the valid peer's diagnostics must survive the failure: {rendered}" - ); - assert_eq!( - failures.len(), - 1, - "exactly one file failed to parse: {rendered}" - ); - assert!( - failures.first().is_some_and(|item| item["path"] - .as_str() - .is_some_and(|p| p.ends_with("malformed.py"))), - "the failure entry must name the file that failed: {rendered}" - ); - for item in coded { - assert!( - item["code"] - .as_str() - .is_some_and(|code| code.starts_with("BSK-")), - "a rule-produced entry keeps its code: {item}" - ); - } - Ok(()) -} - -// A clean run must be untouched by the change: still an empty array, still 0. -#[test] -fn json_output_for_a_clean_file_is_still_an_empty_array() -> Result<(), Box> -{ - let out = run_check_with_args( - &[&fixture("clean/fully_typed_module.py")], - &["--output", "json"], - )?; - assert_eq!(out.status.code(), Some(0), "a clean file must exit 0"); - assert_eq!(stdout(&out).trim(), "[]", "a clean file reports no entries"); - Ok(()) -} - -#[test] -fn analysis_failure_exits_three_without_dropping_valid_diagnostics( -) -> Result<(), Box> { - let (dir, staged) = stage_project(&["errors/e0001_single_param.py"])?; - let malformed = dir.join("malformed.py"); - std::fs::write(&malformed, b"def broken(:\n")?; - let valid = staged.first().ok_or("staged diagnostic fixture missing")?; - let malformed = malformed.to_string_lossy().into_owned(); - - // `analyze` renders the staged house-rule debt ([CHKARCH-COMMANDS]); - // the malformed peer must fail the run without dropping it. - let mixed = run_subcommand("analyze", &[valid, &malformed], &[])?; - let malformed_only = run_check(&[&malformed])?; - let _ = std::fs::remove_dir_all(&dir); - - assert_eq!(mixed.status.code(), Some(3), "analysis failure must exit 3"); - assert!( - stdout(&mixed).contains("BSK-0001"), - "a valid peer's diagnostics must still be rendered: {}", - stdout(&mixed) - ); - assert_eq!( - malformed_only.status.code(), - Some(3), - "a malformed requested file must exit 3" - ); - assert!( - !stdout(&malformed_only).contains("No issues found"), - "analysis failure must never print a clean-success message: {}", - stdout(&malformed_only) - ); - Ok(()) -} - -// ── Clean file output ──────────────────────────────────────────────────────── - -#[test] -fn clean_file_prints_no_issues_found() -> Result<(), Box> { - let out = run_check(&[&fixture("clean/fully_typed_module.py")])?; - assert!( - stdout(&out).contains("No issues found"), - "clean output must say 'No issues found', got:\n{}", - stdout(&out) - ); - Ok(()) -} - -// ── Error output format ────────────────────────────────────────────────────── -// Exercises [CHKARCH-CLI-OUTPUT] (human-readable text default) and the -// rustc-standard layout of [CHKARCH-DIAGEXP-QUALITY]: code, `-->` location, -// source snippet, caret underline, and `= help:`/`= note:`/`= see:` lines. - -#[test] -fn output_contains_error_code_e0001() -> Result<(), Box> { - let out = run_analyze_staged(&["errors/e0001_single_param.py"])?; - assert!( - stdout(&out).contains("BSK-0001"), - "output must contain BSK-0001, got:\n{}", - stdout(&out) - ); - Ok(()) -} - -#[test] -fn output_contains_error_code_e0002() -> Result<(), Box> { - let out = run_analyze_staged(&["errors/e0002_single_func.py"])?; - assert!( - stdout(&out).contains("BSK-0002"), - "output must contain BSK-0002, got:\n{}", - stdout(&out) - ); - Ok(()) -} - -#[test] -fn output_contains_rustc_style_arrow() -> Result<(), Box> { - let out = run_analyze_staged(&["errors/e0001_single_param.py"])?; - assert!( - stdout(&out).contains("-->"), - "output must contain --> location marker, got:\n{}", - stdout(&out) - ); - Ok(()) -} - -#[test] -fn output_contains_source_snippet() -> Result<(), Box> { - let out = run_analyze_staged(&["errors/e0001_single_param.py"])?; - let text = stdout(&out); - assert!( - text.contains("def process(data)"), - "output must contain the source line, got:\n{text}" - ); - Ok(()) -} - -#[test] -fn output_contains_caret_underline() -> Result<(), Box> { - let out = run_analyze_staged(&["errors/e0001_single_param.py"])?; - assert!( - stdout(&out).contains('^'), - "output must contain caret underline, got:\n{}", - stdout(&out) - ); - Ok(()) -} - -#[test] -fn output_contains_help_annotation() -> Result<(), Box> { - let out = run_analyze_staged(&["errors/e0001_single_param.py"])?; - assert!( - stdout(&out).contains("= help:"), - "output must contain help annotation, got:\n{}", - stdout(&out) - ); - Ok(()) -} - -#[test] -fn output_contains_note_annotation() -> Result<(), Box> { - let out = run_analyze_staged(&["errors/e0001_single_param.py"])?; - assert!( - stdout(&out).contains("= note:"), - "output must contain note annotation, got:\n{}", - stdout(&out) - ); - Ok(()) -} - -#[test] -fn output_contains_see_url() -> Result<(), Box> { - let out = run_analyze_staged(&["errors/e0001_single_param.py"])?; - assert!( - stdout(&out).contains("= see: https://"), - "output must contain see URL, got:\n{}", - stdout(&out) - ); - Ok(()) -} - -#[test] -fn output_contains_line_col_location() -> Result<(), Box> { - // def process(data) -> None: — `data` is at line 1, col 13 - let out = run_analyze_staged(&["errors/e0001_single_param.py"])?; - assert!( - stdout(&out).contains("1:13"), - "output must contain line:col 1:13, got:\n{}", - stdout(&out) - ); - Ok(()) -} - -#[test] -fn output_contains_diagnostic_summary() -> Result<(), Box> { - let out = run_analyze_staged(&["errors/e0001_single_param.py"])?; - assert!( - stdout(&out).contains("diagnostic"), - "output must contain summary line, got:\n{}", - stdout(&out) - ); - Ok(()) -} - -#[test] -fn output_shows_correct_error_count() -> Result<(), Box> { - // missing_both.py has 3 x BSK-0001 + 2 x BSK-0002 = 5 errors - let out = run_analyze_staged(&["missing_both.py"])?; - assert!( - stdout(&out).contains("5 error"), - "output must show 5 errors, got:\n{}", - stdout(&out) - ); - Ok(()) -} - -// ── Multiple files ──────────────────────────────────────────────────────────── - -#[test] -fn checks_multiple_files_in_one_invocation() -> Result<(), Box> { - let out = run_analyze_staged(&[ - "errors/e0001_single_param.py", - "errors/e0002_single_func.py", - ])?; - let text = stdout(&out); - assert!(text.contains("BSK-0001"), "must flag BSK-0001"); - assert!(text.contains("BSK-0002"), "must flag BSK-0002"); - assert_eq!(out.status.code(), Some(1)); - Ok(()) -} - -#[test] -fn clean_and_error_file_together_exits_1() -> Result<(), Box> { - let out = run_analyze_staged(&[ - "clean/fully_typed_module.py", - "errors/e0001_single_param.py", - ])?; - assert_eq!(out.status.code(), Some(1)); - Ok(()) -} - -// ── Directory traversal ─────────────────────────────────────────────────────── - -#[test] -fn traverses_directory_and_finds_errors() -> Result<(), Box> { - let out = run_check(&[&fixture("errors")])?; - assert_eq!( - out.status.code(), - Some(1), - "errors/ directory contains broken files, must exit 1" - ); - Ok(()) -} - -#[test] -fn traverses_clean_directory_exits_0() -> Result<(), Box> { - let out = run_check(&[&fixture("clean")])?; - assert_eq!( - out.status.code(), - Some(0), - "clean/ directory has no errors, must exit 0" - ); - Ok(()) -} - -#[test] -fn bound_method_does_not_collide_with_same_named_function() -> Result<(), Box> -{ - let out = run_check(&[&fixture("clean/typed_optional.py")])?; - assert_eq!( - out.status.code(), - Some(0), - "`haystack.find(needle)` must not be checked against the module function `find(haystack, needle)`:\n{}", - stdout(&out) - ); - Ok(()) -} - -// ── Output severity label ───────────────────────────────────────────────────── - -#[test] -fn output_severity_label_is_error() -> Result<(), Box> { - let out = run_analyze_staged(&["errors/e0001_single_param.py"])?; - assert!( - stdout(&out).contains("error[BSK-"), - "severity label must be 'error', got:\n{}", - stdout(&out) - ); - Ok(()) -} - -// ── Terminal colours ───────────────────────────────────────────────────────── - -/// ANSI escape sequences produced by the `colored` crate. -const BOLD_RED: &str = "\x1b[1;31m"; -const BOLD_BLUE: &str = "\x1b[1;34m"; -const BOLD_CYAN: &str = "\x1b[1;36m"; -const BOLD: &str = "\x1b[1m"; - -#[test] -fn color_always_emits_ansi_for_error_label() -> Result<(), Box> { - let out = - run_analyze_staged_with_args(&["errors/e0001_single_param.py"], &["--color", "always"])?; - let text = stdout(&out); - assert!( - text.contains(BOLD_RED), - "--color always must emit bold-red ANSI for error label, got:\n{text}" - ); - Ok(()) -} - -#[test] -fn color_always_emits_ansi_for_error_code() -> Result<(), Box> { - let out = - run_analyze_staged_with_args(&["errors/e0001_single_param.py"], &["--color", "always"])?; - let text = stdout(&out); - assert!( - text.contains(&format!("{BOLD}[BSK-0001]")), - "--color always must emit bold error code, got:\n{text}" - ); - Ok(()) -} - -#[test] -fn color_always_emits_ansi_for_arrow() -> Result<(), Box> { - let out = - run_analyze_staged_with_args(&["errors/e0001_single_param.py"], &["--color", "always"])?; - let text = stdout(&out); - assert!( - text.contains(&format!("{BOLD_BLUE}-->")), - "--color always must emit bold-blue arrow, got:\n{text}" - ); - Ok(()) -} - -#[test] -fn color_always_emits_ansi_for_pipe() -> Result<(), Box> { - let out = - run_analyze_staged_with_args(&["errors/e0001_single_param.py"], &["--color", "always"])?; - let text = stdout(&out); - assert!( - text.contains(&format!("{BOLD_BLUE}|")), - "--color always must emit bold-blue pipe, got:\n{text}" - ); - Ok(()) -} - -#[test] -fn color_always_emits_ansi_for_caret_underline() -> Result<(), Box> { - let out = - run_analyze_staged_with_args(&["errors/e0001_single_param.py"], &["--color", "always"])?; - let text = stdout(&out); - assert!( - text.contains(&format!("{BOLD_RED}^")), - "--color always must emit bold-red caret underline, got:\n{text}" - ); - Ok(()) -} - -#[test] -fn color_always_emits_ansi_for_help_label() -> Result<(), Box> { - let out = - run_analyze_staged_with_args(&["errors/e0001_single_param.py"], &["--color", "always"])?; - let text = stdout(&out); - assert!( - text.contains(&format!("{BOLD_CYAN}help")), - "--color always must emit bold-cyan help label, got:\n{text}" - ); - Ok(()) -} - -#[test] -fn color_always_emits_ansi_for_note_label() -> Result<(), Box> { - let out = - run_analyze_staged_with_args(&["errors/e0001_single_param.py"], &["--color", "always"])?; - let text = stdout(&out); - assert!( - text.contains(&format!("{BOLD_CYAN}note")), - "--color always must emit bold-cyan note label, got:\n{text}" - ); - Ok(()) -} - -#[test] -fn color_always_emits_ansi_for_see_label() -> Result<(), Box> { - let out = - run_analyze_staged_with_args(&["errors/e0001_single_param.py"], &["--color", "always"])?; - let text = stdout(&out); - assert!( - text.contains(&format!("{BOLD_CYAN}see")), - "--color always must emit bold-cyan see label, got:\n{text}" - ); - Ok(()) -} - -#[test] -fn color_never_strips_all_ansi() -> Result<(), Box> { - let out = run_check_with_args( - &[&fixture("errors/e0001_single_param.py")], - &["--color", "never"], - )?; - let text = stdout(&out); - assert!( - !text.contains("\x1b["), - "--color never must not emit any ANSI codes, got:\n{text}" - ); - Ok(()) -} - -#[test] -fn color_never_clean_file_strips_all_ansi() -> Result<(), Box> { - let out = run_check_with_args( - &[&fixture("clean/fully_typed_module.py")], - &["--color", "never"], - )?; - let text = stdout(&out); - assert!( - !text.contains("\x1b["), - "--color never must not emit any ANSI codes for clean output, got:\n{text}" - ); - Ok(()) -} - -#[test] -fn color_always_clean_file_emits_ansi() -> Result<(), Box> { - let out = run_check_with_args( - &[&fixture("clean/fully_typed_module.py")], - &["--color", "always"], - )?; - let text = stdout(&out); - assert!( - text.contains("\x1b["), - "--color always must emit ANSI codes even for clean output, got:\n{text}" - ); - Ok(()) -} - -// ── Tracing log ANSI (issue #23) ────────────────────────────────────────────── - -/// Regression for issue #23: when the `basilisk` binary runs as a subprocess -/// (e.g. the LSP launched by the VS Code extension), its stderr is a pipe, not -/// a terminal. The structured `tracing` logs written to stderr must NOT contain -/// raw ANSI colour escape sequences in that case — otherwise the VS Code output -/// channel renders them as garbage (as reported in the bug). `Command::output` -/// captures stderr through a pipe, faithfully reproducing the non-terminal case. -#[test] -fn tracing_logs_emit_no_ansi_on_piped_stderr() -> Result<(), Box> { - // BASILISK_LOG=info guarantees `run_check` emits at least its - // "loaded config" info line to stderr, so stderr is non-empty. - let out = binary() - .arg("check") - .arg(fixture("clean/fully_typed_module.py")) - .env("BASILISK_LOG", "info") - .output()?; - - let stderr = String::from_utf8_lossy(&out.stderr).into_owned(); - assert!( - !stderr.is_empty(), - "BASILISK_LOG=info must produce tracing output on stderr so this test is meaningful" - ); - assert!( - !stderr.contains('\u{1b}'), - "tracing logs on piped (non-terminal) stderr must contain no ANSI escapes, got:\n{stderr}" - ); - Ok(()) -} diff --git a/crates/basilisk-cli/tests/cli_tests.rs b/crates/basilisk-cli/tests/cli_tests.rs deleted file mode 100644 index 2507c7ae1..000000000 --- a/crates/basilisk-cli/tests/cli_tests.rs +++ /dev/null @@ -1,129 +0,0 @@ -//! Tests for [CHKARCH-CLI]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-CLI -#![allow( - clippy::allow_attributes, - clippy::indexing_slicing, - clippy::expect_used, - clippy::unwrap_used, - clippy::panic, - clippy::as_conversions -)] -//! End-to-end integration tests for the full pipeline. -//! -//! These tests exercise parse -> resolve -> check using real Python fixture -//! files. They do NOT test CLI argument parsing — they test the pipeline -//! that powers the CLI. - -use basilisk_checker::Severity; -use basilisk_config::BasiliskConfig; - -mod common; -use common::{fixture, run as check_fixture, run_with_config}; - -/// Check a fixture with the annotation house rules enabled in configuration — -/// the off-by-default rules (`BSK-0001`/`BSK-0002`) these tests exercise. The -/// default config is pure PEP conformance; a project opts these in. No modes; -/// this is configuration. See [CHKARCH-CONFIGURATION-ONLY]. -fn check_fixture_strict( - name: &str, -) -> Result, Box> { - run_with_config( - name, - &BasiliskConfig::with_rule_entries( - ["BSK-0001", "BSK-0002"] - .into_iter() - .map(|code| (code.to_owned(), basilisk_config::RuleSeverity::Error)) - .collect(), - ), - ) -} - -#[test] -fn all_annotated_produces_no_diagnostics() -> Result<(), Box> { - let diags = check_fixture("all_annotated.py")?; - assert!( - diags.is_empty(), - "all_annotated.py should produce zero diagnostics, got: {diags:#?}" - ); - Ok(()) -} - -#[test] -fn missing_param_annotation_produces_only_e0001() -> Result<(), Box> { - let diags = check_fixture_strict("missing_param_annotation.py")?; - assert!(!diags.is_empty(), "should have diagnostics"); - assert!( - diags.iter().all(|d| d.code.code == "BSK-0001"), - "all diagnostics should be BSK-0001, got: {diags:#?}" - ); - // `process` has 1 unannotated param, `transform` has 1 unannotated param - assert_eq!( - diags.len(), - 2, - "expected 2 BSK-0001 diagnostics, got {}", - diags.len() - ); - Ok(()) -} - -#[test] -fn missing_return_annotation_produces_only_e0002() -> Result<(), Box> { - let diags = check_fixture_strict("missing_return_annotation.py")?; - assert!(!diags.is_empty(), "should have diagnostics"); - assert!( - diags.iter().all(|d| d.code.code == "BSK-0002"), - "all diagnostics should be BSK-0002, got: {diags:#?}" - ); - assert_eq!(diags.len(), 2, "two functions without return annotations"); - Ok(()) -} - -#[test] -fn missing_both_produces_e0001_and_e0002() -> Result<(), Box> { - let diags = check_fixture_strict("missing_both.py")?; - let codes: Vec<&str> = diags.iter().map(|d| d.code.code).collect(); - assert!(codes.contains(&"BSK-0001"), "should contain BSK-0001"); - assert!(codes.contains(&"BSK-0002"), "should contain BSK-0002"); - assert!( - diags.iter().all(|d| d.severity == Severity::Error), - "all diagnostics should be errors" - ); - Ok(()) -} - -#[test] -fn all_diagnostics_have_valid_spans() -> Result<(), Box> { - let diags = check_fixture_strict("missing_both.py")?; - assert!(!diags.is_empty(), "fixture should produce diagnostics"); - for diag in &diags { - assert!( - diag.span.start <= diag.span.end, - "span start ({}) must not exceed end ({})", - diag.span.start, - diag.span.end - ); - } - Ok(()) -} - -#[test] -fn all_diagnostics_reference_correct_file_path() -> Result<(), Box> { - let path = fixture("missing_both.py"); - let diags = check_fixture_strict("missing_both.py")?; - for diag in &diags { - assert_eq!(diag.path, path, "diagnostic path should match fixture path"); - } - Ok(()) -} - -#[test] -fn missing_both_broken_has_two_params_flagged() -> Result<(), Box> { - // `broken(x, y)` has 2 unannotated params -> 2 x BSK-0001 - // `also_broken(name)` has 1 unannotated param -> 1 x BSK-0001 - // Both functions lack return annotation -> 2 x BSK-0002 - let diags = check_fixture_strict("missing_both.py")?; - let e0001_count = diags.iter().filter(|d| d.code.code == "BSK-0001").count(); - let e0002_count = diags.iter().filter(|d| d.code.code == "BSK-0002").count(); - assert_eq!(e0001_count, 3, "expected 3 E0001s (x, y, name)"); - assert_eq!(e0002_count, 2, "expected 2 E0002s (broken, also_broken)"); - Ok(()) -} diff --git a/crates/basilisk-cli/tests/common/mod.rs b/crates/basilisk-cli/tests/common/mod.rs deleted file mode 100644 index 5295b362f..000000000 --- a/crates/basilisk-cli/tests/common/mod.rs +++ /dev/null @@ -1,133 +0,0 @@ -//! Tests for [CHKARCH-CLI]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-CLI -#![allow( - clippy::allow_attributes, - clippy::indexing_slicing, - clippy::expect_used, - clippy::unwrap_used, - clippy::panic, - clippy::as_conversions, - // Shared helpers (`run` / `run_with_config` / `annotation_rules_config` / - // `fixture`) are each used by SOME but not every CLI test binary; `mod - // common` compiles into each binary independently, so a helper unused by one - // is not dead across the suite. - dead_code -)] -//! Shared helpers for Basilisk CLI end-to-end tests. -//! -//! Every test uses a real `.py` fixture file and asserts the exact set of -//! diagnostics produced: error code, symbol name, byte span, line, column, -//! and message. No hand-wavy count assertions — if a diagnostic appears at -//! the wrong location or with the wrong message, the test fails. -//! -//! Pipeline under test: `parse_file` → resolve → check - -use std::path::Path; - -use basilisk_checker::{check, check_with_config, Diagnostic}; -use basilisk_config::BasiliskConfig; -use basilisk_parser::parse_file; -use basilisk_resolver::resolve; - -// Re-export shared helpers from the test-utils crate — used by sibling test modules. -#[expect( - unused_imports, - reason = "re-exported for sibling test files via `use common::assert_diagnostics`" -)] -pub use basilisk_test_utils::assert_diagnostics; -#[expect( - unused_imports, - reason = "re-exported for sibling test files via `use common::Expected`" -)] -pub use basilisk_test_utils::Expected; - -pub fn fixture(rel: &str) -> String { - Path::new(env!("CARGO_MANIFEST_DIR")) - .join("tests/fixtures") - .join(rel) - .to_string_lossy() - .into_owned() -} - -pub fn run(rel: &str) -> Result, Box> { - let resolved = resolve_fixture(rel)?; - Ok(check(&resolved)) -} - -fn resolve_fixture( - rel: &str, -) -> Result> { - use std::sync::Arc; - - let path = fixture(rel); - let parsed = parse_file(&path)?; - let mut resolved = resolve(&parsed)?; - let snapshot = basilisk_stubs::typeshed::bundle::bundled_snapshot()?; - let paths = basilisk_checker::imports::ImportSearchPaths { - roots: Vec::new(), - extra_paths: Vec::new(), - stub_paths: Vec::new(), - workspace_members: Vec::new(), - site_packages: None, - registry: None, - typeshed_snapshot: Some(basilisk_checker::imports::ActiveTypeshed::new( - Arc::new(snapshot), - None, - )), - }; - basilisk_checker::imports::resolve_module_imports(&mut resolved, &paths); - Ok(resolved) -} - -/// Run the checker over a fixture honoring an explicit project configuration. -/// -/// Basilisk has **no modes** — the checker does exactly what configuration -/// says. [`run`] uses the default config (every PEP rule, no `BSK-`prefixed -/// house rules), so out of the box a fixture is graded as pure PEP conformance. -/// House-style rules (require-annotation, require-`@override`, redundant -/// annotation, …) are off by default; a fixture that exercises them passes a -/// config that opts in. See [CHKARCH-CONFIGURATION-ONLY]. -pub fn run_with_config( - rel: &str, - config: &BasiliskConfig, -) -> Result, Box> { - let resolved = resolve_fixture(rel)?; - Ok(check_with_config(&resolved, config)) -} - -/// Project configuration with explicit native severities for Basilisk's -/// annotation house rules. -/// -/// This is configuration **data** — exactly what a project writes in -/// config file to enable these off-by-default rules — not a checker "mode". -/// See [CHKARCH-CONFIGURATION-ONLY]. -#[must_use] -pub fn annotation_rules_config() -> BasiliskConfig { - use basilisk_config::RuleSeverity::{Error, Warning}; - - BasiliskConfig::with_rule_entries( - [ - ("BSK-0001", Error), - ("BSK-0002", Error), - ("BSK-0003", Error), - ("BSK-0004", Error), - ("BSK-0005", Error), - ("BSK-0025", Error), - ("BSK-0014", Warning), - ("BSK-0040", Warning), - ("BSK-0050", Warning), - ] - .into_iter() - .map(|(code, severity)| (code.to_owned(), severity)) - .collect(), - ) -} - -/// Annotation-rule configuration for checks whose semantics depend on a -/// concrete Python target. -#[must_use] -pub fn annotation_rules_config_for_python(version: &str) -> BasiliskConfig { - BasiliskConfig { - python_version: Some(version.to_owned()), - ..annotation_rules_config() - } -} diff --git a/crates/basilisk-cli/tests/e2e_bundled_typeshed_config.rs b/crates/basilisk-cli/tests/e2e_bundled_typeshed_config.rs deleted file mode 100644 index 81b1e99fa..000000000 --- a/crates/basilisk-cli/tests/e2e_bundled_typeshed_config.rs +++ /dev/null @@ -1,294 +0,0 @@ -//! Tests default runtime typeshed behavior for [STUBRES-CUSTOM-TYPESHED]. -//! See docs/specs/CHECKER-STUB-RESOLUTION-SPEC.md#STUBRES-CUSTOM-TYPESHED -#![allow( - clippy::allow_attributes, - clippy::expect_used, - clippy::unwrap_used, - clippy::panic -)] - -use std::path::{Path, PathBuf}; -use std::process::{Command, Output}; -use std::sync::atomic::{AtomicU64, Ordering}; - -fn unique_dir(prefix: &str) -> PathBuf { - static CTR: AtomicU64 = AtomicU64::new(0); - let n = CTR.fetch_add(1, Ordering::Relaxed); - let dir = std::env::temp_dir().join(format!( - "bsk_runtime_typeshed_{prefix}_{}_{n}", - std::process::id() - )); - std::fs::create_dir_all(&dir).expect("create temp dir"); - dir -} - -fn check_app(dir: &Path) -> Output { - Command::new(env!("CARGO_BIN_EXE_basilisk")) - .arg("check") - .arg("app.py") - .current_dir(dir) - .env_remove("VIRTUAL_ENV") - .output() - .expect("spawn basilisk") -} - -#[test] -fn cli_without_typeshed_path_activates_the_default_runtime_source() { - let dir = unique_dir("default_source"); - std::fs::write( - dir.join("pyproject.toml"), - "[project]\nname = \"x\"\nversion = \"0.1.0\"\n\n[tool.basilisk]\n", - ) - .expect("write pyproject"); - std::fs::write( - dir.join("app.py"), - "from fractions import Fraction\n\nvalue = Fraction(1, 2)\n", - ) - .expect("write app"); - - let output = check_app(&dir); - let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); - - assert!( - !stdout.contains("imports_unresolved"), - "the default runtime source must suppress unresolved diagnostics, stdout: {stdout}, stderr: {stderr}" - ); - assert!( - !stdout.contains("fractions"), - "diagnostics must not name a resolved stdlib module, stdout: {stdout}" - ); - assert_eq!( - output.status.code(), - Some(0), - "the default runtime source must let the CLI check pass, stdout: {stdout}, stderr: {stderr}" - ); - - let _ = std::fs::remove_dir_all(&dir); -} - -/// Regression for GitHub #330: a member the ACTIVE Typeshed stub does not -/// declare must be reported on a plain-imported stdlib module. -/// -/// `imports_module_attribute` documents plain imports backed by an -/// authoritative local stub **or the active step-3 Typeshed source** as in -/// scope, and the LSP's cross-module query populates both. The CLI's -/// single-file pipeline captured only user-stub module APIs, so the Typeshed -/// half was silently dropped and `basilisk check` exited 0 on a call that -/// cannot exist. -#[test] -fn cli_flags_a_member_the_active_typeshed_stub_does_not_declare() { - let dir = unique_dir("typeshed_module_attribute"); - std::fs::write( - dir.join("pyproject.toml"), - "[project]\nname = \"x\"\nversion = \"0.1.0\"\n\n[tool.basilisk]\n", - ) - .expect("write pyproject"); - std::fs::write( - dir.join("app.py"), - "import json\n\npayload = json.parse_body(\"{}\")\n", - ) - .expect("write app"); - - let output = check_app(&dir); - let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); - - assert!( - stdout.contains("imports_module_attribute"), - "the active Typeshed `json` stub declares no `parse_body`, so the CLI must report it, stdout: {stdout}, stderr: {stderr}" - ); - assert!( - stdout.contains("parse_body"), - "the diagnostic must name the missing member, stdout: {stdout}" - ); - assert_eq!( - output.status.code(), - Some(1), - "a missing Typeshed member is an error, so the CLI must exit 1, stdout: {stdout}, stderr: {stderr}" - ); - - let _ = std::fs::remove_dir_all(&dir); -} - -/// Regression for the GitHub #312 follow-up (comment 5053013115), end to end -/// through the real CLI with the reporter's exact configuration: `stub-paths` -/// and `typeshed-path` both point at `typings/`, `typings/uio.pyi` is only -/// `from io import *`, and `io` lives in `typings/stdlib/`. The user stub's -/// star target resolves through the active custom typeshed — never a false -/// "Module `uio` has no attribute `StringIO`". -#[test] -fn cli_accepts_user_stub_reexports_from_the_custom_typeshed_stdlib() { - let dir = unique_dir("user_stub_stdlib_reexport"); - let typings = dir.join("typings"); - let stdlib = typings.join("stdlib"); - std::fs::create_dir_all(&stdlib).expect("create typings/stdlib"); - std::fs::write( - dir.join("pyproject.toml"), - "[project]\nname = \"x\"\nversion = \"0.1.0\"\n\n[tool.basilisk]\nstub-paths = [\"typings\"]\ntypeshed-path = \"typings\"\n", - ) - .expect("write pyproject"); - // Mirrors micropython-esp32-stubs' uio.pyi verbatim. - std::fs::write(typings.join("uio.pyi"), "from io import *\n").expect("write uio stub"); - std::fs::write( - stdlib.join("io.pyi"), - "class StringIO: ...\nclass BytesIO: ...\n", - ) - .expect("write io stub"); - std::fs::write(stdlib.join("VERSIONS"), "io: 3.0-\n").expect("write VERSIONS"); - std::fs::write( - dir.join("app.py"), - "import uio\n\nbuffer_1 = uio.StringIO()\nbuffer_2 = uio.BytesIO()\n", - ) - .expect("write app"); - - let output = check_app(&dir); - let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); - - assert!( - !stdout.contains("imports_module_attribute"), - "`StringIO`/`BytesIO` are star-re-exported from the custom typeshed's \ - `io` stub and must not be flagged, stdout: {stdout}, stderr: {stderr}" - ); - assert_eq!( - output.status.code(), - Some(0), - "spec-valid re-exports must let the CLI check pass, stdout: {stdout}, stderr: {stderr}" - ); - - let _ = std::fs::remove_dir_all(&dir); -} - -/// Regression for GitHub #324 (bug 1): `@overload` stdlib functions must be -/// part of a module's captured member set. `stub_module_exports` looped the -/// stub's plain functions, classes, and variables but NOT its overload groups, -/// so every overloaded stdlib function (`math.ceil`, `hmac.new`, `ast.parse`, …) -/// looked undeclared and `basilisk check` red a clean repo out of the box. -#[test] -fn cli_accepts_overloaded_stdlib_functions() { - let dir = unique_dir("overloaded_stdlib_functions"); - std::fs::write( - dir.join("pyproject.toml"), - "[project]\nname = \"x\"\nversion = \"0.1.0\"\n\n[tool.basilisk]\n", - ) - .expect("write pyproject"); - std::fs::write( - dir.join("app.py"), - "import math\nimport ast\n\nrounded = math.ceil(1.5)\ntree = ast.parse(\"x = 1\")\n", - ) - .expect("write app"); - - let output = check_app(&dir); - let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); - - assert!( - !stdout.contains("imports_module_attribute"), - "`math.ceil` and `ast.parse` are `@overload` stdlib functions and must not be flagged, stdout: {stdout}, stderr: {stderr}" - ); - assert_eq!( - output.status.code(), - Some(0), - "correct stdlib code using overloaded functions must let the CLI check pass, stdout: {stdout}, stderr: {stderr}" - ); - - let _ = std::fs::remove_dir_all(&dir); -} - -/// Regression for GitHub #324 (bug 2): an unaliased dotted `import os.path` -/// binds only the root package `os`, but the resolved stub is `os.path` — its -/// members are NOT `os`'s members, so attributing them to `os` red `os.path` -/// itself ("Module `os` has no attribute `path`"). Such imports must capture no -/// authoritative module API, mirroring the user-stub path that already skips -/// every dotted import. -#[test] -fn cli_accepts_dotted_submodule_imports() { - let dir = unique_dir("dotted_submodule_imports"); - std::fs::write( - dir.join("pyproject.toml"), - "[project]\nname = \"x\"\nversion = \"0.1.0\"\n\n[tool.basilisk]\n", - ) - .expect("write pyproject"); - std::fs::write( - dir.join("app.py"), - "import os.path\nimport http.client\n\njoined = os.path.join(\"a\", \"b\")\nconn = http.client.HTTPConnection(\"localhost\")\n", - ) - .expect("write app"); - - let output = check_app(&dir); - let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); - - assert!( - !stdout.contains("imports_module_attribute"), - "`os.path.join` and `http.client.HTTPConnection` are correct dotted-submodule access and must not be flagged, stdout: {stdout}, stderr: {stderr}" - ); - assert_eq!( - output.status.code(), - Some(0), - "correct dotted-submodule imports must let the CLI check pass, stdout: {stdout}, stderr: {stderr}" - ); - - let _ = std::fs::remove_dir_all(&dir); -} - -/// Guard against over-skipping the GitHub #324 dotted fix: an *aliased* dotted -/// `import X.Y as z` binds the alias `z` to the leaf module `X.Y`, so its member -/// API is still authoritative and a genuine typo on it must still be flagged. -#[test] -fn cli_still_flags_missing_member_on_aliased_dotted_import() { - let dir = unique_dir("aliased_dotted_missing_member"); - std::fs::write( - dir.join("pyproject.toml"), - "[project]\nname = \"x\"\nversion = \"0.1.0\"\n\n[tool.basilisk]\n", - ) - .expect("write pyproject"); - std::fs::write( - dir.join("app.py"), - "import http.client as hc\n\nvalue = hc.NoSuchMemberZzz\n", - ) - .expect("write app"); - - let output = check_app(&dir); - let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); - - assert!( - stdout.contains("imports_module_attribute") && stdout.contains("NoSuchMemberZzz"), - "an aliased dotted import still binds the leaf module's authoritative API, so a missing member must be flagged, stdout: {stdout}, stderr: {stderr}" - ); - - let _ = std::fs::remove_dir_all(&dir); -} - -/// The other half of GitHub #330, and the guard against re-introducing #312: -/// members the active Typeshed stub DOES declare — including names it -/// re-exports rather than defines — must never be flagged. Capturing the -/// Typeshed module API is only correct if it captures the module's full export -/// set; a partial capture turns every valid re-export into a false positive. -#[test] -fn cli_accepts_members_the_active_typeshed_stub_declares_or_reexports() { - let dir = unique_dir("typeshed_module_attribute_valid"); - std::fs::write( - dir.join("pyproject.toml"), - "[project]\nname = \"x\"\nversion = \"0.1.0\"\n\n[tool.basilisk]\n", - ) - .expect("write pyproject"); - std::fs::write( - dir.join("app.py"), - "import json\n\ntext = json.dumps({})\nvalue = json.loads(text)\ndecoder = json.JSONDecoder\nerror = json.JSONDecodeError\n", - ) - .expect("write app"); - - let output = check_app(&dir); - let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); - - assert!( - !stdout.contains("imports_module_attribute"), - "`dumps`/`loads`/`JSONDecoder` are declared and `JSONDecodeError` is re-exported by the active `json` stub — none may be flagged, stdout: {stdout}, stderr: {stderr}" - ); - - let _ = std::fs::remove_dir_all(&dir); -} diff --git a/crates/basilisk-cli/tests/e2e_cache.rs b/crates/basilisk-cli/tests/e2e_cache.rs deleted file mode 100644 index 58ba14419..000000000 --- a/crates/basilisk-cli/tests/e2e_cache.rs +++ /dev/null @@ -1,356 +0,0 @@ -//! Tests for [CHKCACHE] / [CHKCACHE-TEST]. See docs/specs/CHECKER-CACHE-SPEC.md -#![allow( - clippy::allow_attributes, - clippy::expect_used, - clippy::unwrap_used, - clippy::panic -)] -//! Coarse end-to-end tests for the opt-in result cache. -//! -//! Every test spawns the compiled `basilisk` binary, so it exercises the real -//! flag parsing, fingerprinting, read-set capture, on-disk entry, and replay. -//! The cardinal property under test is the [CHKCACHE-CONTRACT] guarantee: a hit -//! is returned only when nothing that affects the diagnostics has changed, so -//! the cache can never report a stale (wrong) result. - -use std::path::PathBuf; -use std::process::{Command, Output}; -use std::sync::atomic::{AtomicU64, Ordering}; - -/// A throwaway directory unique to this process and call, holding both the -/// checked sources and the cache so tests never collide or touch the repo. -fn unique_dir(prefix: &str) -> PathBuf { - static CTR: AtomicU64 = AtomicU64::new(0); - let n = CTR.fetch_add(1, Ordering::Relaxed); - let dir = std::env::temp_dir().join(format!("bsk_cache_{prefix}_{}_{n}", std::process::id())); - std::fs::create_dir_all(&dir).expect("create temp dir"); - dir -} - -/// Write a `pyproject.toml` into `dir` opting into the annotation house rules -/// (`BSK-0001`/`BSK-0050` …), which are off by default — the default config is -/// pure PEP conformance. Tests that assert those diagnostics call this so they -/// see exactly what a user who enabled them would. No modes; this is -/// configuration. See [CHKARCH-CONFIGURATION-ONLY]. Callers use their own -/// unique dir and must not also write a `pyproject.toml` there (the config test -/// below writes its own instead of calling this). -fn opt_in_house_rules(dir: &std::path::Path) { - std::fs::write( - dir.join("pyproject.toml"), - "[tool.basilisk.rules]\n\"BSK-0001\" = \"error\"\n\"BSK-0002\" = \"error\"\n", - ) - .expect("write pyproject.toml"); -} - -/// Run `basilisk --cache --cache-dir --cache-stats`. -/// -/// `check` and `analyze` share the cache flags and pipeline; entries are -/// scope-free so both commands share them ([CHKARCH-COMMANDS]). -fn run_cached(subcommand: &str, target: &PathBuf, cache: &PathBuf) -> Output { - Command::new(env!("CARGO_BIN_EXE_basilisk")) - .arg(subcommand) - .arg(target) - .arg("--cache") - .arg("--cache-dir") - .arg(cache) - .arg("--cache-stats") - .output() - .expect("spawn basilisk") -} - -/// Run `basilisk check` with the cache enabled. -fn check_cached(target: &PathBuf, cache: &PathBuf) -> Output { - run_cached("check", target, cache) -} - -/// Run `basilisk analyze` with the cache enabled — the command that renders -/// the opt-in house-rule diagnostics these fixtures produce -/// ([CHKARCH-COMMANDS]). -fn analyze_cached(target: &PathBuf, cache: &PathBuf) -> Output { - run_cached("analyze", target, cache) -} - -fn stdout(output: &Output) -> String { - String::from_utf8_lossy(&output.stdout).into_owned() -} - -fn stderr(output: &Output) -> String { - String::from_utf8_lossy(&output.stderr).into_owned() -} - -/// Assert the `--cache-stats` line reports the expected hit/miss counts. -fn assert_stats(output: &Output, hits: usize, misses: usize) { - let line = format!("cache: {hits} hit / {misses} miss"); - assert!( - stderr(output).contains(&line), - "expected `{line}` in stderr, got:\n{}", - stderr(output) - ); -} - -// ── CHKCACHE-TEST-HIT / CHKCACHE-TEST-STATS ───────────────────────────────── - -/// A second run is a hit and replays byte-identical diagnostics. -#[test] -fn second_run_hits_with_identical_output() { - let dir = unique_dir("hit"); - opt_in_house_rules(&dir); - let target = dir.join("t.py"); - let cache = dir.join("cache"); - std::fs::write(&target, "def f(x):\n return x\n").unwrap(); - - let first = analyze_cached(&target, &cache); - assert_stats(&first, 0, 1); - let second = analyze_cached(&target, &cache); - assert_stats(&second, 1, 0); - - assert_eq!( - stdout(&first), - stdout(&second), - "a cache hit must replay byte-identical diagnostics" - ); - assert!( - stdout(&first).contains("BSK-0001"), - "the fixture must produce a diagnostic to make the parity check meaningful" - ); - assert_eq!( - first.status.code(), - second.status.code(), - "exit code parity" - ); -} - -// ── CHKCACHE-TEST-TARGET ──────────────────────────────────────────────────── - -/// Editing the target between runs yields a fresh result, never the stale one. -#[test] -fn editing_target_invalidates() { - let dir = unique_dir("target"); - opt_in_house_rules(&dir); - let target = dir.join("t.py"); - let cache = dir.join("cache"); - - std::fs::write(&target, "def f(x):\n return x\n").unwrap(); - let first = analyze_cached(&target, &cache); - assert!( - stdout(&first).contains("BSK-0001"), - "first run reports error" - ); - - // Fix the error; the cached entry must NOT be served. - std::fs::write(&target, "def f(x: int) -> int:\n return x\n").unwrap(); - let second = analyze_cached(&target, &cache); - assert_stats(&second, 0, 1); - assert!( - !stdout(&second).contains("BSK-0001"), - "stale cached diagnostics must not survive a target edit:\n{}", - stdout(&second) - ); - assert_eq!(second.status.code(), Some(0), "fixed file must exit clean"); -} - -// ── CHKCACHE-TEST-DEP ─────────────────────────────────────────────────────── - -/// Editing an imported dependency invalidates the importer's cached result. -#[test] -fn editing_dependency_invalidates() { - let dir = unique_dir("dep"); - let importer = dir.join("a.py"); - let dependency = dir.join("b.py"); - let cache = dir.join("cache"); - std::fs::write( - &importer, - "from b import helper\n\ndef use() -> int:\n return helper()\n", - ) - .unwrap(); - std::fs::write(&dependency, "def helper() -> int:\n return 1\n").unwrap(); - - assert_stats(&check_cached(&importer, &cache), 0, 1); - assert_stats(&check_cached(&importer, &cache), 1, 0); - - // Touch the dependency: the importer must be re-checked, not served stale. - std::fs::write(&dependency, "def helper() -> str:\n return \"x\"\n").unwrap(); - assert_stats(&check_cached(&importer, &cache), 0, 1); -} - -// ── CHKCACHE-TEST-CONFIG ──────────────────────────────────────────────────── - -/// A config change forces a miss even when every source file is unchanged. -#[test] -fn changing_config_invalidates() { - let dir = unique_dir("config"); - let target = dir.join("m.py"); - let cache = dir.join("cache"); - let pyproject = dir.join("pyproject.toml"); - std::fs::write(&target, "x: int = 42\n").unwrap(); - std::fs::write( - &pyproject, - "[project]\nname = \"x\"\nversion = \"0.1.0\"\n\n[tool.basilisk.rules]\n\"BSK-0050\" = \"warning\"\n", - ) - .unwrap(); - - assert_stats(&check_cached(&target, &cache), 0, 1); - assert_stats(&check_cached(&target, &cache), 1, 0); - - // Same source, different config: the fingerprint must differ → miss. - std::fs::write( - &pyproject, - "[project]\nname = \"x\"\nversion = \"0.1.0\"\n\n[tool.basilisk.rules]\n\"BSK-0050\" = \"error\"\n", - ) - .unwrap(); - assert_stats(&check_cached(&target, &cache), 0, 1); -} - -// ── CHKCACHE-TEST-DISABLED ────────────────────────────────────────────────── - -/// Without `--cache`, no cache directory is created and output is unchanged. -// Exercises [CHKCACHE-CLI] -#[test] -fn disabled_creates_no_cache_dir() { - let dir = unique_dir("disabled"); - opt_in_house_rules(&dir); - let target = dir.join("t.py"); - std::fs::write(&target, "def f(x):\n return x\n").unwrap(); - - let plain = Command::new(env!("CARGO_BIN_EXE_basilisk")) - .arg("analyze") - .arg(&target) - .output() - .expect("spawn basilisk"); - - assert!( - !dir.join(".basilisk").exists(), - "no cache directory may be created without --cache" - ); - assert!( - stdout(&plain).contains("BSK-0001"), - "plain analyze output must be unchanged" - ); - assert!( - !stderr(&plain).contains("cache:"), - "no cache stats without --cache-stats" - ); -} - -// ── CHKCACHE-CONFIG ───────────────────────────────────────────────────────── - -/// Run `basilisk analyze --cache-stats` with no cache flag at all, so -/// the project's `[tool.basilisk] cache` key is the only thing that can turn -/// the cache on. -fn analyze_with_extra(target: &PathBuf, extra: &[&str]) -> Output { - Command::new(env!("CARGO_BIN_EXE_basilisk")) - .arg("analyze") - .arg(target) - .arg("--cache-stats") - .args(extra) - .output() - .expect("spawn basilisk") -} - -/// A project that writes `cache = true` gets the cache with no flags: the -/// configuration IS the switch ([CHKCACHE-CONFIG]). -#[test] -fn pyproject_cache_key_enables_the_cache_without_any_flag() { - let dir = unique_dir("cfg_enable"); - std::fs::write( - dir.join("pyproject.toml"), - "[tool.basilisk]\ncache = true\n\n[tool.basilisk.rules]\n\"BSK-0001\" = \"error\"\n", - ) - .unwrap(); - let target = dir.join("t.py"); - std::fs::write(&target, "def f(x):\n return x\n").unwrap(); - - let cold = analyze_with_extra(&target, &[]); - assert_stats(&cold, 0, 1); - assert!( - dir.join(".basilisk").join("cache").join("check").is_dir(), - "the configured cache must use the documented default folder" - ); - - let warm = analyze_with_extra(&target, &[]); - assert_stats(&warm, 1, 0); - assert_eq!( - stdout(&cold), - stdout(&warm), - "a configured warm run must replay identical diagnostics" - ); -} - -/// `cache-dir` relocates the cache, and the relative path anchors to the -/// project root rather than the caller's working directory ([CHKCACHE-CONFIG]). -#[test] -fn pyproject_cache_dir_relocates_the_cache() { - let dir = unique_dir("cfg_dir"); - std::fs::write( - dir.join("pyproject.toml"), - "[tool.basilisk]\ncache = true\ncache-dir = \"build/bsk\"\n", - ) - .unwrap(); - let target = dir.join("t.py"); - std::fs::write(&target, "def f(x: int) -> int:\n return x\n").unwrap(); - - assert_stats(&analyze_with_extra(&target, &[]), 0, 1); - assert!( - dir.join("build").join("bsk").is_dir(), - "the configured cache-dir must hold the entries" - ); - assert!( - !dir.join(".basilisk").exists(), - "the default folder must not be created once cache-dir is configured" - ); - assert_stats(&analyze_with_extra(&target, &[]), 1, 0); -} - -/// `--no-cache` overrides `cache = true` for one run, and creates nothing -/// ([CHKCACHE-CONFIG]). -#[test] -fn no_cache_flag_overrides_the_configured_cache() { - let dir = unique_dir("cfg_no_cache"); - std::fs::write( - dir.join("pyproject.toml"), - "[tool.basilisk]\ncache = true\n", - ) - .unwrap(); - let target = dir.join("t.py"); - std::fs::write(&target, "def f(x: int) -> int:\n return x\n").unwrap(); - - let output = analyze_with_extra(&target, &["--no-cache"]); - assert_stats(&output, 0, 0); - assert!( - !dir.join(".basilisk").exists(), - "--no-cache must create no cache directory" - ); -} - -/// `--no-cache` wins over `--cache` when a command line states both, so the -/// explicit opt-out is never silently dropped ([CHKCACHE-CONFIG]). -#[test] -fn no_cache_flag_wins_over_cache_flag() { - let dir = unique_dir("cfg_both_flags"); - let target = dir.join("t.py"); - std::fs::write(&target, "def f(x: int) -> int:\n return x\n").unwrap(); - - let output = analyze_with_extra(&target, &["--cache", "--no-cache"]); - assert_stats(&output, 0, 0); - assert!(!dir.join(".basilisk").exists()); -} - -/// `cache = false` is honoured, and `--cache` still overrides it for one run -/// ([CHKCACHE-CONFIG]). -#[test] -fn explicit_cache_false_is_honoured_and_flag_overridable() { - let dir = unique_dir("cfg_false"); - std::fs::write( - dir.join("pyproject.toml"), - "[tool.basilisk]\ncache = false\n", - ) - .unwrap(); - let target = dir.join("t.py"); - std::fs::write(&target, "def f(x: int) -> int:\n return x\n").unwrap(); - - assert_stats(&analyze_with_extra(&target, &[]), 0, 0); - assert!(!dir.join(".basilisk").exists()); - - assert_stats(&analyze_with_extra(&target, &["--cache"]), 0, 1); - assert!(dir.join(".basilisk").join("cache").join("check").is_dir()); -} diff --git a/crates/basilisk-cli/tests/e2e_clean.rs b/crates/basilisk-cli/tests/e2e_clean.rs deleted file mode 100644 index 4d6e8ed8a..000000000 --- a/crates/basilisk-cli/tests/e2e_clean.rs +++ /dev/null @@ -1,290 +0,0 @@ -//! Tests for [CHKARCH-CLI]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-CLI -#![allow( - clippy::allow_attributes, - clippy::indexing_slicing, - clippy::expect_used, - clippy::unwrap_used, - clippy::panic, - clippy::as_conversions -)] -//! Clean fixture tests — zero diagnostics expected. -//! -//! These tests verify that fully-typed Python files produce no diagnostics. - -mod common; - -use common::run; - -// --------------------------------------------------------------------------- -// Clean fixtures — zero diagnostics expected -// --------------------------------------------------------------------------- - -#[test] -fn clean_fully_typed_module_is_silent() -> Result<(), Box> { - let diags = run("clean/fully_typed_module.py")?; - assert!( - diags.is_empty(), - "fully_typed_module.py must produce no diagnostics, got:\n{diags:#?}" - ); - Ok(()) -} - -#[test] -fn clean_typed_with_varargs_is_silent() -> Result<(), Box> { - let diags = run("clean/typed_with_varargs.py")?; - assert!( - diags.is_empty(), - "typed_with_varargs.py must produce no diagnostics, got:\n{diags:#?}" - ); - Ok(()) -} - -#[test] -fn clean_nested_functions_is_silent() -> Result<(), Box> { - let diags = run("clean/nested_functions.py")?; - assert!( - diags.is_empty(), - "nested_functions.py must produce no diagnostics, got:\n{diags:#?}" - ); - Ok(()) -} - -// --------------------------------------------------------------------------- -// Clean fixtures — additional patterns, zero diagnostics expected -// --------------------------------------------------------------------------- - -#[test] -fn clean_typed_generics_is_silent() -> Result<(), Box> { - let diags = run("clean/typed_generics.py")?; - assert!( - diags.is_empty(), - "typed_generics.py must produce no diagnostics, got:\n{diags:#?}" - ); - Ok(()) -} - -#[test] -fn clean_typed_optional_is_silent() -> Result<(), Box> { - let diags = run("clean/typed_optional.py")?; - assert!( - diags.is_empty(), - "typed_optional.py must produce no diagnostics, got:\n{diags:#?}" - ); - Ok(()) -} - -#[test] -fn clean_typed_inheritance_is_silent() -> Result<(), Box> { - let diags = run("clean/typed_inheritance.py")?; - assert!( - diags.is_empty(), - "typed_inheritance.py must produce no diagnostics, got:\n{diags:#?}" - ); - Ok(()) -} - -#[test] -fn clean_typed_dataclass_style_is_silent() -> Result<(), Box> { - let diags = run("clean/typed_dataclass_style.py")?; - assert!( - diags.is_empty(), - "typed_dataclass_style.py must produce no diagnostics, got:\n{diags:#?}" - ); - Ok(()) -} - -#[test] -fn clean_typed_control_flow_is_silent() -> Result<(), Box> { - let diags = run("clean/typed_control_flow.py")?; - assert!( - diags.is_empty(), - "typed_control_flow.py must produce no diagnostics, got:\n{diags:#?}" - ); - Ok(()) -} - -// --------------------------------------------------------------------------- -// Clean fixtures — control flow and exception handling -// --------------------------------------------------------------------------- - -#[test] -fn clean_typed_try_except_is_silent() -> Result<(), Box> { - let diags = run("clean/typed_try_except.py")?; - assert!( - diags.is_empty(), - "typed_try_except.py must produce no diagnostics, got:\n{diags:#?}" - ); - Ok(()) -} - -#[test] -fn clean_typed_while_for_is_silent() -> Result<(), Box> { - let diags = run("clean/typed_while_for.py")?; - assert!( - diags.is_empty(), - "typed_while_for.py must produce no diagnostics, got:\n{diags:#?}" - ); - Ok(()) -} - -#[test] -fn clean_typed_with_statement_is_silent() -> Result<(), Box> { - let diags = run("clean/typed_with_statement.py")?; - assert!( - diags.is_empty(), - "typed_with_statement.py must produce no diagnostics, got:\n{diags:#?}" - ); - Ok(()) -} - -// --------------------------------------------------------------------------- -// Clean — overloads with different arities must not trigger E0020 or E0021 -// --------------------------------------------------------------------------- - -#[test] -fn clean_overloads_different_arity_is_silent() -> Result<(), Box> { - let diags = run("clean/typed_overloads_multi_arity.py")?; - assert!( - diags.is_empty(), - "overloads with different arities must produce no diagnostics, got:\n{diags:#?}" - ); - Ok(()) -} - -// --------------------------------------------------------------------------- -// Clean fixtures for new rules — must produce zero diagnostics -// --------------------------------------------------------------------------- - -#[test] -fn clean_typed_module_vars_is_silent() -> Result<(), Box> { - let diags = run("clean/typed_module_vars.py")?; - assert!( - diags.is_empty(), - "typed_module_vars.py must produce no diagnostics, got:\n{diags:#?}" - ); - Ok(()) -} - -#[test] -fn clean_typed_class_attrs_is_silent() -> Result<(), Box> { - let diags = run("clean/typed_class_attrs.py")?; - assert!( - diags.is_empty(), - "typed_class_attrs.py must produce no diagnostics, got:\n{diags:#?}" - ); - Ok(()) -} - -#[test] -fn clean_typed_overloads_is_silent() -> Result<(), Box> { - let diags = run("clean/typed_overloads.py")?; - assert!( - diags.is_empty(), - "typed_overloads.py must produce no diagnostics, got:\n{diags:#?}" - ); - Ok(()) -} - -#[test] -fn clean_typed_override_is_silent() -> Result<(), Box> { - let diags = run("clean/typed_override.py")?; - assert!( - diags.is_empty(), - "typed_override.py must produce no diagnostics, got:\n{diags:#?}" - ); - Ok(()) -} - -#[test] -fn clean_typed_match_is_silent() -> Result<(), Box> { - let diags = run("clean/typed_match.py")?; - assert!( - diags.is_empty(), - "typed_match.py must produce no diagnostics, got:\n{diags:#?}" - ); - Ok(()) -} - -// --------------------------------------------------------------------------- -// stdlib imports must NOT trigger E0010 -// --------------------------------------------------------------------------- - -#[test] -fn clean_stdlib_imports_are_silent() -> Result<(), Box> { - let diags = run("clean/typed_stdlib_imports.py")?; - let e0010: Vec<_> = diags - .iter() - .filter(|d| d.code.code == "imports_unresolved") - .collect(); - assert!( - e0010.is_empty(), - "stdlib imports must not produce E0010, got:\n{e0010:#?}" - ); - Ok(()) -} - -// --------------------------------------------------------------------------- -// concrete annotations must NOT trigger the explicit-Any warning -// --------------------------------------------------------------------------- - -#[test] -fn clean_concrete_annotations_no_any_warning() -> Result<(), Box> { - let diags = run("clean/typed_any_justified.py")?; - let any_warnings: Vec<_> = diags.iter().filter(|d| d.code.code == "BSK-0014").collect(); - assert!( - any_warnings.is_empty(), - "concrete annotations must not produce the BSK-0014 explicit-Any warning, got:\n{any_warnings:#?}" - ); - Ok(()) -} - -// --------------------------------------------------------------------------- -// match with wildcard must NOT trigger E0023 -// --------------------------------------------------------------------------- - -#[test] -fn clean_match_with_wildcard_is_silent() -> Result<(), Box> { - let diags = run("clean/typed_match.py")?; - let e0023: Vec<_> = diags - .iter() - .filter(|d| d.code.code == "match_exhaustiveness") - .collect(); - assert!( - e0023.is_empty(), - "match with wildcard must not produce E0023, got:\n{e0023:#?}" - ); - Ok(()) -} - -// --------------------------------------------------------------------------- -// override WITH @override must NOT trigger BSK-0025 -// --------------------------------------------------------------------------- - -#[test] -fn clean_override_with_decorator_is_silent() -> Result<(), Box> { - let diags = run("clean/typed_override.py")?; - let e0025: Vec<_> = diags.iter().filter(|d| d.code.code == "BSK-0025").collect(); - assert!( - e0025.is_empty(), - "override with @override must not produce BSK-0025, got:\n{e0025:#?}" - ); - Ok(()) -} - -// --------------------------------------------------------------------------- -// proper @overload with implementation must NOT trigger E0020 -// --------------------------------------------------------------------------- - -#[test] -fn clean_overloads_with_implementation_is_silent() -> Result<(), Box> { - let diags = run("clean/typed_overloads.py")?; - let e0020: Vec<_> = diags - .iter() - .filter(|d| d.code.code == "overloads_definitions") - .collect(); - assert!( - e0020.is_empty(), - "properly implemented overloads must not produce E0020, got:\n{e0020:#?}" - ); - Ok(()) -} diff --git a/crates/basilisk-cli/tests/e2e_config_ancestor_walk.rs b/crates/basilisk-cli/tests/e2e_config_ancestor_walk.rs deleted file mode 100644 index 581cac4a2..000000000 --- a/crates/basilisk-cli/tests/e2e_config_ancestor_walk.rs +++ /dev/null @@ -1,224 +0,0 @@ -//! End-to-end tests for per-file rule-config discovery -//! ([CHKARCH-CONFIG-DISCOVERY]). -//! See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-CONFIG-DISCOVERY -//! -//! The contract under test, exactly as a user experiences it through the -//! real binary (GitHub #311): -//! - A FILE argument discovers its rule config from ancestor directories — -//! the nearest `[tool.basilisk]` table that decides a rule wins, however -//! deep the checked file sits below it. -//! - Diagnostics NEVER depend on argument order: every checked file resolves -//! its own ancestor chain, not the first argument's. -//! - A `pyproject.toml` WITHOUT `[tool.basilisk]` contributes nothing and -//! does not stop the walk (Ruff `[tool.ruff]` semantics). -//! - Scalar keys resolve nearest-first: a child `python-version` overrides -//! an ancestor's for files under the child. -#![allow( - clippy::allow_attributes, - clippy::expect_used, - clippy::unwrap_used, - clippy::panic -)] - -use std::path::{Path, PathBuf}; -use std::process::{Command, Output}; -use std::sync::atomic::{AtomicU64, Ordering}; - -/// A function body whose return type mismatch always produces -/// `returns_compatibility` AND `returns_compatibility_2` diagnostics when -/// the file is checked, so configs downgrade both codes together. -const BAD_PY: &str = "def f() -> int:\n return \"bad\"\n"; - -fn unique_dir(prefix: &str) -> PathBuf { - static CTR: AtomicU64 = AtomicU64::new(0); - let n = CTR.fetch_add(1, Ordering::Relaxed); - let dir = std::env::temp_dir().join(format!( - "bsk_ancestor_walk_{prefix}_{}_{n}", - std::process::id() - )); - std::fs::create_dir_all(&dir).expect("create temp dir"); - dir -} - -fn write(dir: &Path, rel: &str, contents: &str) { - let path = dir.join(rel); - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent).expect("create parent dir"); - } - std::fs::write(path, contents).expect("write file"); -} - -fn check(dir: &Path, args: &[&str]) -> Output { - Command::new(env!("CARGO_BIN_EXE_basilisk")) - .arg("check") - .args(args) - .current_dir(dir) - .env_remove("VIRTUAL_ENV") - .output() - .expect("spawn basilisk") -} - -fn stdout_of(output: &Output) -> String { - String::from_utf8_lossy(&output.stdout).into_owned() -} - -/// The blank-line-separated diagnostic records that mention `file_marker`. -/// A record couples the `severity[code]:` header with its `--> path` line, -/// so severity can be asserted per file. The docs URL inside a record -/// contains the substring `errors/`, so severity checks must match the -/// `severity[` header prefix, never a bare `error`. -fn records_for(stdout: &str, file_marker: &str) -> Vec { - stdout - .split("\n\n") - .map(str::trim_start) - .filter(|block| block.contains(file_marker)) - .map(str::to_owned) - .collect() -} - -#[test] -fn file_argument_discovers_rule_config_from_ancestor_directories() { - let dir = unique_dir("ancestor_discovery"); - write( - &dir, - "pyproject.toml", - "[project]\nname = \"x\"\nversion = \"0.1.0\"\n\n[tool.basilisk.rules]\nreturns_compatibility = \"warning\"\nreturns_compatibility_2 = \"warning\"\n", - ); - write(&dir, "pkg/app.py", BAD_PY); - - let output = check(&dir, &["pkg/app.py"]); - let stdout = stdout_of(&output); - let stderr = String::from_utf8_lossy(&output.stderr); - - assert!( - stdout.contains("warning[returns_compatibility"), - "the root table's downgrade must reach a file passed as a nested FILE argument (GitHub #311 headline), stdout: {stdout}, stderr: {stderr}" - ); - assert!( - !stdout.contains("error["), - "the downgraded diagnostics must not be reported as errors, stdout: {stdout}" - ); - assert_eq!( - output.status.code(), - Some(0), - "a warning-only run must exit 0 — the ancestor downgrade was honored, stdout: {stdout}, stderr: {stderr}" - ); - - let _ = std::fs::remove_dir_all(&dir); -} - -#[test] -fn diagnostics_do_not_depend_on_argument_order() { - let dir = unique_dir("argument_order"); - write( - &dir, - "pyproject.toml", - "[project]\nname = \"x\"\nversion = \"0.1.0\"\n", - ); - write( - &dir, - "a/pyproject.toml", - "[tool.basilisk.rules]\nreturns_compatibility = \"warning\"\nreturns_compatibility_2 = \"warning\"\n", - ); - write(&dir, "a/x.py", BAD_PY); - write(&dir, "b/y.py", BAD_PY); - - for args in [["a/x.py", "b/y.py"], ["b/y.py", "a/x.py"]] { - let output = check(&dir, &args); - let stdout = stdout_of(&output); - let stderr = String::from_utf8_lossy(&output.stderr); - let x_records = records_for(&stdout, "x.py"); - let y_records = records_for(&stdout, "y.py"); - - assert!( - !x_records.is_empty() - && x_records.iter().all(|r| r.starts_with("warning[")), - "a/x.py must ALWAYS take a/'s downgrade regardless of argument order {args:?} (GitHub #311), stdout: {stdout}, stderr: {stderr}" - ); - assert!( - !y_records.is_empty() && y_records.iter().all(|r| r.starts_with("error[")), - "b/y.py must ALWAYS keep the default error severity regardless of argument order {args:?}, stdout: {stdout}, stderr: {stderr}" - ); - assert_eq!( - output.status.code(), - Some(1), - "the error in b/y.py must fail the run in both orders, stdout: {stdout}, stderr: {stderr}" - ); - } - - let _ = std::fs::remove_dir_all(&dir); -} - -#[test] -fn pyproject_without_tool_basilisk_does_not_stop_the_walk() { - let dir = unique_dir("walk_through"); - write( - &dir, - "pyproject.toml", - "[project]\nname = \"x\"\nversion = \"0.1.0\"\n\n[tool.basilisk.rules]\nreturns_compatibility = \"warning\"\nreturns_compatibility_2 = \"warning\"\n", - ); - write( - &dir, - "mid/pyproject.toml", - "[project]\nname = \"mid\"\nversion = \"0.1.0\"\n", - ); - write(&dir, "mid/pkg/app.py", BAD_PY); - - let output = check(&dir, &["mid/pkg/app.py"]); - let stdout = stdout_of(&output); - let stderr = String::from_utf8_lossy(&output.stderr); - - assert!( - stdout.contains("warning[returns_compatibility") && !stdout.contains("error["), - "a bare [project] pyproject in mid/ must not stop the walk — the ROOT downgrade still applies (Ruff semantics, [CHKARCH-CONFIG-DISCOVERY]), stdout: {stdout}, stderr: {stderr}" - ); - assert_eq!( - output.status.code(), - Some(0), - "the run holds only the downgraded warning, so it must pass, stdout: {stdout}, stderr: {stderr}" - ); - - let _ = std::fs::remove_dir_all(&dir); -} - -#[test] -fn nearest_python_version_wins_over_ancestor() { - let dir = unique_dir("scalar_nearest"); - write( - &dir, - "pyproject.toml", - "[project]\nname = \"x\"\nversion = \"0.1.0\"\n\n[tool.basilisk]\npython-version = \"3.12\"\n", - ); - write( - &dir, - "legacy/pyproject.toml", - "[tool.basilisk]\npython-version = \"3.9\"\n", - ); - // tomllib joined the stdlib in 3.11: visible on 3.9, absent-flagged there. - write(&dir, "legacy/app.py", "import tomllib\n"); - write(&dir, "app.py", "import tomllib\n"); - - let legacy = check(&dir, &["legacy/app.py"]); - let legacy_stdout = stdout_of(&legacy); - let legacy_stderr = String::from_utf8_lossy(&legacy.stderr); - assert_eq!( - legacy.status.code(), - Some(1), - "under legacy/'s python-version = 3.9 the tomllib import must be flagged — the CHILD scalar wins over the root's 3.12 ([CHKARCH-CONFIG-DISCOVERY] scalar merge), stdout: {legacy_stdout}, stderr: {legacy_stderr}" - ); - assert!( - legacy_stdout.contains("tomllib"), - "the diagnostic must name the version-gated module, stdout: {legacy_stdout}" - ); - - let root = check(&dir, &["app.py"]); - let root_stdout = stdout_of(&root); - let root_stderr = String::from_utf8_lossy(&root.stderr); - assert_eq!( - root.status.code(), - Some(0), - "at the root the 3.12 target applies and tomllib is clean — proof the 3.9 came from legacy/'s table, not global state, stdout: {root_stdout}, stderr: {root_stderr}" - ); - - let _ = std::fs::remove_dir_all(&dir); -} diff --git a/crates/basilisk-cli/tests/e2e_config_overrides.rs b/crates/basilisk-cli/tests/e2e_config_overrides.rs deleted file mode 100644 index f48525bfd..000000000 --- a/crates/basilisk-cli/tests/e2e_config_overrides.rs +++ /dev/null @@ -1,308 +0,0 @@ -//! Tests for [CHKARCH-CONFIG-MODEL] / [CHKARCH-COMMANDS]. See -//! docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-CONFIG-MODEL -#![allow( - clippy::allow_attributes, - clippy::indexing_slicing, - clippy::expect_used, - clippy::unwrap_used, - clippy::panic, - clippy::as_conversions, - unused_results, - dead_code -)] -//! E2E tests for the configuration model through the full pipeline. -//! -//! Pipeline: `parse_file` → `resolve` → `check_with_config` -//! -//! The model is two flat maps ([CHKARCH-CONFIG-MODEL]): `[tool.basilisk.rules]` -//! (code → severity) and `[tool.basilisk.rule-tags]` (tag → severity), -//! resolved nearest-deciding-table-first; a rule entry beats tag entries and -//! the strictest matching tag wins. There are no per-path globs, per-module -//! overrides, or presets. - -mod common; - -use std::collections::HashMap; - -use basilisk_checker::{check_with_config, Diagnostic, Severity}; -use basilisk_config::{BasiliskConfig, RuleSeverity, RuleTables}; -use basilisk_parser::parse_file; -use basilisk_resolver::resolve; -use common::fixture; - -/// Parse + resolve + check with a given config. -fn run_with_config( - rel: &str, - config: &BasiliskConfig, -) -> Result, Box> { - let path = fixture(rel); - let parsed = parse_file(&path)?; - let resolved = resolve(&parsed)?; - Ok(check_with_config(&resolved, config)) -} - -/// A config whose single nearest table holds these per-rule entries. -fn rules_config(entries: &[(&str, RuleSeverity)]) -> BasiliskConfig { - BasiliskConfig::with_rule_entries( - entries - .iter() - .map(|(code, severity)| ((*code).to_owned(), *severity)) - .collect(), - ) -} - -/// A config whose single nearest table holds these tag entries. -fn tags_config(entries: &[(&str, RuleSeverity)]) -> BasiliskConfig { - BasiliskConfig { - rule_chain: vec![RuleTables { - rules: HashMap::new(), - rule_tags: entries - .iter() - .map(|(tag, severity)| ((*tag).to_owned(), *severity)) - .collect(), - }], - ..BasiliskConfig::default() - } -} - -/// Config with explicit severities for the opt-in rules used here. -fn annotations_on() -> BasiliskConfig { - rules_config(&[ - ("BSK-0001", RuleSeverity::Error), - ("BSK-0002", RuleSeverity::Error), - ]) -} - -// --------------------------------------------------------------------------- -// Rule-entry selection and grading ([CHKARCH-CONFIG-MODEL]) -// --------------------------------------------------------------------------- - -/// A `disabled` entry deselects an analyze-scope rule entirely. -#[test] -fn rule_entry_disabled_suppresses_bsk_0001() -> Result<(), Box> { - let config = rules_config(&[ - ("BSK-0001", RuleSeverity::Disabled), - ("BSK-0002", RuleSeverity::Error), - ]); - - let diags = run_with_config("missing_param_annotation.py", &config)?; - let has_bsk_0001 = diags.iter().any(|d| d.code.code == "BSK-0001"); - assert!( - !has_bsk_0001, - "BSK-0001 must be deselected by a disabled rule entry, got: {diags:#?}" - ); - Ok(()) -} - -/// A `warning` entry selects and grades the rule. -#[test] -fn rule_entry_warning_demotes_bsk_0001() -> Result<(), Box> { - let config = rules_config(&[("BSK-0001", RuleSeverity::Warning)]); - - let diags = run_with_config("missing_param_annotation.py", &config)?; - let bsk_0001: Vec<_> = diags.iter().filter(|d| d.code.code == "BSK-0001").collect(); - assert!(!bsk_0001.is_empty(), "should still emit BSK-0001, graded"); - for diag in &bsk_0001 { - assert_eq!( - diag.severity, - Severity::Warning, - "BSK-0001 should be graded to warning, got: {diag:?}" - ); - } - Ok(()) -} - -/// An `info` entry selects and grades the rule. -#[test] -fn rule_entry_info_demotes_bsk_0001() -> Result<(), Box> { - let config = rules_config(&[("BSK-0001", RuleSeverity::Info)]); - - let diags = run_with_config("missing_param_annotation.py", &config)?; - let bsk_0001: Vec<_> = diags.iter().filter(|d| d.code.code == "BSK-0001").collect(); - assert!( - !bsk_0001.is_empty(), - "should still emit BSK-0001, graded to info" - ); - for diag in &bsk_0001 { - assert_eq!( - diag.severity, - Severity::Info, - "BSK-0001 should be graded to info, got: {diag:?}" - ); - } - Ok(()) -} - -/// The default config selects nothing beyond the pep scope: `check()` and -/// `check_with_config(default)` are identical. [CHKARCH-CONFIGURATION-ONLY] -#[test] -fn default_config_does_not_change_diagnostics() -> Result<(), Box> { - let config = BasiliskConfig::default(); - let diags_config = run_with_config("missing_param_annotation.py", &config)?; - - // Compare with plain check() (no config) - let path = fixture("missing_param_annotation.py"); - let parsed = parse_file(&path)?; - let resolved = resolve(&parsed)?; - let diags_plain = basilisk_checker::check(&resolved); - - assert_eq!( - diags_config.len(), - diags_plain.len(), - "default config should produce identical diagnostics" - ); - Ok(()) -} - -// --------------------------------------------------------------------------- -// Tag entries ([CHKARCH-CONFIG-MODEL]) -// --------------------------------------------------------------------------- - -/// One `"basilisk" = "error"` tag entry turns every house rule on: the -/// annotation rules fire without any per-rule entry. -#[test] -fn basilisk_tag_entry_selects_house_rules() -> Result<(), Box> { - let config = tags_config(&[("basilisk", RuleSeverity::Error)]); - - let diags = run_with_config("missing_both.py", &config)?; - let codes: Vec<&str> = diags.iter().map(|d| d.code.code).collect(); - assert!( - codes.contains(&"BSK-0001") && codes.contains(&"BSK-0002"), - "the `basilisk` tag entry must select the annotation rules, got: {codes:?}" - ); - assert!( - diags - .iter() - .filter(|d| d.code.code.starts_with("BSK-000")) - .all(|d| d.severity == Severity::Error), - "the tag entry's severity grades the selected rules" - ); - Ok(()) -} - -/// Within one table a per-rule entry beats tag entries: the tag turns the -/// house rules on at error, the rule entry re-grades one of them to info. -#[test] -fn rule_entry_beats_tag_entry() -> Result<(), Box> { - let config = BasiliskConfig { - rule_chain: vec![RuleTables { - rules: [("BSK-0001".to_owned(), RuleSeverity::Info)] - .into_iter() - .collect(), - rule_tags: [("basilisk".to_owned(), RuleSeverity::Error)] - .into_iter() - .collect(), - }], - ..BasiliskConfig::default() - }; - - let diags = run_with_config("missing_both.py", &config)?; - let bsk_0001: Vec<_> = diags.iter().filter(|d| d.code.code == "BSK-0001").collect(); - assert!(!bsk_0001.is_empty(), "BSK-0001 must still be selected"); - for diag in &bsk_0001 { - assert_eq!( - diag.severity, - Severity::Info, - "the per-rule entry must beat the tag entry within one table" - ); - } - assert!( - diags - .iter() - .filter(|d| d.code.code == "BSK-0002") - .all(|d| d.severity == Severity::Error), - "rules without a per-rule entry keep the tag entry's grade" - ); - Ok(()) -} - -// --------------------------------------------------------------------------- -// Nearest-table resolution ([CHKARCH-CONFIG-MODEL]) -// --------------------------------------------------------------------------- - -/// The nearest table that decides a rule wins outright: a nearer `warning` -/// entry beats an ancestor `error` entry — per rule, not per table. -#[test] -fn nearest_deciding_table_wins() -> Result<(), Box> { - // rule_chain is nearest-first ([CHKARCH-CONFIG-DISCOVERY]). - let nearer = RuleTables { - rules: [("BSK-0001".to_owned(), RuleSeverity::Warning)] - .into_iter() - .collect(), - rule_tags: HashMap::new(), - }; - let ancestor = RuleTables { - rules: [ - ("BSK-0001".to_owned(), RuleSeverity::Error), - ("BSK-0002".to_owned(), RuleSeverity::Error), - ] - .into_iter() - .collect(), - rule_tags: HashMap::new(), - }; - let config = BasiliskConfig { - rule_chain: vec![nearer, ancestor], - ..BasiliskConfig::default() - }; - - let diags = run_with_config("missing_both.py", &config)?; - assert!( - diags - .iter() - .filter(|d| d.code.code == "BSK-0001") - .all(|d| d.severity == Severity::Warning), - "the nearest table's BSK-0001 grade must win" - ); - assert!( - diags - .iter() - .any(|d| d.code.code == "BSK-0002" && d.severity == Severity::Error), - "rules the nearest table does not decide fall through to the ancestor" - ); - Ok(()) -} - -// --------------------------------------------------------------------------- -// PEP rules can be graded, never disabled ([CHKARCH-CONFIG-MODEL]) -// --------------------------------------------------------------------------- - -/// Grading a pep rule works like any entry. -#[test] -fn pep_rule_grades_to_warning() -> Result<(), Box> { - let config = rules_config(&[("returns_compatibility_2", RuleSeverity::Warning)]); - let diags = run_with_config("errors/e0013_return_mismatch.py", &config)?; - let graded: Vec<_> = diags - .iter() - .filter(|d| d.code.code == "returns_compatibility_2") - .collect(); - assert!(!graded.is_empty(), "the pep rule must still fire"); - assert!( - graded.iter().all(|d| d.severity == Severity::Warning), - "a pep rule can be graded to warning, got: {graded:#?}" - ); - Ok(()) -} - -/// A config resolving a pep rule to `disabled` is invalid — -/// `pep_disable_violations` reports it, and the checker defensively keeps the -/// rule running so `check` never loses a PEP diagnostic. -#[test] -fn pep_rule_disable_is_invalid_and_defensively_ignored() -> Result<(), Box> { - let config = rules_config(&[("returns_compatibility_2", RuleSeverity::Disabled)]); - - let violations = basilisk_checker::pep_disable_violations(&config); - assert_eq!( - violations, - vec!["returns_compatibility_2"], - "the invalid pep-disable must be reported" - ); - - let diags = run_with_config("errors/e0013_return_mismatch.py", &config)?; - assert!( - diags - .iter() - .any(|d| d.code.code == "returns_compatibility_2"), - "the checker must defensively keep the pep rule running, got: {diags:#?}" - ); - Ok(()) -} diff --git a/crates/basilisk-cli/tests/e2e_cross_module_final.rs b/crates/basilisk-cli/tests/e2e_cross_module_final.rs deleted file mode 100644 index bddea9a6d..000000000 --- a/crates/basilisk-cli/tests/e2e_cross_module_final.rs +++ /dev/null @@ -1,132 +0,0 @@ -//! Tests for [CHKARCH-DIAG-OWNERSHIP]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG-OWNERSHIP -#![allow( - clippy::allow_attributes, - clippy::indexing_slicing, - clippy::expect_used, - clippy::unwrap_used, - clippy::panic, - clippy::as_conversions, - unused_results, - dead_code -)] -//! E2E: overriding a `@final` method whose definition lives in an imported -//! sibling `.pyi` stub must be flagged (`qualifiers_final_decorator` cross-module final override). - -use std::fs; -use std::path::{Path, PathBuf}; -use std::sync::atomic::{AtomicU64, Ordering}; - -use basilisk_checker::check; -use basilisk_parser::parse_file; -use basilisk_resolver::resolve; - -static CTR: AtomicU64 = AtomicU64::new(0); - -fn unique_tmp(prefix: &str) -> PathBuf { - let ctr = CTR.fetch_add(1, Ordering::Relaxed); - std::env::temp_dir().join(format!("{prefix}_{ctr}_{}", std::process::id())) -} - -fn codes_for(main: &Path) -> Vec { - let parsed = parse_file(main.to_str().unwrap()).unwrap(); - let resolved = resolve(&parsed).unwrap(); - check(&resolved) - .iter() - .map(|d| d.code.code.to_owned()) - .collect() -} - -#[test] -fn cross_module_final_first_overload_override_fires() { - let dir = unique_tmp("e2e_xmod_final_a"); - fs::create_dir_all(&dir).unwrap(); - // `@final` on the first overload of a stub marks the whole method final. - fs::write( - dir.join("_basemod.pyi"), - "from typing import final, overload\n\ - class Base:\n\ - \x20 @final\n\ - \x20 @overload\n\ - \x20 def method(self, x: int) -> int: ...\n\ - \x20 @overload\n\ - \x20 def method(self, x: str) -> str: ...\n", - ) - .unwrap(); - let main = dir.join("main.py"); - fs::write( - &main, - "from _basemod import Base\n\ - class D(Base):\n\ - \x20 def method(self, x):\n\ - \x20 return x\n", - ) - .unwrap(); - - let codes = codes_for(&main); - assert!( - codes.contains(&"qualifiers_final_decorator".to_owned()), - "overriding an imported @final method must fire E0034, got: {codes:?}" - ); - let _ = fs::remove_dir_all(&dir); -} - -#[test] -fn cross_module_final_swapped_decorator_order_fires() { - let dir = unique_tmp("e2e_xmod_final_b"); - fs::create_dir_all(&dir).unwrap(); - // `@overload` then `@final` (swapped) on the first overload is equivalent. - fs::write( - dir.join("_basemod.pyi"), - "from typing import final, overload\n\ - class Base:\n\ - \x20 @overload\n\ - \x20 @final\n\ - \x20 def method(self, x: int) -> int: ...\n\ - \x20 @overload\n\ - \x20 def method(self, x: str) -> str: ...\n", - ) - .unwrap(); - let main = dir.join("main.py"); - fs::write( - &main, - "from _basemod import Base\n\ - class D(Base):\n\ - \x20 def method(self, x):\n\ - \x20 return x\n", - ) - .unwrap(); - - let codes = codes_for(&main); - assert!( - codes.contains(&"qualifiers_final_decorator".to_owned()), - "swapped @overload/@final order must still fire E0034, got: {codes:?}" - ); - let _ = fs::remove_dir_all(&dir); -} - -#[test] -fn cross_module_non_final_override_ok() { - let dir = unique_tmp("e2e_xmod_final_c"); - fs::create_dir_all(&dir).unwrap(); - fs::write( - dir.join("_basemod.pyi"), - "class Base:\n\x20 def method(self, x: int) -> int: ...\n", - ) - .unwrap(); - let main = dir.join("main.py"); - fs::write( - &main, - "from _basemod import Base\n\ - class D(Base):\n\ - \x20 def method(self, x):\n\ - \x20 return x\n", - ) - .unwrap(); - - let codes = codes_for(&main); - assert!( - !codes.contains(&"qualifiers_final_decorator".to_owned()), - "overriding a non-final imported method must not fire E0034, got: {codes:?}" - ); - let _ = fs::remove_dir_all(&dir); -} diff --git a/crates/basilisk-cli/tests/e2e_deep_expressions.rs b/crates/basilisk-cli/tests/e2e_deep_expressions.rs deleted file mode 100644 index 4ffd830fe..000000000 --- a/crates/basilisk-cli/tests/e2e_deep_expressions.rs +++ /dev/null @@ -1,151 +0,0 @@ -//! Tests for [LSPARCH-ARCH-STACK]. See docs/specs/LSP-ARCHITECTURE-SPEC.md#LSPARCH-ARCH-STACK -#![allow( - clippy::allow_attributes, - clippy::indexing_slicing, - clippy::expect_used, - clippy::unwrap_used, - clippy::panic -)] -//! A single generated file with a deeply chained expression must never take -//! the LSP server (or the CLI) down (GitHub #278). -//! -//! `ruff_python_parser` caps parenthesis and indentation nesting, but a long -//! binary-operator chain (`total = 1 + 1 + …`) parses fine and yields an -//! arbitrarily deep left-nested `BinOp` tree. The recursive resolver/checker -//! visitors then recurse once per level — on a default ~2 MB tokio worker -//! stack the workspace scan overflowed and aborted the whole server -//! (`thread 'tokio-rt-worker' has overflowed its stack`, exit `0xC00000FD` -//! on Windows), crash-looping VS Code's restart logic. The CLI had the same -//! exposure on the process main thread (~8 MiB on macOS/Linux, ~1 MiB on -//! Windows). -//! -//! These tests drive the REAL `basilisk` binary, because the fix lives in -//! the production entry points' thread/runtime construction — an in-process -//! fixture runs on the test's own runtime and cannot observe it. - -mod lsp_stdio; - -use std::process::Command; -use std::time::Duration; - -use lsp_stdio::{unique_temp_dir, LspProcess}; -use serde_json::json; - -/// Terms in the generated chain. 10,000 is the confirmed real-binary repro -/// for issue #278: it overflows a default tokio worker stack during the -/// workspace scan, while remaining comfortably within the analysis stack -/// size mandated by [LSPARCH-ARCH-STACK]. -const CHAIN_TERMS: usize = 10_000; - -/// Terms that overflow the CLI's default main-thread stack (~8 MiB on -/// macOS/Linux): 20,000 aborts a debug build and 30,000 aborts release, so -/// 30,000 crashes every build flavour before the fix while staying well -/// inside the 64 MiB analysis stack. -const CLI_CHAIN_TERMS: usize = 30_000; - -/// Write a workspace whose single file is an `n`-term `1 + 1 + …` chain and -/// return `(root, source)`. -fn chain_workspace(prefix: &str, n: usize) -> (std::path::PathBuf, String) { - let root = unique_temp_dir(prefix); - std::fs::create_dir_all(&root).expect("create workspace root"); - let chain = vec!["1"; n].join(" + "); - let source = format!("total: int = {chain}\n"); - std::fs::write(root.join("generated.py"), &source).expect("write generated.py"); - (root, source) -} - -#[test] -fn workspace_scan_survives_deeply_chained_binary_expression() { - let (root, deep_source) = chain_workspace("bsk_deep_expr_ws", CHAIN_TERMS); - - let mut lsp = LspProcess::start_with(Some(&root), &json!(null)); - - // The startup scan analyses generated.py. Before the fix the server - // process died right here with a stack overflow, so stdout closed and no - // scan-complete notification ever arrived. - let scan_complete = lsp.wait_for_notification("basilisk/scanComplete", Duration::from_mins(2)); - assert_eq!( - scan_complete["params"]["totalFiles"].as_u64(), - Some(1), - "scan must have analysed the generated file: {scan_complete}" - ); - - // The server must also survive the pathological file being OPENED — the - // user's next click after the project loads — and stay responsive. - let deep_uri = format!("file://{}/generated.py", root.to_string_lossy()); - lsp.did_open(&deep_uri, &deep_source); - let hover = lsp.request( - "textDocument/hover", - &json!({ - "textDocument": { "uri": deep_uri }, - "position": { "line": 0, "character": 1 } - }), - ); - assert!( - hover.get("contents").is_some(), - "server must still answer hover on the deep file: {hover}" - ); - - drop(lsp); - let _ = std::fs::remove_dir_all(&root); -} - -#[test] -fn cli_check_survives_deeply_chained_binary_expression() { - let (root, _source) = chain_workspace("bsk_deep_expr_cli", CLI_CHAIN_TERMS); - - // `total: int = 1 + 1 + …` is well-typed, so a surviving checker exits 0 - // with no diagnostics. Before the fix the process aborted with a stack - // overflow (SIGABRT — `status.code()` is None on Unix). - let output = Command::new(env!("CARGO_BIN_EXE_basilisk")) - .arg("check") - .arg(&root) - .output() - .expect("run basilisk check"); - assert_eq!( - output.status.code(), - Some(0), - "check must analyse the deep chain cleanly without crashing: status {:?}, stderr: {}", - output.status, - String::from_utf8_lossy(&output.stderr) - ); - - let _ = std::fs::remove_dir_all(&root); -} - -/// Terms beyond ANY stack: the measured 64 MiB crash floor is ~150,000 in -/// debug builds and ~210,000 in release, so 300,000 aborts every flavour -/// unless the parse-depth guard rejects the file first. -const UNCHECKABLE_CHAIN_TERMS: usize = 300_000; - -// Tests [CHKARCH-ARCH-PARSEDEPTH] — see -// docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-ARCH-PARSEDEPTH. -#[test] -fn cli_check_rejects_pathological_expression_depth_instead_of_crashing() { - let (root, _source) = chain_workspace("bsk_deep_expr_cap", UNCHECKABLE_CHAIN_TERMS); - - // The guard reports a normal per-file analysis failure — matching CPython's - // own nesting rejection — and the process must never abort or claim success. - let output = Command::new(env!("CARGO_BIN_EXE_basilisk")) - .arg("check") - .arg(&root) - .output() - .expect("run basilisk check"); - assert_eq!( - output.status.code(), - Some(3), - "the depth guard must fail cleanly without crashing: status {:?}", - output.status - ); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - stderr.contains("expression too deeply nested"), - "the skip must be explained in the warning: {stderr}" - ); - assert!( - !String::from_utf8_lossy(&output.stdout).contains("No issues found"), - "a rejected file must never produce a clean-success message" - ); - - let _ = std::fs::remove_dir_all(&root); -} diff --git a/crates/basilisk-cli/tests/e2e_exclude_config.rs b/crates/basilisk-cli/tests/e2e_exclude_config.rs deleted file mode 100644 index 655739a97..000000000 --- a/crates/basilisk-cli/tests/e2e_exclude_config.rs +++ /dev/null @@ -1,374 +0,0 @@ -//! End-to-end tests for `exclude` semantics ([CHKARCH-CONFIG-EXCLUDE]). -//! See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-CONFIG-EXCLUDE -//! -//! The contract under test, exactly as a user experiences it through the -//! real binary: -//! - With no `exclude` key, [`basilisk_config::DEFAULT_EXCLUDES`] applies: -//! vendored/cache trees (`node_modules`, `site-packages`, `build`, …) are -//! never scanned. -//! - Setting `exclude` REPLACES the defaults entirely — it does not extend -//! them. A project that still wants `node_modules` skipped must re-add it. -//! - Hidden (`.`-prefixed) directories are always skipped regardless. -//! - Virtualenvs are skipped structurally, by their PEP 405 `pyvenv.cfg` -//! marker, regardless of directory name or `exclude` configuration. -//! - Patterns are gitignore-style: a bare name matches at any depth; an -//! anchored `dir/**` pattern excludes the whole subtree. -#![allow( - clippy::allow_attributes, - clippy::expect_used, - clippy::unwrap_used, - clippy::panic -)] - -use std::path::{Path, PathBuf}; -use std::process::{Command, Output}; -use std::sync::atomic::{AtomicU64, Ordering}; - -/// A function body whose return type mismatch always produces -/// `returns_compatibility` diagnostics when the file is scanned. -const BAD_PY: &str = "def f() -> int:\n return \"bad\"\n"; - -fn unique_dir(prefix: &str) -> PathBuf { - static CTR: AtomicU64 = AtomicU64::new(0); - let n = CTR.fetch_add(1, Ordering::Relaxed); - let dir = std::env::temp_dir().join(format!( - "bsk_exclude_config_{prefix}_{}_{n}", - std::process::id() - )); - std::fs::create_dir_all(&dir).expect("create temp dir"); - dir -} - -fn write(dir: &Path, rel: &str, contents: &str) { - let path = dir.join(rel); - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent).expect("create parent dir"); - } - std::fs::write(path, contents).expect("write file"); -} - -fn pyproject_with(dir: &Path, basilisk_table: &str) { - write( - dir, - "pyproject.toml", - &format!( - "[project]\nname = \"x\"\nversion = \"0.1.0\"\n\n[tool.basilisk]\n{basilisk_table}" - ), - ); -} - -fn check_dot(dir: &Path) -> Output { - Command::new(env!("CARGO_BIN_EXE_basilisk")) - .arg("check") - .arg(".") - .current_dir(dir) - .env_remove("VIRTUAL_ENV") - .output() - .expect("spawn basilisk") -} - -fn stdout_of(output: &Output) -> String { - String::from_utf8_lossy(&output.stdout).into_owned() -} - -#[test] -fn default_excludes_skip_vendored_and_cache_directories() { - let dir = unique_dir("defaults"); - pyproject_with(&dir, ""); - write(&dir, "node_modules/bad.py", BAD_PY); - write(&dir, "site-packages/bad.py", BAD_PY); - write(&dir, "build/bad.py", BAD_PY); - write(&dir, "dist/bad.py", BAD_PY); - write(&dir, "__pycache__/bad.py", BAD_PY); - write(&dir, "ok.py", "value: int = 1\n"); - - let output = check_dot(&dir); - let stdout = stdout_of(&output); - let stderr = String::from_utf8_lossy(&output.stderr); - - assert!( - !stdout.contains("returns_compatibility"), - "DEFAULT_EXCLUDES directories must never be scanned when `exclude` is unset, stdout: {stdout}, stderr: {stderr}" - ); - for skipped in [ - "node_modules", - "site-packages", - "build", - "dist", - "__pycache__", - ] { - assert!( - !stdout.contains(skipped), - "no diagnostic may name the default-excluded `{skipped}` tree, stdout: {stdout}" - ); - } - assert_eq!( - output.status.code(), - Some(0), - "a project whose only defects sit inside default-excluded trees must pass, stdout: {stdout}, stderr: {stderr}" - ); - - let _ = std::fs::remove_dir_all(&dir); -} - -#[test] -fn setting_exclude_replaces_the_default_list_entirely() { - let dir = unique_dir("replace"); - pyproject_with(&dir, "exclude = [\"generated\"]\n"); - write(&dir, "node_modules/bad.py", BAD_PY); - write(&dir, "generated/bad.py", BAD_PY); - - let output = check_dot(&dir); - let stdout = stdout_of(&output); - let stderr = String::from_utf8_lossy(&output.stderr); - - assert!( - stdout.contains("returns_compatibility"), - "`exclude = [\"generated\"]` replaces the defaults, so node_modules must now be scanned and its defect reported, stdout: {stdout}, stderr: {stderr}" - ); - assert!( - stdout.contains("node_modules"), - "the diagnostic must point into the now-scanned node_modules tree, stdout: {stdout}" - ); - assert!( - !stdout.contains("generated"), - "the user's own `generated` entry must still be excluded, stdout: {stdout}" - ); - assert_eq!( - output.status.code(), - Some(1), - "a defect in a no-longer-excluded tree is a real finding, so the CLI must exit 1, stdout: {stdout}, stderr: {stderr}" - ); - - let _ = std::fs::remove_dir_all(&dir); -} - -#[test] -fn re_adding_a_default_entry_restores_its_exclusion() { - let dir = unique_dir("readd"); - pyproject_with(&dir, "exclude = [\"generated\", \"node_modules\"]\n"); - write(&dir, "node_modules/bad.py", BAD_PY); - write(&dir, "generated/bad.py", BAD_PY); - write(&dir, "ok.py", "value: int = 1\n"); - - let output = check_dot(&dir); - let stdout = stdout_of(&output); - let stderr = String::from_utf8_lossy(&output.stderr); - - assert!( - !stdout.contains("returns_compatibility"), - "explicitly re-added default entries must be excluded again, stdout: {stdout}, stderr: {stderr}" - ); - assert_eq!( - output.status.code(), - Some(0), - "both excluded trees are skipped, so the check must pass, stdout: {stdout}, stderr: {stderr}" - ); - - let _ = std::fs::remove_dir_all(&dir); -} - -#[test] -fn hidden_directories_are_always_skipped_even_with_custom_exclude() { - let dir = unique_dir("hidden"); - pyproject_with(&dir, "exclude = [\"generated\"]\n"); - write(&dir, ".hidden/bad.py", BAD_PY); - write(&dir, "ok.py", "value: int = 1\n"); - - let output = check_dot(&dir); - let stdout = stdout_of(&output); - let stderr = String::from_utf8_lossy(&output.stderr); - - assert!( - !stdout.contains(".hidden") && !stdout.contains("returns_compatibility"), - "`.`-prefixed directories are skipped regardless of the user's `exclude` list, stdout: {stdout}, stderr: {stderr}" - ); - assert_eq!( - output.status.code(), - Some(0), - "a defect inside a hidden directory must never fail the check, stdout: {stdout}, stderr: {stderr}" - ); - - let _ = std::fs::remove_dir_all(&dir); -} - -#[test] -fn bare_name_pattern_matches_at_any_depth() { - let dir = unique_dir("any_depth"); - pyproject_with(&dir, "exclude = [\"generated\"]\n"); - write(&dir, "a/generated/bad.py", BAD_PY); - write(&dir, "ok.py", "value: int = 1\n"); - - let output = check_dot(&dir); - let stdout = stdout_of(&output); - let stderr = String::from_utf8_lossy(&output.stderr); - - assert!( - !stdout.contains("returns_compatibility"), - "a bare `generated` pattern must also exclude the nested a/generated tree, stdout: {stdout}, stderr: {stderr}" - ); - assert_eq!( - output.status.code(), - Some(0), - "nothing outside excluded trees is defective, so the check must pass, stdout: {stdout}, stderr: {stderr}" - ); - - let _ = std::fs::remove_dir_all(&dir); -} - -#[test] -fn anchored_glob_excludes_the_whole_subtree() { - let dir = unique_dir("anchored"); - pyproject_with(&dir, "exclude = [\"vendor/**\"]\n"); - write(&dir, "vendor/sub/bad.py", BAD_PY); - write(&dir, "ok.py", "value: int = 1\n"); - - let output = check_dot(&dir); - let stdout = stdout_of(&output); - let stderr = String::from_utf8_lossy(&output.stderr); - - assert!( - !stdout.contains("returns_compatibility"), - "`vendor/**` must exclude every file beneath vendor/, stdout: {stdout}, stderr: {stderr}" - ); - assert_eq!( - output.status.code(), - Some(0), - "the only defect sits inside the excluded subtree, so the check must pass, stdout: {stdout}, stderr: {stderr}" - ); - - let _ = std::fs::remove_dir_all(&dir); -} - -/// Lay down a project whose custom `exclude` replaces the defaults (so no -/// `venv`/`site-packages` entry survives) with a real virtualenv beside the -/// sources, marked by PEP 405's `pyvenv.cfg`. -fn write_project_with_virtualenv(dir: &Path, vendored: &str) { - pyproject_with( - dir, - "exclude = [\"generated\"]\n\n[tool.basilisk.rules]\n\"BSK-0050\" = \"warning\"\n", - ); - write(dir, "venv/pyvenv.cfg", "home = /usr\n"); - write( - dir, - "venv/lib/python3.13/site-packages/dep/mod.py", - vendored, - ); - write(dir, "src/main.py", "x: int = 42\n"); -} - -/// Issue #341: a virtualenv is skipped today only because `venv`/`.venv`/ -/// `site-packages` happen to be literal entries in `DEFAULT_EXCLUDES` — and any -/// custom `exclude` replaces that list wholesale. `fix` mutates files, so the -/// gap rewrites third-party installed packages. The venv must be pruned -/// structurally, by its `pyvenv.cfg` marker, whatever `exclude` says. -#[test] -fn fix_never_rewrites_inside_a_virtualenv_when_custom_exclude_replaces_defaults() { - let dir = unique_dir("venv_fix"); - write_project_with_virtualenv(&dir, "y: int = 42\n"); - - let output = Command::new(env!("CARGO_BIN_EXE_basilisk")) - .arg("fix") - .args(["--rules", "BSK-0050"]) - .current_dir(&dir) - .env_remove("VIRTUAL_ENV") - .output() - .expect("spawn basilisk"); - let stderr = String::from_utf8_lossy(&output.stderr); - - assert_eq!( - std::fs::read_to_string(dir.join("venv/lib/python3.13/site-packages/dep/mod.py")) - .expect("read vendored"), - "y: int = 42\n", - "`fix` must never mutate third-party sources inside a virtualenv, however \ - `exclude` is configured, stdout: {}, stderr: {stderr}", - stdout_of(&output) - ); - assert_eq!( - std::fs::read_to_string(dir.join("src/main.py")).expect("read src"), - "x = 42\n", - "the project's own sources must still be fixed, stderr: {stderr}" - ); - - let _ = std::fs::remove_dir_all(&dir); -} - -/// The read-only half of the same walk: `check` must not report diagnostics -/// from inside a virtualenv either, or the editor and CLI disagree about which -/// files exist ([CHKARCH-CONFIG-EXCLUDE]). -#[test] -fn check_does_not_scan_inside_a_virtualenv_when_custom_exclude_replaces_defaults() { - let dir = unique_dir("venv_check"); - write_project_with_virtualenv(&dir, BAD_PY); - - let output = check_dot(&dir); - let stdout = stdout_of(&output); - let stderr = String::from_utf8_lossy(&output.stderr); - - assert!( - !stdout.contains("returns_compatibility"), - "a defect inside a virtualenv must never be reported, stdout: {stdout}, stderr: {stderr}" - ); - assert_eq!( - output.status.code(), - Some(0), - "the only defect sits inside the virtualenv, so the check must pass, \ - stdout: {stdout}, stderr: {stderr}" - ); - - let _ = std::fs::remove_dir_all(&dir); -} - -/// The structural skip prunes *traversal into* a virtualenv; it does not -/// override an explicit request. Pointing the CLI straight at a path inside one -/// still checks it, mirroring the walk's existing depth-0 root exemption. -#[test] -fn an_explicit_path_inside_a_virtualenv_is_still_checked() { - let dir = unique_dir("venv_explicit"); - write_project_with_virtualenv(&dir, BAD_PY); - - let output = Command::new(env!("CARGO_BIN_EXE_basilisk")) - .arg("check") - .arg("venv/lib/python3.13/site-packages/dep") - .current_dir(&dir) - .env_remove("VIRTUAL_ENV") - .output() - .expect("spawn basilisk"); - let stdout = stdout_of(&output); - - assert!( - stdout.contains("returns_compatibility"), - "an explicitly requested path must still be checked, stdout: {stdout}, stderr: {}", - String::from_utf8_lossy(&output.stderr) - ); - - let _ = std::fs::remove_dir_all(&dir); -} - -#[test] -fn excluded_defect_does_not_mask_a_real_one_outside() { - let dir = unique_dir("mixed"); - pyproject_with(&dir, "exclude = [\"generated\"]\n"); - write(&dir, "generated/bad.py", BAD_PY); - write(&dir, "src/real_bug.py", BAD_PY); - - let output = check_dot(&dir); - let stdout = stdout_of(&output); - let stderr = String::from_utf8_lossy(&output.stderr); - - assert!( - stdout.contains("real_bug.py") && stdout.contains("returns_compatibility"), - "the defect outside the excluded tree must still be reported, stdout: {stdout}, stderr: {stderr}" - ); - assert!( - !stdout.contains("generated"), - "the excluded tree must contribute no diagnostics, stdout: {stdout}" - ); - assert_eq!( - output.status.code(), - Some(1), - "one real defect means exit 1, stdout: {stdout}, stderr: {stderr}" - ); - - let _ = std::fs::remove_dir_all(&dir); -} diff --git a/crates/basilisk-cli/tests/e2e_format.rs b/crates/basilisk-cli/tests/e2e_format.rs deleted file mode 100644 index 66b8ac1fb..000000000 --- a/crates/basilisk-cli/tests/e2e_format.rs +++ /dev/null @@ -1,259 +0,0 @@ -//! Tests for [LSPFMT-CLIENTS] / [CHKARCH-CLI-COMMANDS]. See -//! docs/specs/LSP-FORMATTING-SPEC.md#LSPFMT-CLIENTS -#![allow( - clippy::allow_attributes, - clippy::expect_used, - clippy::unwrap_used, - clippy::panic -)] -//! Real-binary tests for `basilisk format`: write and `--check` behaviour, -//! multiple paths, parse failures, `[tool.ruff]` style configuration, -//! formatter disablement, and byte-parity with the LSP formatting path. -//! -//! Every test spawns the compiled binary with `PATH` pointing at an empty -//! directory, so no external `ruff` (or anything else) is findable — the -//! embedded engine must do all the work ([LSPFMT-DECISION]). - -use std::path::{Path, PathBuf}; -use std::process::{Command, Output}; -use std::sync::atomic::{AtomicU32, Ordering}; - -static DIR_COUNTER: AtomicU32 = AtomicU32::new(0); - -/// A unique, empty project directory for one test. -fn project_dir(tag: &str) -> PathBuf { - let dir = std::env::temp_dir().join(format!( - "basilisk_fmt_{tag}_{}_{}", - std::process::id(), - DIR_COUNTER.fetch_add(1, Ordering::Relaxed) - )); - std::fs::create_dir_all(&dir).expect("create project dir"); - dir -} - -fn write(dir: &Path, rel: &str, content: &str) -> PathBuf { - let path = dir.join(rel); - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent).expect("create parent dir"); - } - std::fs::write(&path, content).expect("write fixture"); - path -} - -fn read(path: &Path) -> String { - std::fs::read_to_string(path).expect("read fixture back") -} - -/// Run `basilisk format ` inside `dir` with an empty `PATH`. -fn run_format(dir: &Path, args: &[&str]) -> Output { - let empty_path = dir.join(".empty-path"); - std::fs::create_dir_all(&empty_path).expect("create empty PATH dir"); - Command::new(env!("CARGO_BIN_EXE_basilisk")) - .current_dir(dir) - .env("PATH", &empty_path) - .arg("format") - .args(args) - .output() - .expect("spawn basilisk format") -} - -fn stdout_of(output: &Output) -> String { - String::from_utf8_lossy(&output.stdout).into_owned() -} - -fn exit_code(output: &Output) -> i32 { - output.status.code().expect("exit code") -} - -// ── Write mode ─────────────────────────────────────────────────────────────── - -/// Write mode rewrites the file to exactly the embedded Ruff output. The -/// input is the same source the LSP no-ruff test formats, so the expected -/// bytes pin CLI/LSP parity ([LSPFMT-CLIENTS] acceptance). -#[test] -fn write_mode_produces_ruff_output_with_no_ruff_on_path() { - let dir = project_dir("write"); - let file = write(&dir, "app.py", "x=1\ny = 'two'\n"); - - let output = run_format(&dir, &["."]); - - assert_eq!(exit_code(&output), 0, "write mode must exit 0: {output:?}"); - assert_eq!( - read(&file), - "x = 1\ny = \"two\"\n", - "output must be byte-identical to the embedded Ruff formatter" - ); - let stdout = stdout_of(&output); - assert!( - stdout.contains("Reformatted 1 file"), - "summary must count the rewrite: {stdout}" - ); - // [LSPFMT-PROVENANCE]: the CLI names the engine that produced the bytes. - assert!( - stdout.contains("embedded Ruff"), - "summary must attribute the embedded engine: {stdout}" - ); -} - -/// An already-formatted file is left byte-identical and reported unchanged. -#[test] -fn write_mode_leaves_formatted_file_untouched() { - let dir = project_dir("clean"); - let file = write(&dir, "app.py", "x = 1\n"); - - let output = run_format(&dir, &["."]); - - assert_eq!(exit_code(&output), 0); - assert_eq!(read(&file), "x = 1\n", "clean file must not be rewritten"); - let stdout = stdout_of(&output); - assert!( - stdout.contains("Reformatted 0 files") && stdout.contains("1 already formatted"), - "summary must report the file as already formatted: {stdout}" - ); -} - -// ── Check mode ─────────────────────────────────────────────────────────────── - -/// `--check` reports the file and exits 1 without writing anything. -#[test] -fn check_mode_reports_without_writing() { - let dir = project_dir("check"); - let file = write(&dir, "app.py", "x=1\n"); - - let output = run_format(&dir, &["--check", "."]); - - assert_eq!(exit_code(&output), 1, "--check must exit 1 on a diff"); - assert_eq!(read(&file), "x=1\n", "--check must never write"); - let stdout = stdout_of(&output); - assert!( - stdout.contains("Would reformat") && stdout.contains("app.py"), - "--check must name the unformatted file: {stdout}" - ); -} - -/// `--check` on a formatted tree exits 0. -#[test] -fn check_mode_clean_tree_exits_zero() { - let dir = project_dir("check_clean"); - let _ = write(&dir, "app.py", "x = 1\n"); - - let output = run_format(&dir, &["--check", "."]); - - assert_eq!(exit_code(&output), 0, "clean --check must exit 0"); - let stdout = stdout_of(&output); - assert!( - stdout.contains("0 files would be reformatted"), - "clean --check summary: {stdout}" - ); -} - -// ── Multiple paths ─────────────────────────────────────────────────────────── - -/// A directory argument recurses and an explicit file argument is taken -/// verbatim; both format in one run. -#[test] -fn multiple_paths_mix_directories_and_files() { - let dir = project_dir("multi"); - let one = write(&dir, "pkg/one.py", "a=1\n"); - let two = write(&dir, "two.py", "b = 2\n"); - - let output = run_format(&dir, &["pkg", "two.py"]); - - assert_eq!(exit_code(&output), 0); - assert_eq!(read(&one), "a = 1\n"); - assert_eq!(read(&two), "b = 2\n"); - assert!( - stdout_of(&output).contains("Reformatted 2 files"), - "both paths must be formatted: {}", - stdout_of(&output) - ); -} - -// ── Parse failures ─────────────────────────────────────────────────────────── - -/// Invalid syntax is refused (never rewritten), the rest of the run still -/// formats, and the exit code is 1. -#[test] -fn parse_failure_exits_one_and_never_rewrites_the_broken_file() { - let dir = project_dir("parse_fail"); - let broken = write(&dir, "broken.py", "def f(:\n"); - let good = write(&dir, "good.py", "x=1\n"); - - let output = run_format(&dir, &["."]); - - assert_eq!(exit_code(&output), 1, "parse failure must exit 1"); - assert_eq!(read(&broken), "def f(:\n", "broken file must be untouched"); - assert_eq!(read(&good), "x = 1\n", "healthy files must still format"); - assert!( - stdout_of(&output).contains("failed to parse"), - "summary must surface the parse failure: {}", - stdout_of(&output) - ); -} - -// ── Style configuration ────────────────────────────────────────────────────── - -/// `[tool.ruff]` / `[tool.ruff.format]` style options are honoured, exactly -/// as the LSP path reads them ([LSPFMT-ENGINE] config-respecting). -#[test] -fn style_options_from_pyproject_are_honoured() { - let dir = project_dir("style"); - let _ = write( - &dir, - "pyproject.toml", - "[tool.ruff]\nline-length = 100\n\n[tool.ruff.format]\nquote-style = \"single\"\n", - ); - let file = write(&dir, "app.py", "x=\"s\"\n"); - - let output = run_format(&dir, &["."]); - - assert_eq!(exit_code(&output), 0); - assert_eq!( - read(&file), - "x = 's'\n", - "quote-style = single must produce single quotes" - ); -} - -// ── Formatter disablement ──────────────────────────────────────────────────── - -/// `formatter = "none"` disables the CLI exactly as it stops the LSP -/// advertising formatting capabilities ([LSPFMT-CONFIG]). -#[test] -fn disabled_formatter_is_a_no_op() { - let dir = project_dir("disabled"); - let _ = write(&dir, "pyrightconfig.json", "{\"formatter\": \"none\"}"); - let file = write(&dir, "app.py", "x=1\n"); - - let output = run_format(&dir, &["."]); - - assert_eq!(exit_code(&output), 0, "disabled formatter must exit 0"); - assert_eq!(read(&file), "x=1\n", "disabled formatter must not write"); - assert!( - stdout_of(&output).contains("disabled"), - "the no-op must be explicit, never silent: {}", - stdout_of(&output) - ); -} - -// ── Exclude semantics ──────────────────────────────────────────────────────── - -/// `[tool.basilisk] exclude` is honoured by the same shared matcher as -/// `check` and `fix` ([CHKARCH-CONFIG-EXCLUDE]). -#[test] -fn excluded_directories_are_skipped() { - let dir = project_dir("exclude"); - let _ = write( - &dir, - "pyproject.toml", - "[tool.basilisk]\nexclude = [\"vendor\"]\n", - ); - let vendored = write(&dir, "vendor/gen.py", "x=1\n"); - let app = write(&dir, "app.py", "y=2\n"); - - let output = run_format(&dir, &["."]); - - assert_eq!(exit_code(&output), 0); - assert_eq!(read(&vendored), "x=1\n", "excluded file must be untouched"); - assert_eq!(read(&app), "y = 2\n", "included file must format"); -} diff --git a/crates/basilisk-cli/tests/e2e_import_resolution.rs b/crates/basilisk-cli/tests/e2e_import_resolution.rs deleted file mode 100644 index 600f12066..000000000 --- a/crates/basilisk-cli/tests/e2e_import_resolution.rs +++ /dev/null @@ -1,223 +0,0 @@ -//! Tests for [LSPUV-DIAGNOSTICS-MODULE-NOT-FOUND], [LSPUV-LOCK-IMPORT-MAPPING], -//! and [LSPUV-WORKSPACE-IMPORT-RESOLUTION]. See docs/specs/LSP-UV-SPEC.md. -#![allow( - clippy::allow_attributes, - clippy::expect_used, - clippy::unwrap_used, - clippy::panic -)] -//! Coarse end-to-end tests for import-resolution classification (issue #25) -//! and src-layout first-party resolution in the CLI (issue #24). - -use std::path::{Path, PathBuf}; -use std::process::{Command, Output}; -use std::sync::atomic::{AtomicU64, Ordering}; - -/// A throwaway directory unique to this process and call. -fn unique_dir(prefix: &str) -> PathBuf { - static CTR: AtomicU64 = AtomicU64::new(0); - let n = CTR.fetch_add(1, Ordering::Relaxed); - let dir = std::env::temp_dir().join(format!("bsk_imres_{prefix}_{}_{n}", std::process::id())); - std::fs::create_dir_all(&dir).expect("create temp dir"); - dir -} - -/// Run `basilisk check ` from inside `dir` with no ambient venv. -fn check(dir: &Path, args: &[&str]) -> Output { - check_with_venv(dir, args, None) -} - -/// Like [`check`], but points `VIRTUAL_ENV` at `venv` when `Some` (the standard -/// signal for an active environment) and removes it otherwise. The `Some` form -/// pins import resolution to a hermetic env so a test never falls back to the -/// host interpreter's global site-packages (where e.g. Pillow may be installed -/// and would mask the diagnostic under test). -fn check_with_venv(dir: &Path, args: &[&str], venv: Option<&Path>) -> Output { - run_with_venv("check", dir, args, venv) -} - -/// [`check_with_venv`] over `basilisk analyze` — for the opt-in -/// dependency-hygiene diagnostics, which are analyze-scope -/// ([CHKARCH-COMMANDS]). -fn analyze_with_venv(dir: &Path, args: &[&str], venv: Option<&Path>) -> Output { - run_with_venv("analyze", dir, args, venv) -} - -fn run_with_venv(subcommand: &str, dir: &Path, args: &[&str], venv: Option<&Path>) -> Output { - let mut cmd = Command::new(env!("CARGO_BIN_EXE_basilisk")); - let _ = cmd.arg(subcommand).args(args).current_dir(dir); - match venv { - Some(path) => { - let _ = cmd.env("VIRTUAL_ENV", path); - } - None => { - let _ = cmd.env_remove("VIRTUAL_ENV"); - } - } - cmd.output().expect("spawn basilisk") -} - -/// Issue #25: an unsynced-but-declared dependency whose import name differs -/// from its distribution name (Pillow → PIL) must be classified as -/// "declared but the environment is not synced", not "not a dependency". -#[test] -fn declared_unsynced_pillow_classified_as_needs_sync() { - let dir = unique_dir("pillow"); - std::fs::write( - dir.join("pyproject.toml"), - "[project]\nname = \"x\"\nversion = \"0.1.0\"\ndependencies = [\"pillow>=11.0.0\"]\n", - ) - .expect("write pyproject"); - std::fs::write( - dir.join("uv.lock"), - "version = 1\nrequires-python = \">=3.12\"\n\n[[package]]\nname = \"pillow\"\nversion = \"11.0.0\"\n", - ) - .expect("write lock"); - std::fs::create_dir_all(dir.join("src")).expect("mkdir src"); - std::fs::write(dir.join("src/app.py"), "from PIL import Image\n").expect("write app"); - - // Hermetic empty environment: a venv with NO packages installed models - // "declared but not synced" exactly, and pinning `VIRTUAL_ENV` to it stops - // the resolver falling back to the host interpreter (which may have Pillow - // installed globally and would otherwise resolve PIL and mask E0010). - let venv = dir.join(".venv"); - std::fs::create_dir_all(venv.join("lib/python3.12/site-packages")).expect("venv lib"); - std::fs::create_dir_all(venv.join("Lib/site-packages")).expect("venv Lib"); - - let output = check_with_venv(&dir, &["src"], Some(&venv)); - let stdout = String::from_utf8_lossy(&output.stdout); - - assert!( - !stdout.contains("not a dependency in pyproject.toml"), - "PIL is declared (as pillow) — must not be classified NotInstalled, got: {stdout}" - ); - assert!( - stdout.contains("declared but the environment is not synced"), - "PIL should be classified as needs-sync, got: {stdout}" - ); -} - -/// Issue #24: in a src-layout project, both `tests.helpers` and the src -/// package must resolve when checking from the project root. -#[test] -fn src_layout_first_party_imports_resolve() { - let dir = unique_dir("srclayout"); - std::fs::write( - dir.join("pyproject.toml"), - "[project]\nname = \"agent-backend\"\nversion = \"0.1.0\"\n", - ) - .expect("write pyproject"); - std::fs::create_dir_all(dir.join("src/agent_backend/db")).expect("mkdir pkg"); - std::fs::create_dir_all(dir.join("tests")).expect("mkdir tests"); - std::fs::write(dir.join("src/agent_backend/__init__.py"), "").expect("write init"); - std::fs::write(dir.join("src/agent_backend/db/__init__.py"), "").expect("write db init"); - std::fs::write( - dir.join("src/agent_backend/db/models.py"), - "class AgentConfig:\n pass\n", - ) - .expect("write models"); - std::fs::write( - dir.join("tests/helpers.py"), - "from agent_backend.db.models import AgentConfig\n", - ) - .expect("write helpers"); - std::fs::write( - dir.join("tests/test_foo.py"), - "from tests.helpers import AgentConfig\n", - ) - .expect("write test_foo"); - - let output = check(&dir, &["."]); - let stdout = String::from_utf8_lossy(&output.stdout); - - assert!( - !stdout.contains("imports_unresolved"), - "first-party src-layout imports must resolve, got: {stdout}" - ); - assert_eq!( - output.status.code(), - Some(0), - "src-layout project must check clean, stdout: {stdout}" - ); -} - -/// Issue #22: a bare `import foo` where `foo.py` is a sibling file in the -/// same scripts directory (no `__init__.py`) must resolve via the importing -/// file's own directory (sys.path[0] semantics) — even when the check is -/// pointed at the project ROOT, not the scripts directory. -#[test] -fn sibling_script_import_resolves_from_project_root() { - let dir = unique_dir("sibling"); - std::fs::create_dir_all(dir.join("scripts")).expect("mkdir scripts"); - std::fs::write( - dir.join("scripts/configure_agent_backend.py"), - "def main() -> None:\n pass\n", - ) - .expect("write sibling"); - std::fs::write( - dir.join("scripts/configure_agent_backend_test.py"), - "from configure_agent_backend import main\nimport configure_agent_backend as subject\n", - ) - .expect("write importer"); - - let output = check(&dir, &["."]); - let stdout = String::from_utf8_lossy(&output.stdout); - - assert!( - !stdout.contains("imports_unresolved"), - "sibling-module script imports must resolve (issue #22), got: {stdout}" - ); -} - -/// Issue #13: packages shipping a PEP 561 `py.typed` marker are typed — no -/// stub diagnostic; a genuinely untyped package still fires, and its help must -/// not fabricate a nonexistent `types-X` distribution. -#[test] -fn py_typed_packages_not_flagged_untyped_ones_still_fire() { - let dir = unique_dir("pytyped"); - let site = dir.join(".venv/lib/python3.12/site-packages"); - std::fs::create_dir_all(site.join("typedpkg_fake/orm")).expect("mkdir typed"); - std::fs::create_dir_all(site.join("untypedpkg_fake")).expect("mkdir untyped"); - std::fs::write(site.join("typedpkg_fake/__init__.py"), "").expect("write init"); - std::fs::write(site.join("typedpkg_fake/py.typed"), "").expect("write marker"); - std::fs::write( - site.join("typedpkg_fake/orm/__init__.py"), - "class Session:\n pass\n", - ) - .expect("write orm"); - std::fs::write(site.join("untypedpkg_fake/__init__.py"), "").expect("write untyped"); - std::fs::create_dir_all(dir.join("src")).expect("mkdir src"); - // The untyped-package stub suggestion (BSK-0152) is off by default — the - // default config is pure PEP conformance. Opt in via the PROJECT ROOT - // `pyproject.toml`, one level above the checked `src/` root: config - // discovery walks ancestor directories ([CHKARCH-CONFIG-DISCOVERY]), so - // this deliberately proves the ancestor walk applies the rule to `src/` - // files. No modes; this is configuration. See [CHKARCH-CONFIGURATION-ONLY]. - std::fs::write( - dir.join("pyproject.toml"), - "[tool.basilisk.rules]\n\"BSK-0152\" = \"error\"\n", - ) - .expect("write config"); - std::fs::write( - dir.join("src/app.py"), - "import typedpkg_fake\nfrom typedpkg_fake.orm import Session\nimport untypedpkg_fake\n", - ) - .expect("write app"); - - // BSK-0152 is analyze-scope ([CHKARCH-COMMANDS]) — drive `analyze`. - let output = analyze_with_venv(&dir, &["src"], Some(&dir.join(".venv"))); - let stdout = String::from_utf8_lossy(&output.stdout); - - assert!( - !stdout.contains("`typedpkg_fake`"), - "a py.typed package must not be flagged as untyped (issue #13), got: {stdout}" - ); - assert!( - stdout.contains("`untypedpkg_fake`"), - "a genuinely untyped package must still be flagged, got: {stdout}" - ); - assert!( - !stdout.contains("types-untypedpkg_fake"), - "help text must not fabricate a nonexistent types-X distribution, got: {stdout}" - ); -} diff --git a/crates/basilisk-cli/tests/e2e_include_config.rs b/crates/basilisk-cli/tests/e2e_include_config.rs deleted file mode 100644 index 9d327f06c..000000000 --- a/crates/basilisk-cli/tests/e2e_include_config.rs +++ /dev/null @@ -1,174 +0,0 @@ -//! Tests for [CHKARCH-CONFIG-INCLUDE]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-CONFIG-INCLUDE -#![allow( - clippy::allow_attributes, - clippy::expect_used, - clippy::unwrap_used, - clippy::panic -)] -//! Coarse end-to-end tests for `[tool.basilisk] include` as the default check -//! roots (issue #37): a no-args `basilisk check` must walk only the configured -//! include roots instead of the whole repository, so files the user excluded -//! by omission (vendored/generated trees) can no longer crash the process. - -use std::path::{Path, PathBuf}; -use std::process::{Command, Output}; -use std::sync::atomic::{AtomicU64, Ordering}; - -/// A throwaway directory unique to this process and call. -fn unique_dir(prefix: &str) -> PathBuf { - static CTR: AtomicU64 = AtomicU64::new(0); - let n = CTR.fetch_add(1, Ordering::Relaxed); - let dir = std::env::temp_dir().join(format!("bsk_include_{prefix}_{}_{n}", std::process::id())); - std::fs::create_dir_all(&dir).expect("create temp dir"); - dir -} - -/// Run `basilisk check` with no path arguments from inside `dir`. -fn check_no_args(dir: &Path) -> Output { - Command::new(env!("CARGO_BIN_EXE_basilisk")) - .arg("check") - .current_dir(dir) - .env_remove("VIRTUAL_ENV") - .output() - .expect("spawn basilisk") -} - -/// Lay down a project with `[tool.basilisk] include = ["src/", "tests/"]`, -/// clean included sources, and `gen/` content OUTSIDE the include roots. -fn write_include_project(dir: &Path, generated: &str) { - std::fs::write( - dir.join("pyproject.toml"), - "[project]\nname = \"x\"\nversion = \"0.1.0\"\n\n[tool.basilisk]\ninclude = [\"src/\", \"tests/\"]\nexclude = [\"**/migrations/**\"]\n", - ) - .expect("write pyproject"); - std::fs::create_dir_all(dir.join("src")).expect("mkdir src"); - std::fs::create_dir_all(dir.join("tests")).expect("mkdir tests"); - std::fs::create_dir_all(dir.join("gen")).expect("mkdir gen"); - std::fs::write( - dir.join("src/main.py"), - "def add(a: int, b: int) -> int:\n return a + b\n", - ) - .expect("write src"); - std::fs::write( - dir.join("tests/test_main.py"), - "def test_add() -> None:\n assert 1 + 1 == 2\n", - ) - .expect("write tests"); - std::fs::write(dir.join("gen/deep.py"), generated).expect("write gen"); -} - -/// Issue #37 repro: a deeply nested expression in a file outside the include -/// roots overflowed the stack because the no-args run walked the whole repo. -#[test] -fn no_args_honors_include_and_does_not_overflow() { - let dir = unique_dir("overflow"); - let deep = format!("x = {}1{}\n", "(".repeat(20000), ")".repeat(20000)); - write_include_project(&dir, &deep); - - let output = check_no_args(&dir); - - // A stack-overflow abort yields no exit code on Unix; the fixed binary - // must exit cleanly without ever parsing gen/deep.py. - assert_eq!( - output.status.code(), - Some(0), - "no-args check must honor include and exit 0, got status {:?}, stderr: {}", - output.status, - String::from_utf8_lossy(&output.stderr) - ); -} - -/// The assertive pair for the include semantics: diagnostics inside include -/// roots still fire, and files outside them are not checked at all. -#[test] -fn no_args_checks_include_roots_only() { - let dir = unique_dir("roots"); - write_include_project(&dir, "def broken() -> int:\n return \"nope\"\n"); - // Add a real error inside an include root. - std::fs::write( - dir.join("src/bad.py"), - "def bad() -> int:\n return \"oops\"\n", - ) - .expect("write bad"); - - let output = check_no_args(&dir); - let stdout = String::from_utf8_lossy(&output.stdout); - - assert!( - stdout.contains("bad.py"), - "errors inside include roots must still be reported, got: {stdout}" - ); - assert!( - !stdout.contains("deep.py"), - "files outside include roots must not be checked, got: {stdout}" - ); -} - -/// Lay down a project whose `include` is `src/` only, with a fixable -/// `BSK-0050` violation inside the include root and an identical one inside a -/// vendored virtualenv that the config's `exclude` does not name. -fn write_fix_include_project(dir: &Path) { - std::fs::write( - dir.join("pyproject.toml"), - "[project]\nname = \"x\"\nversion = \"0.1.0\"\n\n[tool.basilisk]\ninclude = [\"src/\"]\nexclude = [\"**/migrations/**\"]\n\n[tool.basilisk.rules]\n\"BSK-0050\" = \"warning\"\n", - ) - .expect("write pyproject"); - let vendored = dir.join("venv/lib/python3.13/site-packages/dep"); - std::fs::create_dir_all(dir.join("src")).expect("mkdir src"); - std::fs::create_dir_all(&vendored).expect("mkdir vendored"); - std::fs::write(dir.join("venv/pyvenv.cfg"), "home = /usr\n").expect("write pyvenv.cfg"); - std::fs::write(dir.join("src/main.py"), "x: int = 42\n").expect("write src"); - std::fs::write(vendored.join("mod.py"), "y: int = 42\n").expect("write vendored"); -} - -/// Issue #333: `basilisk fix` defaulted `PATHS` to `.` instead of falling back -/// to the configured `include` roots like `check`/`analyze`, so a no-args run -/// walked — and **rewrote** — third-party sources inside `venv/`. -#[test] -fn fix_no_args_honors_include_and_never_rewrites_vendored_files() { - let dir = unique_dir("fix_roots"); - write_fix_include_project(&dir); - let vendored = dir.join("venv/lib/python3.13/site-packages/dep/mod.py"); - - let output = Command::new(env!("CARGO_BIN_EXE_basilisk")) - .arg("fix") - .args(["--rules", "BSK-0050"]) - .current_dir(&dir) - .env_remove("VIRTUAL_ENV") - .output() - .expect("spawn basilisk"); - - assert_eq!( - std::fs::read_to_string(&vendored).expect("read vendored"), - "y: int = 42\n", - "a no-args fix must never mutate files outside the include roots, stderr: {}", - String::from_utf8_lossy(&output.stderr) - ); - assert_eq!( - std::fs::read_to_string(dir.join("src/main.py")).expect("read src"), - "x = 42\n", - "a no-args fix must still fix files inside the include roots, stderr: {}", - String::from_utf8_lossy(&output.stderr) - ); -} - -/// Explicit CLI paths override the configured include roots. -#[test] -fn explicit_paths_override_include() { - let dir = unique_dir("explicit"); - write_include_project(&dir, "def broken() -> int:\n return \"nope\"\n"); - - let output = Command::new(env!("CARGO_BIN_EXE_basilisk")) - .arg("check") - .arg("gen") - .current_dir(&dir) - .env_remove("VIRTUAL_ENV") - .output() - .expect("spawn basilisk"); - let stdout = String::from_utf8_lossy(&output.stdout); - - assert!( - stdout.contains("deep.py"), - "explicit paths must win over include, got: {stdout}" - ); -} diff --git a/crates/basilisk-cli/tests/e2e_lsp_no_ruff.rs b/crates/basilisk-cli/tests/e2e_lsp_no_ruff.rs deleted file mode 100644 index 50c565926..000000000 --- a/crates/basilisk-cli/tests/e2e_lsp_no_ruff.rs +++ /dev/null @@ -1,235 +0,0 @@ -//! Tests for [LSPFMT-ENGINE] / [LSPFMT-IMPORTS]. See docs/specs/LSP-FORMATTING-SPEC.md#LSPFMT -#![allow( - clippy::allow_attributes, - clippy::indexing_slicing, - clippy::expect_used, - clippy::unwrap_used, - clippy::panic -)] -//! Formatting and import hygiene must be self-contained in the `basilisk` -//! binary ([LSPFMT-DECISION]): no external `ruff` executable is ever spawned. -//! -//! Every test here launches the real compiled binary as an LSP server over -//! stdio with a `PATH` pointing at an empty directory, so **no `ruff` (or any -//! other tool) is findable**. Before the fix for #254/#261 the server shelled -//! out to `ruff` and silently no-opped in exactly this environment; these -//! tests pin the in-process behavior. - -mod lsp_stdio; - -use serde_json::{json, Value}; - -use lsp_stdio::{unique_temp_dir, LspProcess}; - -/// Find the first code action whose title contains `needle` and return the -/// full new text of its single whole-document edit for `uri`. -fn action_new_text(actions: &Value, needle: &str, uri: &str) -> Option { - let action = actions - .as_array()? - .iter() - .find(|a| a["title"].as_str().is_some_and(|t| t.contains(needle)))?; - let edits = &action["edit"]["changes"][uri]; - Some(edits.as_array()?.first()?["newText"].as_str()?.to_owned()) -} - -// ── #254: formatting must work with no ruff binary anywhere ───────────────── - -#[test] -fn formatting_works_with_no_ruff_binary_on_path() { - let mut lsp = LspProcess::start(); - let uri = "file:///no_ruff_fmt.py"; - lsp.did_open(uri, "x=1\ny = 'two'\n"); - - let result = lsp.request( - "textDocument/formatting", - &json!({ - "textDocument": { "uri": uri }, - "options": { "tabSize": 4, "insertSpaces": true } - }), - ); - - let edits = result - .as_array() - .unwrap_or_else(|| panic!("formatting silently no-opped without ruff on PATH: {result}")); - let new_text = edits[0]["newText"].as_str().expect("newText string"); - assert_eq!( - new_text, "x = 1\ny = \"two\"\n", - "embedded formatter must produce ruff-format output" - ); -} - -// ── #261: import cleanup must work with no ruff binary anywhere ───────────── - -#[test] -fn organize_imports_works_with_no_ruff_binary_on_path() { - let mut lsp = LspProcess::start(); - let uri = "file:///no_ruff_org.py"; - // Unsorted: stdlib out of order, __future__ not first, third-party mixed in. - lsp.did_open( - uri, - "import sys\nimport requests\nimport os\nfrom __future__ import annotations\n\nprint(os, sys, requests, annotations)\n", - ); - - let actions = lsp.code_actions(uri); - let new_text = action_new_text(&actions, "Organize imports", uri).unwrap_or_else(|| { - panic!("organize-imports action missing without ruff on PATH: {actions}") - }); - - // isort semantics: __future__ first, stdlib section, then third-party, - // one blank line between sections. [LSPFMT-IMPORTS] - assert_eq!( - new_text, - "from __future__ import annotations\n\nimport os\nimport sys\n\nimport requests\n\nprint(os, sys, requests, annotations)\n", - "organize imports must sort with isort semantics" - ); -} - -#[test] -fn split_multi_import_works_with_no_ruff_binary_on_path() { - let mut lsp = LspProcess::start(); - let uri = "file:///no_ruff_split.py"; - lsp.did_open(uri, "import sys, os\n\nprint(os, sys)\n"); - - let actions = lsp.code_actions(uri); - let new_text = action_new_text(&actions, "multiple imports", uri).unwrap_or_else(|| { - panic!("split-multi-import action missing without ruff on PATH: {actions}") - }); - - // Ruff E401 parity: one statement per module, original order kept. - assert_eq!( - new_text, "import sys\nimport os\n\nprint(os, sys)\n", - "split must produce one import statement per module" - ); -} - -#[test] -fn range_formatting_works_with_no_ruff_binary_on_path() { - let mut lsp = LspProcess::start(); - let uri = "file:///no_ruff_range.py"; - // Only line 0 is selected; line 2's bad spacing must stay untouched. - lsp.did_open(uri, "x=1\n\ny = 2\n"); - - let result = lsp.request( - "textDocument/rangeFormatting", - &json!({ - "textDocument": { "uri": uri }, - "range": { - "start": { "line": 0, "character": 0 }, - "end": { "line": 0, "character": 3 } - }, - "options": { "tabSize": 4, "insertSpaces": true } - }), - ); - - let edits = result.as_array().unwrap_or_else(|| { - panic!("range formatting silently no-opped without ruff on PATH: {result}") - }); - let new_text = edits[0]["newText"].as_str().expect("newText string"); - assert_eq!(new_text, "x = 1", "selection must be ruff-formatted"); - assert_eq!( - edits[0]["range"]["end"]["line"].as_i64(), - Some(0), - "the edit must not reach past the selected line: {edits:?}" - ); -} - -#[test] -fn formatting_respects_tool_ruff_format_options() { - // A workspace whose pyproject.toml opts into single quotes. - let root = unique_temp_dir("bsk_no_ruff_ws"); - std::fs::create_dir_all(&root).expect("create workspace root"); - std::fs::write( - root.join("pyproject.toml"), - "[tool.ruff]\nline-length = 100\n\n[tool.ruff.format]\nquote-style = \"single\"\n", - ) - .expect("write pyproject.toml"); - - let mut lsp = LspProcess::start_with(Some(&root), &json!(null)); - let uri = "file:///no_ruff_opts.py"; - lsp.did_open(uri, "x = \"double\"\n"); - - let result = lsp.request( - "textDocument/formatting", - &json!({ - "textDocument": { "uri": uri }, - "options": { "tabSize": 4, "insertSpaces": true } - }), - ); - - let edits = result - .as_array() - .unwrap_or_else(|| panic!("[tool.ruff.format] quote-style was ignored: {result}")); - assert_eq!( - edits[0]["newText"].as_str(), - Some("x = 'double'\n"), - "quote-style = single must produce single quotes" - ); -} - -#[test] -fn formatter_none_setting_disables_formatting_capabilities() { - // [LSPFMT-CONFIG]: `basilisk.formatter = "none"` — the server must not - // advertise formatting, and must answer null if asked anyway. - let mut lsp = LspProcess::start_with(None, &json!({ "formatter": "none" })); - assert_eq!( - lsp.last_capabilities.get("documentFormattingProvider"), - None, - "formatter=none must not advertise documentFormattingProvider: {}", - lsp.last_capabilities - ); - assert_eq!( - lsp.last_capabilities.get("documentRangeFormattingProvider"), - None, - "formatter=none must not advertise documentRangeFormattingProvider" - ); - - let uri = "file:///no_ruff_none.py"; - lsp.did_open(uri, "x=1\n"); - let result = lsp.request( - "textDocument/formatting", - &json!({ - "textDocument": { "uri": uri }, - "options": { "tabSize": 4, "insertSpaces": true } - }), - ); - assert!(result.is_null(), "formatter=none must not format: {result}"); -} - -#[test] -fn formatting_capabilities_advertised_by_default() { - // [LSPFMT-CAPABILITIES]: whole-document AND range formatting by default. - let lsp = LspProcess::start(); - assert_eq!( - lsp.last_capabilities.get("documentFormattingProvider"), - Some(&json!(true)), - "documentFormattingProvider must be advertised: {}", - lsp.last_capabilities - ); - assert_eq!( - lsp.last_capabilities.get("documentRangeFormattingProvider"), - Some(&json!(true)), - "documentRangeFormattingProvider must be advertised (Format Selection)" - ); -} - -#[test] -fn expand_wildcard_import_works_with_no_ruff_binary_on_path() { - let mut lsp = LspProcess::start(); - let uri = "file:///no_ruff_wild.py"; - // `join` and `basename` are used but bound nowhere except the wildcard. - lsp.did_open( - uri, - "from os.path import *\n\nprint(join(\"a\", basename(\"b\")))\n", - ); - - let actions = lsp.code_actions(uri); - let new_text = action_new_text(&actions, "Expand wildcard", uri).unwrap_or_else(|| { - panic!("expand-wildcard action missing without ruff on PATH: {actions}") - }); - - // The wildcard is replaced by the names the file actually uses from it. - assert_eq!( - new_text, "from os.path import basename, join\n\nprint(join(\"a\", basename(\"b\")))\n", - "wildcard must expand to the used names, sorted" - ); -} diff --git a/crates/basilisk-cli/tests/e2e_no_python_spawn.rs b/crates/basilisk-cli/tests/e2e_no_python_spawn.rs deleted file mode 100644 index 4db7c50c6..000000000 --- a/crates/basilisk-cli/tests/e2e_no_python_spawn.rs +++ /dev/null @@ -1,295 +0,0 @@ -//! Tests for [STUBRES-TYPESHED-VERSION]. -//! See docs/specs/CHECKER-STUB-RESOLUTION-SPEC.md#STUBRES-TYPESHED-VERSION -//! -//! Platform target evidence justifies an interpreter launch ONLY for an -//! EXPLICITLY selected interpreter (`python-interpreter` config or -//! `BASILISK_PYTHON`), which can deliberately point at a shim reporting a -//! non-host target. Auto-discovered interpreters — a workspace venv, a bare -//! `python3` on `PATH` — execute on this host by definition, so their -//! `sys.platform` answer is always the host constant. Launching one anyway -//! costs a full interpreter start-up on EVERY `check`, so auto-discovery must -//! resolve the platform without spawning anything. -//! -//! Covers `load_cli_workspace_config` in -//! `crates/basilisk-cli/src/pipeline/typeshed.rs`. -#![cfg(unix)] -#![allow( - clippy::allow_attributes, - clippy::expect_used, - clippy::unwrap_used, - clippy::panic -)] - -use std::path::{Path, PathBuf}; -use std::process::Command; -use std::sync::atomic::{AtomicU64, Ordering}; - -fn unique_dir(prefix: &str) -> PathBuf { - static CTR: AtomicU64 = AtomicU64::new(0); - let n = CTR.fetch_add(1, Ordering::Relaxed); - let dir = std::env::temp_dir().join(format!( - "bsk_no_python_spawn_{prefix}_{}_{n}", - std::process::id() - )); - std::fs::create_dir_all(&dir).expect("create temp dir"); - dir -} - -/// Write an executable interpreter shim named `name` that records every -/// invocation in `sentinel` and otherwise answers exactly like a real -/// interpreter probe. -/// -/// The shim ANSWERS correctly on purpose: the test must fail because the -/// interpreter was launched, never because the launch produced a bad value. -fn install_interpreter_shim(bin_dir: &Path, name: &str, sentinel: &Path) { - use std::os::unix::fs::PermissionsExt as _; - - std::fs::create_dir_all(bin_dir).expect("create shim dir"); - let shim = bin_dir.join(name); - std::fs::write( - &shim, - format!( - "#!/bin/sh\necho invoked >> '{}'\necho darwin\n", - sentinel.display() - ), - ) - .expect("write interpreter shim"); - std::fs::set_permissions(&shim, std::fs::Permissions::from_mode(0o755)) - .expect("make shim executable"); -} - -fn install_python_shim(bin_dir: &Path, sentinel: &Path) { - install_interpreter_shim(bin_dir, "python3", sentinel); -} - -/// `PATH` with `bin_dir` prepended, so shims win over real interpreters. -fn path_with(bin_dir: &Path) -> String { - format!( - "{}:{}", - bin_dir.display(), - std::env::var("PATH").unwrap_or_default() - ) -} - -/// A project that selects no interpreter must be checked without launching -/// one. [STUBRES-TYPESHED-VERSION] -#[test] -fn check_without_a_selected_interpreter_never_spawns_python() { - let dir = unique_dir("unselected"); - let bin_dir = dir.join("fakebin"); - let sentinel = dir.join("python-was-spawned"); - install_python_shim(&bin_dir, &sentinel); - - std::fs::write(dir.join("app.py"), "x: int = 1\n").expect("write app.py"); - std::fs::write( - dir.join("pyproject.toml"), - "[project]\nname = \"x\"\nversion = \"0.1.0\"\n\n[tool.basilisk]\n", - ) - .expect("write pyproject.toml"); - - let output = Command::new(env!("CARGO_BIN_EXE_basilisk")) - .arg("check") - .arg("app.py") - .current_dir(&dir) - .env("PATH", path_with(&bin_dir)) - .env_remove("BASILISK_PYTHON") - .env_remove("VIRTUAL_ENV") - .output() - .expect("spawn basilisk"); - - assert!( - output.status.success(), - "check must succeed; stderr: {}", - String::from_utf8_lossy(&output.stderr) - ); - assert!( - !sentinel.exists(), - "basilisk spawned a Python interpreter for a project that selects none — \ - the platform is knowable from the host without paying an interpreter start-up \ - on every check (sentinel recorded: {})", - std::fs::read_to_string(&sentinel) - .unwrap_or_default() - .trim() - ); -} - -/// An auto-discovered workspace venv is a host binary: its `sys.platform` -/// answer is always the host constant, so discovering one must not trigger an -/// interpreter launch either. [STUBRES-TYPESHED-VERSION] -#[test] -fn check_with_only_a_workspace_venv_never_spawns_python() { - let dir = unique_dir("venv"); - let venv_bin = dir.join(".venv").join("bin"); - let sentinel = dir.join("python-was-spawned"); - install_python_shim(&venv_bin, &sentinel); - // `resolve_python` discovers `.venv/bin/python`; alias the shim to it. - let _bytes = std::fs::copy(venv_bin.join("python3"), venv_bin.join("python")) - .expect("alias venv python"); - - std::fs::write(dir.join("app.py"), "x: int = 1\n").expect("write app.py"); - std::fs::write( - dir.join("pyproject.toml"), - "[project]\nname = \"x\"\nversion = \"0.1.0\"\n\n[tool.basilisk]\n", - ) - .expect("write pyproject.toml"); - - let output = Command::new(env!("CARGO_BIN_EXE_basilisk")) - .arg("check") - .arg("app.py") - .current_dir(&dir) - .env_remove("BASILISK_PYTHON") - .env_remove("VIRTUAL_ENV") - .output() - .expect("spawn basilisk"); - - assert!( - output.status.success(), - "check must succeed; stderr: {}", - String::from_utf8_lossy(&output.stderr) - ); - assert!( - !sentinel.exists(), - "basilisk launched the auto-discovered venv interpreter — a venv binary runs \ - on this host, so its sys.platform is the host constant and the launch buys \ - nothing (sentinel recorded: {})", - std::fs::read_to_string(&sentinel) - .unwrap_or_default() - .trim() - ); -} - -/// The saving must not cost accuracy: an EXPLICITLY selected interpreter is -/// still interrogated, because its platform can differ from the host. -/// [STUBRES-TYPESHED-VERSION] -#[test] -fn check_with_an_explicitly_selected_interpreter_still_probes_it() { - let dir = unique_dir("selected"); - let bin_dir = dir.join("fakebin"); - let sentinel = dir.join("python-was-spawned"); - install_python_shim(&bin_dir, &sentinel); - - std::fs::write(dir.join("app.py"), "x: int = 1\n").expect("write app.py"); - std::fs::write( - dir.join("pyproject.toml"), - "[project]\nname = \"x\"\nversion = \"0.1.0\"\n\n[tool.basilisk]\n", - ) - .expect("write pyproject.toml"); - - let output = Command::new(env!("CARGO_BIN_EXE_basilisk")) - .arg("check") - .arg("app.py") - .current_dir(&dir) - .env("BASILISK_PYTHON", bin_dir.join("python3")) - .env_remove("VIRTUAL_ENV") - .output() - .expect("spawn basilisk"); - - assert!( - output.status.success(), - "check must succeed; stderr: {}", - String::from_utf8_lossy(&output.stderr) - ); - assert!( - sentinel.exists(), - "an explicitly selected interpreter must still be interrogated for its platform" - ); -} - -/// Version evidence with no venv resolves third-party imports against the -/// named `python3.X` interpreter's `site-packages`. For a conventional layout -/// (`/bin/python3.X` + `/lib/python3.X/site-packages`) that -/// directory is encoded in the filesystem — recovering it must not launch the -/// interpreter. [ANALYSIS-CROSSLSP-IMPORT] -#[test] -fn check_resolves_conventional_site_packages_without_spawning_the_versioned_interpreter() { - let dir = unique_dir("conventional_prefix"); - let prefix = dir.join("prefix"); - let sentinel = dir.join("python-was-spawned"); - install_interpreter_shim(&prefix.join("bin"), "python3.12", &sentinel); - let package = prefix - .join("lib") - .join("python3.12") - .join("site-packages") - .join("mypkg"); - std::fs::create_dir_all(&package).expect("create site-packages package"); - std::fs::write(package.join("__init__.py"), "value: int = 1\n").expect("write package"); - std::fs::write(package.join("py.typed"), "").expect("write py.typed"); - - std::fs::write( - dir.join("app.py"), - "import mypkg\n\nnumber: int = mypkg.value\n", - ) - .expect("write app.py"); - std::fs::write( - dir.join("pyproject.toml"), - "[project]\nname = \"x\"\nversion = \"0.1.0\"\nrequires-python = \">=3.12\"\n", - ) - .expect("write pyproject.toml"); - - let output = Command::new(env!("CARGO_BIN_EXE_basilisk")) - .arg("check") - .arg("app.py") - .current_dir(&dir) - .env("PATH", path_with(&prefix.join("bin"))) - .env_remove("BASILISK_PYTHON") - .env_remove("VIRTUAL_ENV") - .env_remove("PYTHONPATH") - .output() - .expect("spawn basilisk"); - - assert!( - !sentinel.exists(), - "basilisk launched `python3.12` to find site-packages that a conventional \ - `/lib/python3.12/site-packages` layout already encodes (sentinel \ - recorded: {})", - std::fs::read_to_string(&sentinel) - .unwrap_or_default() - .trim() - ); - assert!( - output.status.success(), - "the direct layout inspection must actually resolve `mypkg`; stdout: {} stderr: {}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); -} - -/// The saving must not cost coverage of custom layouts: an interpreter whose -/// installation does NOT follow the `bin/` + `lib/` convention is still probed -/// for its real `sys.path`. [ANALYSIS-CROSSLSP-IMPORT] -#[test] -fn check_with_a_custom_interpreter_layout_still_probes_sys_path() { - let dir = unique_dir("custom_prefix"); - let flat = dir.join("flat"); - let sentinel = dir.join("python-was-spawned"); - install_interpreter_shim(&flat, "python3.12", &sentinel); - - std::fs::write(dir.join("app.py"), "import mypkg\n").expect("write app.py"); - std::fs::write( - dir.join("pyproject.toml"), - "[project]\nname = \"x\"\nversion = \"0.1.0\"\nrequires-python = \">=3.12\"\n", - ) - .expect("write pyproject.toml"); - - let output = Command::new(env!("CARGO_BIN_EXE_basilisk")) - .arg("check") - .arg("app.py") - .current_dir(&dir) - .env("PATH", path_with(&flat)) - .env_remove("BASILISK_PYTHON") - .env_remove("VIRTUAL_ENV") - .env_remove("PYTHONPATH") - .output() - .expect("spawn basilisk"); - - assert!( - output.status.code().is_some(), - "check must run to completion; stderr: {}", - String::from_utf8_lossy(&output.stderr) - ); - assert!( - sentinel.exists(), - "a custom-layout interpreter has no conventional site-packages to inspect — \ - the sys.path probe remains the authoritative fallback" - ); -} diff --git a/crates/basilisk-cli/tests/e2e_pep561_stub_packages.rs b/crates/basilisk-cli/tests/e2e_pep561_stub_packages.rs deleted file mode 100644 index e411e1c9f..000000000 --- a/crates/basilisk-cli/tests/e2e_pep561_stub_packages.rs +++ /dev/null @@ -1,293 +0,0 @@ -//! End-to-end tests for PEP 561 stub-distribution semantics through the real -//! `basilisk check` binary ([STUBRES-PEP561], [STUBRES-PEP561-NORMATIVE], -//! [STUBRES-PEP561-MAPPING], [STUBRES-RESOLUTION-FLOW]). -//! See docs/specs/CHECKER-STUB-RESOLUTION-SPEC.md#STUBRES-PEP561-NORMATIVE -//! -//! The contract under test, exactly as the pinned typing specification's -//! "Distributing type information" chapter states it and exactly as a user -//! experiences it out of the box: -//! -//! - step 4: an installed `foopkg-stubs` distribution supersedes the inline -//! `foopkg` install, and the `*-stubs` name alone is a source of typing -//! information (no `py.typed` needed); -//! - a module miss in a COMPLETE stub distribution is terminal — resolution -//! never falls through to the inline package; -//! - a stub distribution is partial only when its `py.typed` holds the exact -//! line `partial` — any other content leaves it complete; -//! - a stub-only namespace package (no `__init__.pyi`) continues to step 5; -//! - step 5: an installed package resolves whether or not it ships `py.typed`. -//! -//! Resolution logic under test: -//! `crates/basilisk-checker/src/imports/resolve.rs` -//! (`try_resolve_stub_package`, `stub_package_miss_allows_fallback`, -//! `has_partial_marker`). The fixture venv (`.venv/lib/python3.12/ -//! site-packages`) is discovered by `resolve_site_packages` in -//! `crates/basilisk-lsp/src/import_resolver.rs`; `VIRTUAL_ENV` is removed so -//! only the fixture's own venv is consulted. -#![allow( - clippy::allow_attributes, - clippy::expect_used, - clippy::unwrap_used, - clippy::panic -)] - -use std::path::{Path, PathBuf}; -use std::process::{Command, Output}; -use std::sync::atomic::{AtomicU64, Ordering}; - -fn unique_dir(prefix: &str) -> PathBuf { - static CTR: AtomicU64 = AtomicU64::new(0); - let counter = CTR.fetch_add(1, Ordering::Relaxed); - let dir = std::env::temp_dir().join(format!( - "bsk_pep561_stub_pkgs_{prefix}_{}_{counter}", - std::process::id() - )); - std::fs::create_dir_all(&dir).expect("create temp dir"); - dir -} - -/// Create the project skeleton — `pyproject.toml` plus a discoverable venv — -/// and return the venv's `site-packages` directory, the stage on which every -/// PEP 561 step-4/step-5 fixture is built. -fn write_project_with_site_packages(project_dir: &Path) -> PathBuf { - std::fs::write( - project_dir.join("pyproject.toml"), - "[project]\nname = \"x\"\nversion = \"0.1.0\"\n", - ) - .expect("write pyproject"); - let site_packages = project_dir - .join(".venv") - .join("lib") - .join("python3.12") - .join("site-packages"); - std::fs::create_dir_all(&site_packages).expect("create site-packages"); - site_packages -} - -/// Write `app.py` with the given source and run `basilisk check app.py`. -fn check_app(project_dir: &Path, app_source: &str) -> Output { - std::fs::write(project_dir.join("app.py"), app_source).expect("write app"); - Command::new(env!("CARGO_BIN_EXE_basilisk")) - .arg("check") - .arg("app.py") - .current_dir(project_dir) - .env_remove("VIRTUAL_ENV") - .output() - .expect("spawn basilisk") -} - -/// Write the shared step-4-vs-step-5 fixture: a `foo-stubs` distribution that -/// LACKS submodule `bar`, alongside an inline `foo` package that HAS -/// `bar.py` (and opts in via `py.typed`). Whether `import foo.bar` resolves -/// is then decided purely by the stub distribution's completeness: -/// -/// - `stubs_marker`: content for `foo-stubs/py.typed`, or `None` for no marker -/// - `stubs_have_init`: whether `foo-stubs/__init__.pyi` exists (a regular -/// package) or not (a stub-only namespace package) -fn write_stubs_missing_submodule_fixture( - site_packages: &Path, - stubs_marker: Option<&str>, - stubs_have_init: bool, -) { - let stubs_dir = site_packages.join("foo-stubs"); - std::fs::create_dir_all(&stubs_dir).expect("create foo-stubs"); - if stubs_have_init { - std::fs::write(stubs_dir.join("__init__.pyi"), "value: int\n").expect("write stubs init"); - } else { - std::fs::write(stubs_dir.join("other.pyi"), "flag: bool\n") - .expect("write namespace stub member"); - } - if let Some(marker_contents) = stubs_marker { - std::fs::write(stubs_dir.join("py.typed"), marker_contents).expect("write stubs marker"); - } - - let inline_dir = site_packages.join("foo"); - std::fs::create_dir_all(&inline_dir).expect("create inline foo"); - std::fs::write(inline_dir.join("__init__.py"), "").expect("write inline init"); - std::fs::write(inline_dir.join("bar.py"), "flag: bool = True\n").expect("write inline bar"); - std::fs::write(inline_dir.join("py.typed"), "").expect("write inline marker"); -} - -/// Assert the import resolved: no `imports_unresolved` diagnostic and exit 0. -fn assert_import_resolved(output: &Output, fixture_description: &str) { - let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - !stdout.contains("imports_unresolved"), - "{fixture_description} must resolve the import, stdout: {stdout}, \ - stderr: {stderr}" - ); - assert_eq!( - output.status.code(), - Some(0), - "{fixture_description} must let the CLI check pass, stdout: {stdout}, \ - stderr: {stderr}" - ); -} - -/// Assert the import missed: an `imports_unresolved` diagnostic naming the -/// module, and exit 1. -fn assert_import_unresolved(output: &Output, module_name: &str, fixture_description: &str) { - let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - stdout.contains("imports_unresolved"), - "{fixture_description} must report `imports_unresolved`, stdout: \ - {stdout}, stderr: {stderr}" - ); - assert!( - stdout.contains(module_name), - "the diagnostic must name the unresolved module `{module_name}`, \ - stdout: {stdout}" - ); - assert_eq!( - output.status.code(), - Some(1), - "{fixture_description} is a diagnostics failure, so the CLI must exit \ - 1, stdout: {stdout}, stderr: {stderr}" - ); -} - -/// A stub-only distribution is a source of typing information by its `-stubs` -/// name alone ([#STUBRES-PEP561-NORMATIVE]: "For stub-only packages adding a -/// `py.typed` marker is not needed"): `foo-stubs/__init__.pyi` with NO inline -/// `foo` package anywhere resolves `import foo` at step 4. -#[test] -fn stub_only_package_resolves_without_inline_package_or_marker() { - let project_dir = unique_dir("stub_only"); - let site_packages = write_project_with_site_packages(&project_dir); - let stubs_dir = site_packages.join("foo-stubs"); - std::fs::create_dir_all(&stubs_dir).expect("create foo-stubs"); - std::fs::write(stubs_dir.join("__init__.pyi"), "value: int\n").expect("write stubs init"); - - let output = check_app(&project_dir, "import foo\n\nresult = foo.value\n"); - assert_import_resolved( - &output, - "a stub-only `foo-stubs` distribution with no inline `foo` install", - ); - - let _ = std::fs::remove_dir_all(&project_dir); -} - -/// Negative control for every resolving fixture in this file: with an EMPTY -/// venv site-packages, `import foo` matches no resolution step, so it must -/// fail as `imports_unresolved` ([#STUBRES-PEP561-MAPPING]: a module matching -/// no step is unresolved). This proves the fixture venv — not some ambient -/// interpreter — is what the resolving tests exercise. -#[test] -fn empty_site_packages_leaves_import_unresolved() { - let project_dir = unique_dir("empty_venv"); - let _site_packages = write_project_with_site_packages(&project_dir); - - let output = check_app(&project_dir, "import foo\n"); - assert_import_unresolved(&output, "foo", "an empty fixture site-packages"); - - let _ = std::fs::remove_dir_all(&project_dir); -} - -/// A COMPLETE stub distribution (regular package, no partial marker) is -/// terminal on a miss ([#STUBRES-PEP561-MAPPING]: "A complete step-4 package -/// stops on a miss"): `foo-stubs` lacks submodule `bar`, so `import foo.bar` -/// must be unresolved even though inline `foo/bar.py` exists with `py.typed`. -/// -/// This is simultaneously the step-4 priority proof ("Stub packages - these -/// packages SHOULD supersede any installed inline package"): the diagnostic -/// can only fire if the `-stubs` distribution was consulted BEFORE the inline -/// package — a merged or inline-first search would have found `foo/bar.py`. -#[test] -fn complete_stub_package_miss_is_terminal_despite_inline_submodule() { - let project_dir = unique_dir("complete_terminal"); - let site_packages = write_project_with_site_packages(&project_dir); - write_stubs_missing_submodule_fixture(&site_packages, None, true); - - let output = check_app(&project_dir, "import foo.bar\n"); - assert_import_unresolved( - &output, - "foo.bar", - "a submodule miss in a complete `foo-stubs` distribution", - ); - - let _ = std::fs::remove_dir_all(&project_dir); -} - -/// A stub distribution whose `py.typed` holds the exact line `partial` is -/// partial ([#STUBRES-PEP561-NORMATIVE]: "If a stub package distribution is -/// partial it MUST include `partial\n` in a `py.typed` file"): the same -/// submodule miss as the terminal test now continues to steps 5–6 and -/// resolves `import foo.bar` through inline `foo/bar.py`. -#[test] -fn partial_marker_continues_submodule_miss_to_inline_package() { - let project_dir = unique_dir("partial_continues"); - let site_packages = write_project_with_site_packages(&project_dir); - write_stubs_missing_submodule_fixture(&site_packages, Some("partial\n"), true); - - let output = check_app(&project_dir, "import foo.bar\n"); - assert_import_resolved( - &output, - "a submodule miss in a `partial\\n`-marked `foo-stubs` distribution", - ); - - let _ = std::fs::remove_dir_all(&project_dir); -} - -/// The partial marker is the exact line `partial` — nothing looser. A -/// `py.typed` containing `partially\n` does NOT mark the distribution -/// partial, so the same miss stays terminal exactly like the marker-free -/// complete distribution. -#[test] -fn inexact_partial_marker_word_leaves_distribution_complete() { - let project_dir = unique_dir("inexact_marker"); - let site_packages = write_project_with_site_packages(&project_dir); - write_stubs_missing_submodule_fixture(&site_packages, Some("partially\n"), true); - - let output = check_app(&project_dir, "import foo.bar\n"); - assert_import_unresolved( - &output, - "foo.bar", - "a `py.typed` reading `partially` (not the exact `partial` line)", - ); - - let _ = std::fs::remove_dir_all(&project_dir); -} - -/// A stub-only NAMESPACE package — identified by the absence of -/// `__init__.pyi` ([#STUBRES-PEP561-NORMATIVE]: "Typecheckers should identify -/// namespace packages by the absence of `__init__.pyi`") — is never terminal: -/// the miss continues to step 5 and `import foo.bar` resolves through the -/// inline package, with no partial marker required. -#[test] -fn namespace_stub_package_miss_continues_to_inline_package() { - let project_dir = unique_dir("namespace_continues"); - let site_packages = write_project_with_site_packages(&project_dir); - write_stubs_missing_submodule_fixture(&site_packages, None, false); - - let output = check_app(&project_dir, "import foo.bar\n"); - assert_import_resolved( - &output, - "a submodule miss in a stub-only namespace package (no __init__.pyi)", - ); - - let _ = std::fs::remove_dir_all(&project_dir); -} - -/// Step 5 resolves an installed package with NO `py.typed` and NO stub -/// distribution: `py.typed` controls downstream provenance classification, -/// not existence ([#STUBRES-RESOLUTION-FLOW]: an untyped `.py` hit is the -/// terminal `UntypedImport` resolution, not `imports_unresolved`), and the -/// out-of-the-box configuration emits no diagnostic for using it. -#[test] -fn untyped_installed_package_resolves_without_diagnostics() { - let project_dir = unique_dir("untyped_inline"); - let site_packages = write_project_with_site_packages(&project_dir); - let inline_dir = site_packages.join("foo"); - std::fs::create_dir_all(&inline_dir).expect("create inline foo"); - std::fs::write(inline_dir.join("__init__.py"), "value: int = 1\n").expect("write inline init"); - - let output = check_app(&project_dir, "import foo\n\nresult = foo.value\n"); - assert_import_resolved( - &output, - "an installed untyped package (no py.typed, no stub distribution)", - ); - - let _ = std::fs::remove_dir_all(&project_dir); -} diff --git a/crates/basilisk-cli/tests/e2e_pyright_compat_config.rs b/crates/basilisk-cli/tests/e2e_pyright_compat_config.rs deleted file mode 100644 index 07adf4a16..000000000 --- a/crates/basilisk-cli/tests/e2e_pyright_compat_config.rs +++ /dev/null @@ -1,206 +0,0 @@ -//! Tests pyright-compatibility config spellings for `basilisk check`. -//! -//! Config-file priority and spellings are specified in -//! docs/specs/LSP-ANALYSIS-MODES-SPEC.md#ANALYSIS-CONFIG-PRI: -//! `pyrightconfig.json` first, then `pyproject.toml` `[tool.basilisk]` or, -//! failing that, `[tool.pyright]` — first-file-wins, no per-field merging. -//! `extra-paths` entries feed manual-path import resolution step 1 of -//! docs/specs/CHECKER-STUB-RESOLUTION-SPEC.md#STUBRES-PEP561-MAPPING. -#![allow( - clippy::allow_attributes, - clippy::expect_used, - clippy::unwrap_used, - clippy::panic -)] - -use std::path::{Path, PathBuf}; -use std::process::{Command, Output}; -use std::sync::atomic::{AtomicU64, Ordering}; - -fn unique_dir(prefix: &str) -> PathBuf { - static CTR: AtomicU64 = AtomicU64::new(0); - let n = CTR.fetch_add(1, Ordering::Relaxed); - let dir = std::env::temp_dir().join(format!( - "bsk_pyright_compat_{prefix}_{}_{n}", - std::process::id() - )); - std::fs::create_dir_all(&dir).expect("create temp dir"); - dir -} - -/// Write the shared fixture: a `vendor/` directory holding `vendored_mod.py` -/// plus an `app.py` that imports and uses it. Only an `extra-paths` entry -/// pointing at `vendor` can make the import resolve. -fn write_vendored_fixture(dir: &Path) { - std::fs::create_dir_all(dir.join("vendor")).expect("create vendor dir"); - std::fs::write( - dir.join("vendor").join("vendored_mod.py"), - "value: int = 1\n", - ) - .expect("write vendored module"); - std::fs::write( - dir.join("app.py"), - "import vendored_mod\n\ntotal: int = vendored_mod.value\n", - ) - .expect("write app"); -} - -fn check_app(dir: &Path) -> Output { - Command::new(env!("CARGO_BIN_EXE_basilisk")) - .arg("check") - .arg("app.py") - .current_dir(dir) - .env_remove("VIRTUAL_ENV") - .output() - .expect("spawn basilisk") -} - -/// Assert the vendored import resolved: exit 0 and no unresolved diagnostic. -fn assert_import_resolved(output: &Output, config_description: &str) { - let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - !stdout.contains("imports_unresolved"), - "{config_description} must make `import vendored_mod` resolve via the \ - vendor/ extra path, stdout: {stdout}, stderr: {stderr}" - ); - assert_eq!( - output.status.code(), - Some(0), - "{config_description} must let the CLI check pass, stdout: {stdout}, \ - stderr: {stderr}" - ); -} - -/// `pyrightconfig.json` with `extraPaths` is the highest-priority config file -/// ([#ANALYSIS-CONFIG-PRI] file tier, entry 1) and its entries feed manual -/// extra-path resolution ([#STUBRES-PEP561-MAPPING] step 1). -#[test] -fn pyrightconfig_extra_paths_resolve_vendored_import() { - let dir = unique_dir("pyrightconfig_json"); - write_vendored_fixture(&dir); - std::fs::write( - dir.join("pyrightconfig.json"), - "{ \"extraPaths\": [\"vendor\"] }\n", - ) - .expect("write pyrightconfig.json"); - - let output = check_app(&dir); - assert_import_resolved(&output, "pyrightconfig.json `extraPaths`"); - - let _ = std::fs::remove_dir_all(&dir); -} - -/// Negative control: with no config file at all, `vendor/` is not on any -/// search path, so the same import must fail with `imports_unresolved` -/// ([#STUBRES-PEP561-MAPPING]: a module matching no step is unresolved). -#[test] -fn missing_config_leaves_vendored_import_unresolved() { - let dir = unique_dir("no_config"); - write_vendored_fixture(&dir); - - let output = check_app(&dir); - let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); - - assert!( - stdout.contains("imports_unresolved"), - "without any config the vendored import must be reported unresolved, \ - stdout: {stdout}, stderr: {stderr}" - ); - assert!( - stdout.contains("vendored_mod"), - "the diagnostic must name the unresolved module, stdout: {stdout}" - ); - assert_eq!( - output.status.code(), - Some(1), - "an unresolved import is an error, so the CLI must exit 1, \ - stdout: {stdout}, stderr: {stderr}" - ); - - let _ = std::fs::remove_dir_all(&dir); -} - -/// With no `[tool.basilisk]` table, `pyproject.toml` `[tool.pyright]` is the -/// compatibility fallback ([#ANALYSIS-CONFIG-PRI] file tier, entry 2), and it -/// accepts pyright's camelCase `extraPaths` spelling. -#[test] -fn tool_pyright_extra_paths_fallback_resolves_import() { - let dir = unique_dir("tool_pyright"); - write_vendored_fixture(&dir); - std::fs::write( - dir.join("pyproject.toml"), - "[tool.pyright]\nextraPaths = [\"vendor\"]\n", - ) - .expect("write pyproject"); - - let output = check_app(&dir); - assert_import_resolved(&output, "the `[tool.pyright]` fallback table"); - - let _ = std::fs::remove_dir_all(&dir); -} - -/// `[tool.basilisk]` accepts pyright's camelCase `extraPaths` alias alongside -/// the native kebab-case spelling (`workspace_config_from_toml` in -/// crates/basilisk-lsp/src/config.rs). -#[test] -fn camel_case_extra_paths_in_tool_basilisk_resolve_import() { - let dir = unique_dir("basilisk_camel"); - write_vendored_fixture(&dir); - std::fs::write( - dir.join("pyproject.toml"), - "[tool.basilisk]\nextraPaths = [\"vendor\"]\n", - ) - .expect("write pyproject"); - - let output = check_app(&dir); - assert_import_resolved(&output, "`[tool.basilisk]` camelCase `extraPaths`"); - - let _ = std::fs::remove_dir_all(&dir); -} - -/// The native kebab-case spelling, `extra-paths`, in `[tool.basilisk]`. -#[test] -fn kebab_case_extra_paths_in_tool_basilisk_resolve_import() { - let dir = unique_dir("basilisk_kebab"); - write_vendored_fixture(&dir); - std::fs::write( - dir.join("pyproject.toml"), - "[tool.basilisk]\nextra-paths = [\"vendor\"]\n", - ) - .expect("write pyproject"); - - let output = check_app(&dir); - assert_import_resolved(&output, "`[tool.basilisk]` kebab-case `extra-paths`"); - - let _ = std::fs::remove_dir_all(&dir); -} - -/// Whole-file precedence, not per-field merging ([#ANALYSIS-CONFIG-PRI]): -/// when `pyrightconfig.json` exists it supplies the ENTIRE workspace config, -/// so its `extraPaths` win even though a `[tool.basilisk]` table is present -/// with other keys and no extra paths of its own. -#[test] -fn pyrightconfig_json_takes_priority_over_pyproject() { - let dir = unique_dir("priority"); - write_vendored_fixture(&dir); - std::fs::write( - dir.join("pyrightconfig.json"), - "{ \"extraPaths\": [\"vendor\"] }\n", - ) - .expect("write pyrightconfig.json"); - std::fs::write( - dir.join("pyproject.toml"), - "[tool.basilisk]\npython-version = \"3.12\"\n", - ) - .expect("write pyproject"); - - let output = check_app(&dir); - assert_import_resolved( - &output, - "pyrightconfig.json (priority over a `[tool.basilisk]` table lacking extra paths)", - ); - - let _ = std::fs::remove_dir_all(&dir); -} diff --git a/crates/basilisk-cli/tests/e2e_python_version_gating.rs b/crates/basilisk-cli/tests/e2e_python_version_gating.rs deleted file mode 100644 index c3ecde31b..000000000 --- a/crates/basilisk-cli/tests/e2e_python_version_gating.rs +++ /dev/null @@ -1,233 +0,0 @@ -//! E2E tests: the `python-version` config key gates stdlib module -//! availability against typeshed's `stdlib/VERSIONS` ranges. -//! -//! Covers [STUBRES-TYPESHED-VERSION] (docs/specs/CHECKER-STUB-RESOLUTION-SPEC.md -//! "Target Python version") and [CHKARCH-VERSION-TARGET] -//! (docs/specs/CHECKER-ARCHITECTURE-SPEC.md "Target Version and Platform"). -//! -//! Each test drives the real binary end to end: a temp project with a -//! `[tool.basilisk]` `python-version` in `pyproject.toml`, an `app.py` -//! importing a version-gated stdlib module, then asserts on stdout AND the -//! exit code. VERSIONS ranges exercised: `tomllib: 3.11-` (introduced), -//! `distutils: 3.0-3.11` (removed in 3.12), `wsgiref.types: 3.11-` -//! (version-gated submodule of an always-present package). -#![allow( - clippy::allow_attributes, - clippy::expect_used, - clippy::unwrap_used, - clippy::panic -)] - -use std::path::{Path, PathBuf}; -use std::process::{Command, Output}; -use std::sync::atomic::{AtomicU64, Ordering}; - -fn unique_dir(prefix: &str) -> PathBuf { - static CTR: AtomicU64 = AtomicU64::new(0); - let n = CTR.fetch_add(1, Ordering::Relaxed); - let dir = std::env::temp_dir().join(format!( - "bsk_python_version_gating_{prefix}_{}_{n}", - std::process::id() - )); - std::fs::create_dir_all(&dir).expect("create temp dir"); - dir -} - -/// Write a project whose `[tool.basilisk]` sets `version_key = "version"` -/// and whose `app.py` contains `source`. -fn write_project(dir: &Path, version_key: &str, version: &str, source: &str) { - std::fs::write( - dir.join("pyproject.toml"), - format!( - "[project]\nname = \"x\"\nversion = \"0.1.0\"\n\n[tool.basilisk]\n{version_key} = \"{version}\"\n" - ), - ) - .expect("write pyproject"); - std::fs::write(dir.join("app.py"), source).expect("write app"); -} - -fn check_app(dir: &Path) -> Output { - Command::new(env!("CARGO_BIN_EXE_basilisk")) - .arg("check") - .arg("app.py") - .current_dir(dir) - .env_remove("VIRTUAL_ENV") - .output() - .expect("spawn basilisk") -} - -/// Assert the check flagged `module` as unresolved and exited 1. -fn assert_import_flagged(output: &Output, module: &str, context: &str) { - let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - stdout.contains("imports_unresolved"), - "{context}: `{module}` must be reported as unresolved, stdout: {stdout}, stderr: {stderr}" - ); - assert!( - stdout.contains(module), - "{context}: the diagnostic must name `{module}`, stdout: {stdout}, stderr: {stderr}" - ); - assert_eq!( - output.status.code(), - Some(1), - "{context}: an unresolved import is an error, so the CLI must exit 1, stdout: {stdout}, stderr: {stderr}" - ); -} - -/// Assert the check resolved `module` cleanly and exited 0. -fn assert_import_resolved(output: &Output, module: &str, context: &str) { - let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - !stdout.contains("imports_unresolved"), - "{context}: `{module}` must resolve without diagnostics, stdout: {stdout}, stderr: {stderr}" - ); - assert!( - !stdout.contains(module), - "{context}: diagnostics must not name the resolved module `{module}`, stdout: {stdout}, stderr: {stderr}" - ); - assert_eq!( - output.status.code(), - Some(0), - "{context}: a resolved stdlib import must let the CLI check pass, stdout: {stdout}, stderr: {stderr}" - ); -} - -/// `tomllib` is `3.11-` in typeshed's `stdlib/VERSIONS`: targeting 3.9 the -/// module does not exist yet, so the import must be flagged as unresolved. -#[test] -fn python_version_before_module_introduction_flags_import() { - let dir = unique_dir("tomllib_39"); - write_project(&dir, "python-version", "3.9", "import tomllib\n"); - - let output = check_app(&dir); - assert_import_flagged( - &output, - "tomllib", - "python-version = \"3.9\" predates tomllib (3.11- per VERSIONS)", - ); - - let _ = std::fs::remove_dir_all(&dir); -} - -/// Targeting 3.12 the same `tomllib` import sits inside its `3.11-` VERSIONS -/// range, so the check must pass cleanly. -#[test] -fn python_version_at_or_after_introduction_resolves_import() { - let dir = unique_dir("tomllib_312"); - write_project(&dir, "python-version", "3.12", "import tomllib\n"); - - let output = check_app(&dir); - assert_import_resolved( - &output, - "tomllib", - "python-version = \"3.12\" is within tomllib's 3.11- range", - ); - - let _ = std::fs::remove_dir_all(&dir); -} - -/// `distutils` is `3.0-3.11` in VERSIONS (removed from the stdlib in 3.12): -/// the upper bound must gate too — flagged when targeting 3.12, resolved when -/// targeting 3.10. -#[test] -fn removed_module_gated_by_version() { - let flagged_dir = unique_dir("distutils_312"); - write_project(&flagged_dir, "python-version", "3.12", "import distutils\n"); - let flagged = check_app(&flagged_dir); - assert_import_flagged( - &flagged, - "distutils", - "python-version = \"3.12\" is past distutils' 3.0-3.11 range", - ); - let _ = std::fs::remove_dir_all(&flagged_dir); - - let resolved_dir = unique_dir("distutils_310"); - write_project( - &resolved_dir, - "python-version", - "3.10", - "import distutils\n", - ); - let resolved = check_app(&resolved_dir); - assert_import_resolved( - &resolved, - "distutils", - "python-version = \"3.10\" is within distutils' 3.0-3.11 range", - ); - let _ = std::fs::remove_dir_all(&resolved_dir); -} - -/// The camelCase `pythonVersion` alias (pyright spelling, accepted by -/// `workspace_config_from_toml` in crates/basilisk-lsp/src/config.rs) must -/// gate exactly like `python-version`: flag `tomllib` at 3.9, resolve it -/// at 3.12. -#[test] -fn camel_case_python_version_alias_gates_identically() { - let flagged_dir = unique_dir("camel_39"); - write_project(&flagged_dir, "pythonVersion", "3.9", "import tomllib\n"); - let flagged = check_app(&flagged_dir); - assert_import_flagged( - &flagged, - "tomllib", - "pythonVersion = \"3.9\" (camelCase alias) predates tomllib (3.11-)", - ); - let _ = std::fs::remove_dir_all(&flagged_dir); - - let resolved_dir = unique_dir("camel_312"); - write_project(&resolved_dir, "pythonVersion", "3.12", "import tomllib\n"); - let resolved = check_app(&resolved_dir); - assert_import_resolved( - &resolved, - "tomllib", - "pythonVersion = \"3.12\" (camelCase alias) is within tomllib's 3.11- range", - ); - let _ = std::fs::remove_dir_all(&resolved_dir); -} - -/// Submodule gating: `wsgiref.types` is `3.11-` in VERSIONS while its parent -/// package `wsgiref` is `3.0-`. Targeting 3.9 the parent must resolve but the -/// submodule import must be flagged; targeting 3.12 the submodule resolves. -#[test] -fn submodule_version_gating() { - let parent_dir = unique_dir("wsgiref_parent_39"); - write_project(&parent_dir, "python-version", "3.9", "import wsgiref\n"); - let parent = check_app(&parent_dir); - assert_import_resolved( - &parent, - "wsgiref", - "python-version = \"3.9\" is within the parent package's 3.0- range", - ); - let _ = std::fs::remove_dir_all(&parent_dir); - - let flagged_dir = unique_dir("wsgiref_types_39"); - write_project( - &flagged_dir, - "python-version", - "3.9", - "import wsgiref.types\n", - ); - let flagged = check_app(&flagged_dir); - assert_import_flagged( - &flagged, - "wsgiref.types", - "python-version = \"3.9\" predates the wsgiref.types submodule (3.11-)", - ); - let _ = std::fs::remove_dir_all(&flagged_dir); - - let resolved_dir = unique_dir("wsgiref_types_312"); - write_project( - &resolved_dir, - "python-version", - "3.12", - "import wsgiref.types\n", - ); - let resolved = check_app(&resolved_dir); - assert_import_resolved( - &resolved, - "wsgiref.types", - "python-version = \"3.12\" is within wsgiref.types' 3.11- range", - ); - let _ = std::fs::remove_dir_all(&resolved_dir); -} diff --git a/crates/basilisk-cli/tests/e2e_release_notes_block.rs b/crates/basilisk-cli/tests/e2e_release_notes_block.rs deleted file mode 100644 index 5e04aaa7c..000000000 --- a/crates/basilisk-cli/tests/e2e_release_notes_block.rs +++ /dev/null @@ -1,87 +0,0 @@ -//! Tests for [LSPFMT-RELEASE-NOTES]. See -//! docs/specs/LSP-FORMATTING-SPEC.md#LSPFMT-RELEASE-NOTES -#![allow( - clippy::allow_attributes, - clippy::expect_used, - clippy::indexing_slicing, - clippy::unwrap_used, - clippy::panic -)] -//! Drift test for the generated release-notes component block -//! (`scripts/gen_release_notes.py`): the block is generated from -//! `shipwright.json` and the real binary, so the notes can never claim -//! different formatter bytes from the build. This test runs the generator -//! against the freshly compiled binary and proves the block enumerates every -//! manifest component and reports exactly the binary's embedded Ruff version. - -use std::path::Path; -use std::process::Command; - -/// The `Ruff formatter: X` line straight from the binary — the ground truth -/// the generated block must match. -fn formatter_version_from_binary() -> String { - let out = Command::new(env!("CARGO_BIN_EXE_basilisk")) - .arg("--version") - .output() - .expect("run basilisk --version"); - let stdout = String::from_utf8_lossy(&out.stdout).into_owned(); - stdout - .lines() - .find_map(|line| line.strip_prefix("Ruff formatter: ")) - .unwrap_or_else(|| panic!("--version must report the embedded formatter: {stdout}")) - .to_owned() -} - -#[test] -fn generated_block_matches_the_binary_and_the_manifest() { - let repo_root = Path::new(env!("CARGO_MANIFEST_DIR")) - .join("../..") - .canonicalize() - .expect("repo root"); - - let out = Command::new("python3") - .arg(repo_root.join("scripts/gen_release_notes.py")) - .arg(env!("CARGO_BIN_EXE_basilisk")) - .arg("v9.9.9-test") - .arg(repo_root.join("shipwright.json")) - .output() - .expect("run gen_release_notes.py (python3 required, as for conformance)"); - assert!( - out.status.success(), - "generator must succeed: {}", - String::from_utf8_lossy(&out.stderr) - ); - let block = String::from_utf8_lossy(&out.stdout).into_owned(); - - // Every shipwright.json component is enumerated; versioned components - // carry the release version passed in. - let manifest: serde_json::Value = serde_json::from_str( - &std::fs::read_to_string(repo_root.join("shipwright.json")).expect("read manifest"), - ) - .expect("parse manifest"); - let components = manifest["components"].as_array().expect("components"); - assert!(!components.is_empty(), "manifest must declare components"); - for component in components { - let id = component["id"].as_str().expect("component id"); - assert!( - block.contains(&format!("| `{id}` |")), - "block must enumerate component `{id}`:\n{block}" - ); - if component["expectedVersion"].as_str() == Some("${PRODUCT_VERSION}") { - assert!( - block - .lines() - .any(|l| l.contains(&format!("| `{id}` |")) && l.contains("v9.9.9-test")), - "component `{id}` must carry the release version:\n{block}" - ); - } - } - - // The formatter line is the binary's own, byte for byte — the whole - // point of generating the block ([LSPFMT-RELEASE-NOTES]). - let expected = formatter_version_from_binary(); - assert!( - block.contains(&format!("Embedded Ruff formatter: `{expected}`")), - "block must report the binary's embedded Ruff version {expected}:\n{block}" - ); -} diff --git a/crates/basilisk-cli/tests/e2e_rules_a.rs b/crates/basilisk-cli/tests/e2e_rules_a.rs deleted file mode 100644 index dc4330982..000000000 --- a/crates/basilisk-cli/tests/e2e_rules_a.rs +++ /dev/null @@ -1,490 +0,0 @@ -//! Tests for [CHKARCH-CLI]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-CLI -#![allow( - clippy::allow_attributes, - clippy::indexing_slicing, - clippy::expect_used, - clippy::unwrap_used, - clippy::panic, - clippy::as_conversions -)] -//! E2E tests for error codes BSK-0001 through BSK-0005. -//! -//! Missing parameter type annotation -//! Missing return type annotation -//! Unannotated module-level variable -//! Unannotated *args/**kwargs -//! Unannotated class attribute - -mod common; - -use basilisk_test_utils::{assert_diagnostics, Expected}; -use common::{ - annotation_rules_config, annotation_rules_config_for_python, fixture, run_with_config, -}; - -// Every test in this file exercises the annotation house rules -// (`BSK-0001`..`BSK-0005`), which are OFF by default — the default config is -// pure PEP conformance. Route every fixture through a config that opts those -// rules in, so the suite asserts exactly what a user who enabled them sees. -// Basilisk has no modes; this is configuration, not a switch. -// See [CHKARCH-CONFIGURATION-ONLY]. -fn run(rel: &str) -> Result, Box> { - run_with_config(rel, &annotation_rules_config()) -} - -// --------------------------------------------------------------------------- -// Missing parameter type annotation -// --------------------------------------------------------------------------- - -/// ```python -/// def process(data) -> None: # `data` at col 13, line 1 -/// pass -/// ``` -#[test] -fn single_unannotated_param() -> Result<(), Box> { - let diags = run("errors/e0001_single_param.py")?; - let src = std::fs::read_to_string(fixture("errors/e0001_single_param.py"))?; - assert_diagnostics( - &src, - &diags, - &[Expected::error("BSK-0001", "`data`", 1, 13)], - ); - Ok(()) -} - -/// ```python -/// def compute(x, y, z) -> int: # x→col 13, y→col 16, z→col 19 -/// return 0 -/// ``` -#[test] -fn three_unannotated_params() -> Result<(), Box> { - let diags = run("errors/e0001_multi_param.py")?; - let src = std::fs::read_to_string(fixture("errors/e0001_multi_param.py"))?; - assert_diagnostics( - &src, - &diags, - &[ - Expected::error("BSK-0001", "`x`", 1, 13), - Expected::error("BSK-0001", "`y`", 1, 16), - Expected::error("BSK-0001", "`z`", 1, 19), - ], - ); - Ok(()) -} - -/// ```python -/// def log(*messages, level: str) -> None: # *messages unannotated → BSK-0004, col 10 -/// pass -/// ``` -#[test] -fn unannotated_vararg() -> Result<(), Box> { - let diags = run("errors/e0001_varargs.py")?; - let src = std::fs::read_to_string(fixture("errors/e0001_varargs.py"))?; - assert_diagnostics( - &src, - &diags, - &[Expected::error("BSK-0004", "`messages`", 1, 10)], - ); - Ok(()) -} - -/// ```python -/// def configure(**options) -> None: # **options unannotated → BSK-0004, col 17 -/// pass -/// ``` -#[test] -fn unannotated_kwarg() -> Result<(), Box> { - let diags = run("errors/e0001_kwargs.py")?; - let src = std::fs::read_to_string(fixture("errors/e0001_kwargs.py"))?; - assert_diagnostics( - &src, - &diags, - &[Expected::error("BSK-0004", "`options`", 1, 17)], - ); - Ok(()) -} - -/// ```python -/// def outer(x: int) -> int: -/// def inner(y) -> int: # `y` unannotated, line 2, col 15 -/// return x + y -/// -/// return inner(1) -/// ``` -#[test] -fn unannotated_param_in_nested_function() -> Result<(), Box> { - let diags = run("errors/e0001_nested.py")?; - let src = std::fs::read_to_string(fixture("errors/e0001_nested.py"))?; - assert_diagnostics(&src, &diags, &[Expected::error("BSK-0001", "`y`", 2, 15)]); - Ok(()) -} - -// --------------------------------------------------------------------------- -// Missing return type annotation -// --------------------------------------------------------------------------- - -/// ```python -/// def fetch(url: str): # `fetch` at col 5, line 1 -/// pass -/// ``` -#[test] -fn single_function_missing_return() -> Result<(), Box> { - let diags = run("errors/e0002_single_func.py")?; - let src = std::fs::read_to_string(fixture("errors/e0002_single_func.py"))?; - assert_diagnostics( - &src, - &diags, - &[Expected::error("BSK-0002", "`fetch`", 1, 5)], - ); - Ok(()) -} - -/// ```python -/// def fetch(url: str): # line 1, col 5 -/// pass -/// -/// -/// def compute(x: int, y: int): # line 5, col 5 -/// return x + y -/// -/// -/// def noop(): # line 9, col 5 -/// pass -/// ``` -#[test] -fn three_functions_all_missing_return() -> Result<(), Box> { - let diags = run("errors/e0002_multiple_funcs.py")?; - let src = std::fs::read_to_string(fixture("errors/e0002_multiple_funcs.py"))?; - assert_diagnostics( - &src, - &diags, - &[ - Expected::error("BSK-0002", "`fetch`", 1, 5), - Expected::error("BSK-0002", "`compute`", 5, 5), - Expected::error("BSK-0002", "`noop`", 9, 5), - ], - ); - Ok(()) -} - -// --------------------------------------------------------------------------- -// BSK-0001 + BSK-0002 — Mixed, class methods -// --------------------------------------------------------------------------- - -#[test] -fn and_e0002_class_methods() -> Result<(), Box> { - let diags = run("errors/e0001_and_e0002_class_methods.py")?; - let src = std::fs::read_to_string(fixture("errors/e0001_and_e0002_class_methods.py"))?; - assert_diagnostics( - &src, - &diags, - &[ - Expected::error("BSK-0002", "`connect`", 2, 9), - Expected::error("BSK-0001", "`host`", 2, 23), - Expected::error("BSK-0001", "`port`", 2, 29), - Expected::error("BSK-0002", "`send`", 8, 9), - ], - ); - Ok(()) -} - -// --------------------------------------------------------------------------- -// dunder methods without return annotations -// --------------------------------------------------------------------------- - -/// [TYPEINF-FUNC-RETURN]: BSK-0002 fires only where the return type is not -/// inferable. `__init__` (no return → `None`), `__repr__` (f-string → `str`), -/// and `__len__` (`return 2` → `int`) are all inferable and must stay silent; -/// only `__add__` (returns a constructor call) still needs an annotation. -#[test] -fn dunder_methods_only_uninferable_return_fires() -> Result<(), Box> { - let diags = run("errors/e0002_dunder_methods.py")?; - let src = std::fs::read_to_string(fixture("errors/e0002_dunder_methods.py"))?; - assert_diagnostics( - &src, - &diags, - &[Expected::error("BSK-0002", "`__add__`", 9, 9)], - ); - Ok(()) -} - -// --------------------------------------------------------------------------- -// mixed: some params annotated, some not -// --------------------------------------------------------------------------- - -#[test] -fn only_unannotated_params_flagged_in_mixed_signature() -> Result<(), Box> { - let diags = run("errors/e0001_mixed_annotated.py")?; - let src = std::fs::read_to_string(fixture("errors/e0001_mixed_annotated.py"))?; - assert_diagnostics( - &src, - &diags, - &[ - Expected::error("BSK-0001", "`destination`", 1, 27), - Expected::error("BSK-0001", "`currency`", 1, 55), - ], - ); - Ok(()) -} - -// --------------------------------------------------------------------------- -// positional-only parameters without annotations -// --------------------------------------------------------------------------- - -#[test] -fn positional_only_params_flagged() -> Result<(), Box> { - let diags = run("errors/e0001_posonly_params.py")?; - let src = std::fs::read_to_string(fixture("errors/e0001_posonly_params.py"))?; - assert_diagnostics( - &src, - &diags, - &[ - Expected::error("BSK-0001", "`numerator`", 1, 12), - Expected::error("BSK-0001", "`denominator`", 1, 23), - ], - ); - Ok(()) -} - -// --------------------------------------------------------------------------- -// missing return on innermost nested function only -// --------------------------------------------------------------------------- - -#[test] -fn only_innermost_nested_function_missing_return() -> Result<(), Box> { - let diags = run("errors/e0002_deeply_nested.py")?; - let src = std::fs::read_to_string(fixture("errors/e0002_deeply_nested.py"))?; - assert_diagnostics( - &src, - &diags, - &[Expected::error("BSK-0002", "`inner`", 3, 13)], - ); - Ok(()) -} - -// --------------------------------------------------------------------------- -// keyword-only params without annotations -// --------------------------------------------------------------------------- - -#[test] -fn unannotated_keyword_only_params_flagged() -> Result<(), Box> { - let diags = run("errors/e0001_kwonly_params.py")?; - let src = std::fs::read_to_string(fixture("errors/e0001_kwonly_params.py"))?; - assert_diagnostics( - &src, - &diags, - &[ - Expected::error("BSK-0001", "`height`", 1, 27), - Expected::error("BSK-0001", "`background`", 1, 35), - ], - ); - Ok(()) -} - -// --------------------------------------------------------------------------- -// BSK-0001 + BSK-0002 — four module-level functions, all completely untyped -// --------------------------------------------------------------------------- - -#[test] -fn and_e0002_four_completely_untyped_functions() -> Result<(), Box> { - let diags = run("errors/e0001_and_e0002_module_level.py")?; - let src = std::fs::read_to_string(fixture("errors/e0001_and_e0002_module_level.py"))?; - assert_diagnostics( - &src, - &diags, - &[ - Expected::error("BSK-0002", "`parse`", 1, 5), - Expected::error("BSK-0001", "`raw`", 1, 11), - // `validate` returns `True` — inferable as `bool`, so no BSK-0002 - // ([TYPEINF-FUNC-RETURN]); its parameter still fires BSK-0001. - Expected::error("BSK-0001", "`value`", 5, 14), - Expected::error("BSK-0002", "`transform`", 9, 5), - Expected::error("BSK-0001", "`data`", 9, 15), - Expected::error("BSK-0002", "`serialize`", 13, 5), - Expected::error("BSK-0001", "`obj`", 13, 15), - ], - ); - Ok(()) -} - -// --------------------------------------------------------------------------- -// function inside else branch of version guard -// --------------------------------------------------------------------------- - -#[test] -fn function_in_else_branch_of_version_guard() -> Result<(), Box> { - let diags = run("errors/e0002_in_if_block.py")?; - let src = std::fs::read_to_string(fixture("errors/e0002_in_if_block.py"))?; - assert_diagnostics( - &src, - &diags, - &[Expected::error("BSK-0002", "`new_feature`", 7, 9)], - ); - Ok(()) -} - -// --------------------------------------------------------------------------- -// BSK-0001 + BSK-0002 — subclass overrides with missing annotations -// --------------------------------------------------------------------------- - -#[test] -fn and_e0002_subclass_override_missing_annotations() -> Result<(), Box> { - let diags = run_with_config( - "errors/e0001_and_e0002_inheritance.py", - &annotation_rules_config_for_python("3.12"), - )?; - let src = std::fs::read_to_string(fixture("errors/e0001_and_e0002_inheritance.py"))?; - assert_diagnostics( - &src, - &diags, - &[ - Expected::error("BSK-0002", "`process`", 7, 9), - Expected::error("BSK-0025", "`process`", 7, 9), - Expected::error("BSK-0001", "`data`", 7, 23), - Expected::error("BSK-0002", "`extra`", 10, 9), - Expected::error("BSK-0001", "`value`", 10, 21), - ], - ); - Ok(()) -} - -// --------------------------------------------------------------------------- -// BSK-0001 + BSK-0002 — untyped functions inside try/except blocks -// --------------------------------------------------------------------------- - -#[test] -fn and_e0002_functions_inside_try_except() -> Result<(), Box> { - let diags = run("errors/e0001_and_e0002_try_except.py")?; - let src = std::fs::read_to_string(fixture("errors/e0001_and_e0002_try_except.py"))?; - assert_diagnostics( - &src, - &diags, - &[ - Expected::error("BSK-0002", "`risky`", 1, 5), - Expected::error("BSK-0001", "`value`", 1, 11), - Expected::error("BSK-0002", "`also_risky`", 8, 5), - Expected::error("BSK-0001", "`a`", 8, 16), - Expected::error("BSK-0001", "`b`", 8, 19), - ], - ); - Ok(()) -} - -// --------------------------------------------------------------------------- -// BSK-0001 + BSK-0002 — untyped functions inside while/for blocks -// --------------------------------------------------------------------------- - -#[test] -fn and_e0002_functions_inside_while_for() -> Result<(), Box> { - let diags = run("errors/e0001_and_e0002_while_for.py")?; - let src = std::fs::read_to_string(fixture("errors/e0001_and_e0002_while_for.py"))?; - assert_diagnostics( - &src, - &diags, - &[ - Expected::error("BSK-0002", "`count`", 1, 5), - Expected::error("BSK-0001", "`limit`", 1, 11), - Expected::error("BSK-0002", "`search`", 8, 5), - Expected::error("BSK-0001", "`items`", 8, 12), - Expected::error("BSK-0001", "`target`", 8, 19), - ], - ); - Ok(()) -} - -// --------------------------------------------------------------------------- -// zero-param functions without return annotation -// --------------------------------------------------------------------------- - -#[test] -fn zero_param_functions_all_missing_return() -> Result<(), Box> { - let diags = run("errors/e0002_no_params.py")?; - let src = std::fs::read_to_string(fixture("errors/e0002_no_params.py"))?; - assert_diagnostics( - &src, - &diags, - &[ - Expected::error("BSK-0002", "`get_version`", 1, 5), - Expected::error("BSK-0002", "`get_timestamp`", 5, 5), - Expected::error("BSK-0002", "`noop`", 9, 5), - ], - ); - Ok(()) -} - -// --------------------------------------------------------------------------- -// unannotated params in doubly-nested class methods -// --------------------------------------------------------------------------- - -#[test] -fn params_in_doubly_nested_class_methods() -> Result<(), Box> { - let diags = run("errors/e0001_deeply_nested_class.py")?; - let src = std::fs::read_to_string(fixture("errors/e0001_deeply_nested_class.py"))?; - assert_diagnostics( - &src, - &diags, - &[ - Expected::error("BSK-0001", "`value`", 3, 26), - Expected::error("BSK-0001", "`x`", 6, 28), - Expected::error("BSK-0001", "`y`", 6, 31), - ], - ); - Ok(()) -} - -// --------------------------------------------------------------------------- -// BSK-0001 + BSK-0004 — every parameter kind in one signature -// --------------------------------------------------------------------------- - -#[test] -fn and_e0004_all_parameter_kinds_flagged() -> Result<(), Box> { - let diags = run("errors/e0001_all_param_kinds.py")?; - let src = std::fs::read_to_string(fixture("errors/e0001_all_param_kinds.py"))?; - assert_diagnostics( - &src, - &diags, - &[ - Expected::error("BSK-0001", "`pos_only`", 1, 16), - Expected::error("BSK-0001", "`normal`", 1, 29), - Expected::error("BSK-0004", "`args`", 1, 38), - Expected::error("BSK-0001", "`kw_only`", 1, 44), - Expected::error("BSK-0004", "`kwargs`", 1, 55), - ], - ); - Ok(()) -} - -// --------------------------------------------------------------------------- -// module-level variables with unresolvable inference -// --------------------------------------------------------------------------- - -#[test] -fn unannotated_module_vars() -> Result<(), Box> { - let diags = run("errors/e0003_module_vars.py")?; - let src = std::fs::read_to_string(fixture("errors/e0003_module_vars.py"))?; - assert_diagnostics( - &src, - &diags, - &[ - Expected::error("BSK-0003", "`items`", 1, 1), - Expected::error("BSK-0003", "`data`", 2, 1), - Expected::error("BSK-0003", "`empty`", 3, 1), - ], - ); - Ok(()) -} - -// --------------------------------------------------------------------------- -// class attributes without type annotations -// --------------------------------------------------------------------------- - -#[test] -fn unannotated_class_attributes() -> Result<(), Box> { - let diags = run("errors/e0005_class_attrs.py")?; - let codes: Vec<&str> = diags.iter().map(|d| d.code.code).collect(); - assert!( - codes.contains(&"BSK-0005"), - "should emit BSK-0005 for unannotated class attributes, got: {diags:#?}" - ); - Ok(()) -} diff --git a/crates/basilisk-cli/tests/e2e_rules_b.rs b/crates/basilisk-cli/tests/e2e_rules_b.rs deleted file mode 100644 index ff1ed717b..000000000 --- a/crates/basilisk-cli/tests/e2e_rules_b.rs +++ /dev/null @@ -1,367 +0,0 @@ -//! Tests for [CHKARCH-CLI]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-CLI -#![allow( - clippy::allow_attributes, - clippy::indexing_slicing, - clippy::expect_used, - clippy::unwrap_used, - clippy::panic, - clippy::as_conversions -)] -//! E2E tests for error codes E0010 through BSK-0025. -//! -//! Includes both exact-diagnostic tests and presence-check tests for -//! rules that are partially implemented. - -mod common; - -use basilisk_test_utils::{assert_diagnostics, Expected}; -use common::{ - annotation_rules_config, annotation_rules_config_for_python, fixture, run, run_with_config, -}; - -// --------------------------------------------------------------------------- -// import from untyped module -// --------------------------------------------------------------------------- - -#[test] -fn import_from_untyped_module() -> Result<(), Box> { - let diags = run("errors/e0010_untyped_import.py")?; - let codes: Vec<&str> = diags.iter().map(|d| d.code.code).collect(); - assert!( - codes.contains(&"imports_unresolved"), - "should emit E0010 for untyped imports, got: {diags:#?}" - ); - Ok(()) -} - -// --------------------------------------------------------------------------- -// explicit Any without justification (split from E0011) -// --------------------------------------------------------------------------- - -#[test] -fn explicit_any_in_annotation() -> Result<(), Box> { - let diags = run_with_config("errors/e0011_explicit_any.py", &annotation_rules_config())?; - let codes: Vec<&str> = diags.iter().map(|d| d.code.code).collect(); - assert!( - codes.contains(&"BSK-0014"), - "should emit BSK-0014 for explicit Any annotations, got: {diags:#?}" - ); - Ok(()) -} - -// --------------------------------------------------------------------------- -// Any on vararg, kwarg, and return annotation (split from E0011) -// --------------------------------------------------------------------------- - -#[test] -fn any_on_vararg_kwarg_and_return() -> Result<(), Box> { - let diags = run_with_config( - "errors/e0011_vararg_kwarg_any.py", - &annotation_rules_config(), - )?; - let src = std::fs::read_to_string(fixture("errors/e0011_vararg_kwarg_any.py"))?; - assert_diagnostics( - &src, - &diags, - &[ - Expected::warning("BSK-0014", "return annotation", 4, 5), - Expected::warning("BSK-0014", "`args`", 4, 14), - Expected::warning("BSK-0014", "`kwargs`", 4, 27), - ], - ); - Ok(()) -} - -// --------------------------------------------------------------------------- -// return type mismatch (-> None returning value) -// --------------------------------------------------------------------------- - -#[test] -fn none_annotated_returning_value() -> Result<(), Box> { - let diags = run("errors/e0013_return_mismatch.py")?; - let codes: Vec<&str> = diags.iter().map(|d| d.code.code).collect(); - assert!( - codes.contains(&"returns_compatibility_2"), - "should emit E0013 when -> None function returns a value, got: {diags:#?}" - ); - Ok(()) -} - -// --------------------------------------------------------------------------- -// assignment type incompatibility (literal mismatches) -// --------------------------------------------------------------------------- - -#[test] -fn literal_assigned_to_incompatible_annotation() -> Result<(), Box> { - let diags = run("errors/e0014_assignment_incompatible.py")?; - let codes: Vec<&str> = diags.iter().map(|d| d.code.code).collect(); - assert!( - codes.contains(&"assignment_compatibility"), - "should emit E0014 for literal type mismatches, got: {diags:#?}" - ); - Ok(()) -} - -// --------------------------------------------------------------------------- -// bytes literal, float literal, and int-to-bytes mismatches -// --------------------------------------------------------------------------- - -#[test] -fn bytes_and_float_mismatches() -> Result<(), Box> { - let diags = run("errors/e0014_bytes_float_mismatches.py")?; - let src = std::fs::read_to_string(fixture("errors/e0014_bytes_float_mismatches.py"))?; - assert_diagnostics( - &src, - &diags, - &[ - Expected::error("assignment_compatibility", "`ratio`", 1, 1), - Expected::error("assignment_compatibility", "`name`", 2, 1), - Expected::error("assignment_compatibility", "`raw`", 3, 1), - ], - ); - Ok(()) -} - -// --------------------------------------------------------------------------- -// invalid type argument count -// --------------------------------------------------------------------------- - -#[test] -fn invalid_type_arg_count() -> Result<(), Box> { - let diags = run("errors/e0015_invalid_type_arg.py")?; - let codes: Vec<&str> = diags.iter().map(|d| d.code.code).collect(); - assert!( - codes.contains(&"callables_annotation"), - "should emit E0015 for invalid generic arg count, got: {diags:#?}" - ); - Ok(()) -} - -// --------------------------------------------------------------------------- -// set, frozenset, and dict with wrong type argument counts -// --------------------------------------------------------------------------- - -#[test] -fn set_frozenset_and_dict_wrong_arg_count() -> Result<(), Box> { - let diags = run("errors/e0015_more_generics.py")?; - let src = std::fs::read_to_string(fixture("errors/e0015_more_generics.py"))?; - assert_diagnostics( - &src, - &diags, - &[ - Expected::error("callables_annotation", "`set[", 1, 11), - Expected::error("callables_annotation", "`frozenset[", 5, 17), - Expected::error("callables_annotation", "`data`", 9, 18), - ], - ); - Ok(()) -} - -// --------------------------------------------------------------------------- -// @overload without implementation -// --------------------------------------------------------------------------- - -#[test] -fn overload_missing_implementation() -> Result<(), Box> { - let diags = run("errors/e0020_missing_overload_impl.py")?; - let codes: Vec<&str> = diags.iter().map(|d| d.code.code).collect(); - assert!( - codes.contains(&"overloads_definitions"), - "should emit E0020 when @overload has no implementation, got: {diags:#?}" - ); - Ok(()) -} - -// --------------------------------------------------------------------------- -// exact diagnostic: two @overload variants with no implementation -// --------------------------------------------------------------------------- - -#[test] -fn exact_diagnostic_for_double() -> Result<(), Box> { - let diags = run("errors/e0020_missing_overload_impl.py")?; - let src = std::fs::read_to_string(fixture("errors/e0020_missing_overload_impl.py"))?; - assert_diagnostics( - &src, - &diags, - &[Expected::error("overloads_definitions", "`double`", 5, 5)], - ); - Ok(()) -} - -// --------------------------------------------------------------------------- -// overlapping @overload signatures -// --------------------------------------------------------------------------- - -#[test] -fn overlapping_overload_signatures() -> Result<(), Box> { - let diags = run("errors/e0021_overlapping_overloads.py")?; - let codes: Vec<&str> = diags.iter().map(|d| d.code.code).collect(); - assert!( - codes.contains(&"overloads_consistency"), - "should emit E0021 for overlapping overload signatures, got: {diags:#?}" - ); - Ok(()) -} - -// --------------------------------------------------------------------------- -// exact diagnostics: overlapping overloads also trigger BSK-0001 -// --------------------------------------------------------------------------- - -#[test] -fn exact_diagnostics_for_overlapping_overloads() -> Result<(), Box> { - let diags = run_with_config( - "errors/e0021_overlapping_overloads.py", - &annotation_rules_config(), - )?; - let src = std::fs::read_to_string(fixture("errors/e0021_overlapping_overloads.py"))?; - assert_diagnostics( - &src, - &diags, - &[ - Expected::error("BSK-0001", "`x`", 5, 13), - Expected::error("overloads_consistency", "`process`", 9, 5), - // The second overload returns `str`, not assignable to the impl's `int`. - Expected::error("overloads_consistency_3", "`process`", 9, 5), - Expected::error("BSK-0001", "`x`", 9, 13), - ], - ); - Ok(()) -} - -// --------------------------------------------------------------------------- -// non-exhaustive match (no wildcard case) -// --------------------------------------------------------------------------- - -#[test] -fn match_without_wildcard() -> Result<(), Box> { - let diags = run("errors/e0023_nonexhaustive_match.py")?; - let codes: Vec<&str> = diags.iter().map(|d| d.code.code).collect(); - assert!( - codes.contains(&"match_exhaustiveness"), - "should emit E0023 for match without wildcard, got: {diags:#?}" - ); - Ok(()) -} - -// --------------------------------------------------------------------------- -// invalid type form in annotation -// --------------------------------------------------------------------------- - -#[test] -fn numeric_literal_as_type_annotation() -> Result<(), Box> { - let diags = run("errors/e0024_invalid_type_form.py")?; - let codes: Vec<&str> = diags.iter().map(|d| d.code.code).collect(); - assert!( - codes.contains(&"annotations_typeexpr"), - "should emit E0024 for numeric literal used as type, got: {diags:#?}" - ); - Ok(()) -} - -// --------------------------------------------------------------------------- -// numeric literal on vararg, kwarg, and return annotation -// --------------------------------------------------------------------------- - -#[test] -fn numeric_literal_on_vararg_kwarg_and_return() -> Result<(), Box> { - let diags = run("errors/e0024_vararg_kwarg_return_literal.py")?; - let src = std::fs::read_to_string(fixture("errors/e0024_vararg_kwarg_return_literal.py"))?; - assert_diagnostics( - &src, - &diags, - &[ - Expected::error("annotations_typeexpr", "return type", 1, 5), - Expected::error("annotations_typeexpr", "`args`", 1, 14), - Expected::error("annotations_typeexpr", "`kwargs`", 1, 26), - ], - ); - Ok(()) -} - -// --------------------------------------------------------------------------- -// method override without @override decorator -// --------------------------------------------------------------------------- - -#[test] -fn override_without_decorator() -> Result<(), Box> { - let diags = run_with_config( - "errors/e0025_missing_override.py", - &annotation_rules_config_for_python("3.12"), - )?; - let codes: Vec<&str> = diags.iter().map(|d| d.code.code).collect(); - assert!( - codes.contains(&"BSK-0025"), - "should emit BSK-0025 for override without @override, got: {diags:#?}" - ); - Ok(()) -} - -// --------------------------------------------------------------------------- -// Type-safety and flow diagnostics. -// --------------------------------------------------------------------------- - -/// Argument type mismatch. -#[test] -fn argument_type_mismatch() -> Result<(), Box> { - let diags = run("errors/e0012_wrong_arg_type.py")?; - assert!( - diags.iter().any(|d| d.code.code == "calls_argument_type"), - "expected calls_argument_type for an incompatible argument" - ); - Ok(()) -} - -/// Incompatible method override (type-level). -#[test] -fn incompatible_method_override() -> Result<(), Box> { - let diags = run("errors/e0016_incompatible_override.py")?; - assert!( - diags.iter().any(|d| d.code.code == "classes_override"), - "expected classes_override for an incompatible method override" - ); - Ok(()) -} - -/// Incompatible variable override. -#[test] -fn incompatible_variable_override() -> Result<(), Box> { - let diags = run("errors/e0017_variable_override.py")?; - assert!( - diags.iter().any(|d| d.code.code == "classes_override_2"), - "expected classes_override_2 for an incompatible variable override" - ); - Ok(()) -} - -/// Undefined variable. -#[test] -fn undefined_variable() -> Result<(), Box> { - let diags = run("errors/e0018_undefined_variable.py")?; - assert!( - diags.iter().any(|d| d.code.code == "names_undefined"), - "expected names_undefined for an undefined variable" - ); - Ok(()) -} - -/// Unbound variable on some code paths. -#[test] -fn unbound_variable() -> Result<(), Box> { - let diags = run("errors/e0019_unbound_variable.py")?; - assert!( - diags.iter().any(|d| d.code.code == "names_unbound"), - "expected names_unbound for a conditionally unbound variable" - ); - Ok(()) -} - -/// Unhashable type in hash-requiring context. -#[test] -fn unhashable_type() -> Result<(), Box> { - let diags = run("errors/e0022_unhashable_type.py")?; - assert!( - diags.iter().any(|d| d.code.code == "dict_key_hashable"), - "expected dict_key_hashable for an unhashable dictionary key" - ); - Ok(()) -} diff --git a/crates/basilisk-cli/tests/e2e_rules_c.rs b/crates/basilisk-cli/tests/e2e_rules_c.rs deleted file mode 100644 index dad2f7659..000000000 --- a/crates/basilisk-cli/tests/e2e_rules_c.rs +++ /dev/null @@ -1,446 +0,0 @@ -//! Tests for [CHKARCH-CLI]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-CLI -#![allow( - clippy::allow_attributes, - clippy::indexing_slicing, - clippy::expect_used, - clippy::unwrap_used, - clippy::panic, - clippy::as_conversions -)] -//! E2E tests for error codes E0026 through E0050. - -mod common; - -use common::run; - -// --------------------------------------------------------------------------- -// TypeVar with single constraint -// --------------------------------------------------------------------------- - -#[test] -fn typevar_single_constraint() -> Result<(), Box> { - let diags = run("errors/e0026_typevar_single_constraint.py")?; - let filtered: Vec<_> = diags - .iter() - .filter(|d| d.code.code == "generics_basic") - .collect(); - assert!( - !filtered.is_empty(), - "expected at least one generics_basic diagnostic" - ); - Ok(()) -} - -// --------------------------------------------------------------------------- -// Duplicate TypeVar in Generic[...] -// --------------------------------------------------------------------------- - -#[test] -fn duplicate_typevar_generic() -> Result<(), Box> { - let diags = run("errors/e0027_duplicate_typevar_generic.py")?; - let filtered: Vec<_> = diags - .iter() - .filter(|d| d.code.code == "generics_base_class") - .collect(); - assert!( - !filtered.is_empty(), - "expected at least one generics_base_class diagnostic" - ); - Ok(()) -} - -// --------------------------------------------------------------------------- -// Method defined inside a TypedDict -// --------------------------------------------------------------------------- - -#[test] -fn typeddict_method() -> Result<(), Box> { - let diags = run("errors/e0029_typeddict_method.py")?; - let filtered: Vec<_> = diags - .iter() - .filter(|d| d.code.code == "typeddicts_class_syntax") - .collect(); - assert!( - !filtered.is_empty(), - "expected at least one typeddicts_class_syntax diagnostic" - ); - Ok(()) -} - -// --------------------------------------------------------------------------- -// Non-default TypeVar follows default TypeVar in Generic[...] -// --------------------------------------------------------------------------- - -#[test] -fn non_default_after_default() -> Result<(), Box> { - let diags = run("errors/e0030_non_default_after_default.py")?; - let filtered: Vec<_> = diags - .iter() - .filter(|d| d.code.code == "generics_defaults") - .collect(); - assert!( - !filtered.is_empty(), - "expected at least one generics_defaults diagnostic" - ); - Ok(()) -} - -// --------------------------------------------------------------------------- -// Invalid cast() call -// --------------------------------------------------------------------------- - -#[test] -fn invalid_cast() -> Result<(), Box> { - let diags = run("errors/e0031_invalid_cast.py")?; - let filtered: Vec<_> = diags - .iter() - .filter(|d| d.code.code == "directives_cast") - .collect(); - assert!( - !filtered.is_empty(), - "expected at least one directives_cast diagnostic" - ); - Ok(()) -} - -// --------------------------------------------------------------------------- -// Invalid keyword argument in TypedDict class -// --------------------------------------------------------------------------- - -#[test] -fn typeddict_invalid_keyword() -> Result<(), Box> { - let diags = run("errors/e0032_typeddict_invalid_keyword.py")?; - let filtered: Vec<_> = diags - .iter() - .filter(|d| d.code.code == "typeddicts_class_syntax_2") - .collect(); - assert!( - !filtered.is_empty(), - "expected at least one typeddicts_class_syntax_2 diagnostic" - ); - Ok(()) -} - -// --------------------------------------------------------------------------- -// Invalid reveal_type() call -// --------------------------------------------------------------------------- - -#[test] -fn invalid_reveal_type() -> Result<(), Box> { - let diags = run("errors/e0033_invalid_reveal_type.py")?; - let filtered: Vec<_> = diags - .iter() - .filter(|d| d.code.code == "directives_reveal_type") - .collect(); - assert!( - !filtered.is_empty(), - "expected at least one directives_reveal_type diagnostic" - ); - Ok(()) -} - -// --------------------------------------------------------------------------- -// @final decorator violations -// --------------------------------------------------------------------------- - -#[test] -fn final_class_inherit() -> Result<(), Box> { - let diags = run("errors/e0034_final_class_inherit.py")?; - let filtered: Vec<_> = diags - .iter() - .filter(|d| d.code.code == "qualifiers_final_decorator") - .collect(); - assert!( - !filtered.is_empty(), - "expected at least one qualifiers_final_decorator diagnostic" - ); - Ok(()) -} - -// --------------------------------------------------------------------------- -// Required/NotRequired used outside TypedDict -// --------------------------------------------------------------------------- - -#[test] -fn required_outside_typeddict() -> Result<(), Box> { - let diags = run("errors/e0035_required_outside_typeddict.py")?; - let filtered: Vec<_> = diags - .iter() - .filter(|d| d.code.code == "typeddicts_required") - .collect(); - assert!( - !filtered.is_empty(), - "expected at least one typeddicts_required diagnostic" - ); - Ok(()) -} - -// --------------------------------------------------------------------------- -// ClassVar used in invalid context -// --------------------------------------------------------------------------- - -#[test] -fn classvar_invalid() -> Result<(), Box> { - let diags = run("errors/e0036_classvar_invalid.py")?; - let filtered: Vec<_> = diags - .iter() - .filter(|d| d.code.code == "classes_classvar") - .collect(); - assert!( - !filtered.is_empty(), - "expected at least one classes_classvar diagnostic" - ); - Ok(()) -} - -// --------------------------------------------------------------------------- -// Invalid TypedDict functional syntax -// --------------------------------------------------------------------------- - -#[test] -fn typeddict_functional_invalid() -> Result<(), Box> { - let diags = run("errors/e0037_typeddict_functional_invalid.py")?; - let filtered: Vec<_> = diags - .iter() - .filter(|d| d.code.code == "typeddicts_alt_syntax") - .collect(); - assert!( - !filtered.is_empty(), - "expected at least one typeddicts_alt_syntax diagnostic" - ); - Ok(()) -} - -// --------------------------------------------------------------------------- -// Invalid TypedDict inheritance -// --------------------------------------------------------------------------- - -#[test] -fn typeddict_inheritance_invalid() -> Result<(), Box> { - let diags = run("errors/e0038_typeddict_inheritance_invalid.py")?; - let filtered: Vec<_> = diags - .iter() - .filter(|d| d.code.code == "typeddicts_inheritance") - .collect(); - assert!( - !filtered.is_empty(), - "expected at least one typeddicts_inheritance diagnostic" - ); - Ok(()) -} - -// --------------------------------------------------------------------------- -// Invalid assert_type() call -// --------------------------------------------------------------------------- - -#[test] -fn invalid_assert_type() -> Result<(), Box> { - let diags = run("errors/e0039_invalid_assert_type.py")?; - let filtered: Vec<_> = diags - .iter() - .filter(|d| d.code.code == "directives_assert_type") - .collect(); - assert!( - !filtered.is_empty(), - "expected at least one directives_assert_type diagnostic" - ); - Ok(()) -} - -// --------------------------------------------------------------------------- -// Invalid Enum subclassing -// --------------------------------------------------------------------------- - -#[test] -fn enum_subclass() -> Result<(), Box> { - let diags = run("errors/e0040_enum_subclass.py")?; - let filtered: Vec<_> = diags - .iter() - .filter(|d| d.code.code == "enums_behaviors") - .collect(); - assert!( - !filtered.is_empty(), - "expected at least one enums_behaviors diagnostic" - ); - Ok(()) -} - -// --------------------------------------------------------------------------- -// Too few arguments in function call -// --------------------------------------------------------------------------- - -#[test] -fn too_few_args() -> Result<(), Box> { - let diags = run("errors/e0041_too_few_args.py")?; - let filtered: Vec<_> = diags - .iter() - .filter(|d| d.code.code == "calls_argument_count") - .collect(); - assert!( - !filtered.is_empty(), - "expected at least one calls_argument_count diagnostic" - ); - Ok(()) -} - -// --------------------------------------------------------------------------- -// PEP 695 type parameter mixed with traditional TypeVars -// --------------------------------------------------------------------------- - -#[test] -fn pep695_mixed_typevar() -> Result<(), Box> { - let diags = run("errors/e0042_pep695_mixed_typevar.py")?; - let filtered: Vec<_> = diags - .iter() - .filter(|d| d.code.code == "generics_syntax_compatibility") - .collect(); - assert!( - !filtered.is_empty(), - "expected at least one generics_syntax_compatibility diagnostic" - ); - Ok(()) -} - -// --------------------------------------------------------------------------- -// Non-TypeVar argument in Generic[...] -// --------------------------------------------------------------------------- - -#[test] -fn non_typevar_in_generic() -> Result<(), Box> { - let diags = run("errors/e0043_non_typevar_in_generic.py")?; - let filtered: Vec<_> = diags - .iter() - .filter(|d| d.code.code == "generics_basic_2") - .collect(); - assert!( - !filtered.is_empty(), - "expected at least one generics_basic_2 diagnostic" - ); - Ok(()) -} - -// --------------------------------------------------------------------------- -// Final used in invalid position -// --------------------------------------------------------------------------- - -#[test] -fn final_invalid_position() -> Result<(), Box> { - let diags = run("errors/e0044_final_invalid_position.py")?; - let filtered: Vec<_> = diags - .iter() - .filter(|d| d.code.code == "qualifiers_final_annotation") - .collect(); - assert!( - !filtered.is_empty(), - "expected at least one qualifiers_final_annotation diagnostic" - ); - Ok(()) -} - -// --------------------------------------------------------------------------- -// Invalid first argument to Annotated[...] -// --------------------------------------------------------------------------- - -#[test] -fn annotated_invalid() -> Result<(), Box> { - let diags = run("errors/e0045_annotated_invalid.py")?; - let filtered: Vec<_> = diags - .iter() - .filter(|d| d.code.code == "qualifiers_annotated") - .collect(); - assert!( - !filtered.is_empty(), - "expected at least one qualifiers_annotated diagnostic" - ); - Ok(()) -} - -// --------------------------------------------------------------------------- -// Enum member annotated with explicit type -// --------------------------------------------------------------------------- - -#[test] -fn enum_member_annotated() -> Result<(), Box> { - let diags = run("errors/e0046_enum_member_annotated.py")?; - let filtered: Vec<_> = diags - .iter() - .filter(|d| d.code.code == "enums_members") - .collect(); - assert!( - !filtered.is_empty(), - "expected at least one enums_members diagnostic" - ); - Ok(()) -} - -// --------------------------------------------------------------------------- -// Invalid type expression in annotation -// --------------------------------------------------------------------------- - -#[test] -fn invalid_type_expr() -> Result<(), Box> { - let diags = run("errors/e0047_invalid_type_expr.py")?; - let filtered: Vec<_> = diags - .iter() - .filter(|d| d.code.code == "annotations_forward_refs") - .collect(); - assert!( - !filtered.is_empty(), - "expected at least one annotations_forward_refs diagnostic" - ); - Ok(()) -} - -// --------------------------------------------------------------------------- -// Invalid RHS for TypeAlias -// --------------------------------------------------------------------------- - -#[test] -fn typealias_invalid_rhs() -> Result<(), Box> { - let diags = run("errors/e0048_typealias_invalid_rhs.py")?; - let filtered: Vec<_> = diags - .iter() - .filter(|d| d.code.code == "aliases_implicit") - .collect(); - assert!( - !filtered.is_empty(), - "expected at least one aliases_implicit diagnostic" - ); - Ok(()) -} - -// --------------------------------------------------------------------------- -// Multiple unbounded tuple components -// --------------------------------------------------------------------------- - -#[test] -fn multiple_unbounded_tuple() -> Result<(), Box> { - let diags = run("errors/e0049_multiple_unbounded_tuple.py")?; - let filtered: Vec<_> = diags - .iter() - .filter(|d| d.code.code == "tuples_type_form") - .collect(); - assert!( - !filtered.is_empty(), - "expected at least one tuples_type_form diagnostic" - ); - Ok(()) -} - -// --------------------------------------------------------------------------- -// Invalid NewType call -// --------------------------------------------------------------------------- - -#[test] -fn invalid_newtype() -> Result<(), Box> { - let diags = run("errors/e0050_invalid_newtype.py")?; - let filtered: Vec<_> = diags - .iter() - .filter(|d| d.code.code == "aliases_newtype") - .collect(); - assert!( - !filtered.is_empty(), - "expected at least one aliases_newtype diagnostic" - ); - Ok(()) -} diff --git a/crates/basilisk-cli/tests/e2e_rules_d.rs b/crates/basilisk-cli/tests/e2e_rules_d.rs deleted file mode 100644 index 8cb7945c8..000000000 --- a/crates/basilisk-cli/tests/e2e_rules_d.rs +++ /dev/null @@ -1,348 +0,0 @@ -//! Tests for [CHKARCH-CLI]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-CLI -#![allow( - clippy::allow_attributes, - clippy::indexing_slicing, - clippy::expect_used, - clippy::unwrap_used, - clippy::panic, - clippy::as_conversions -)] -//! E2E tests for error codes E0051 through E0069. - -mod common; - -use common::run; - -// --------------------------------------------------------------------------- -// Invalid Literal parameterization -// --------------------------------------------------------------------------- - -#[test] -fn invalid_literal() -> Result<(), Box> { - let diags = run("errors/e0051_invalid_literal.py")?; - let filtered: Vec<_> = diags - .iter() - .filter(|d| d.code.code == "literals_parameterizations") - .collect(); - assert!( - !filtered.is_empty(), - "expected at least one literals_parameterizations diagnostic" - ); - Ok(()) -} - -// --------------------------------------------------------------------------- -// Frozen dataclass attribute assignment -// --------------------------------------------------------------------------- - -#[test] -fn frozen_dataclass() -> Result<(), Box> { - let diags = run("errors/e0052_frozen_dataclass.py")?; - let filtered: Vec<_> = diags - .iter() - .filter(|d| d.code.code == "dataclasses_frozen") - .collect(); - assert!( - !filtered.is_empty(), - "expected at least one dataclasses_frozen diagnostic" - ); - Ok(()) -} - -// --------------------------------------------------------------------------- -// assert_type() type mismatch (may be disabled) -// --------------------------------------------------------------------------- - -#[test] -fn assert_type_mismatch() -> Result<(), Box> { - // E0053 may be disabled pending full type inference; just verify the - // fixture parses and runs through the pipeline without crashing. - let _diags = run("errors/e0053_assert_type_mismatch.py")?; - Ok(()) -} - -// --------------------------------------------------------------------------- -// Final reassignment -// --------------------------------------------------------------------------- - -#[test] -fn final_reassignment() -> Result<(), Box> { - let diags = run("errors/e0054_final_reassignment.py")?; - let filtered: Vec<_> = diags - .iter() - .filter(|d| d.code.code == "qualifiers_final_annotation_2") - .collect(); - assert!( - !filtered.is_empty(), - "expected at least one qualifiers_final_annotation_2 diagnostic" - ); - Ok(()) -} - -// --------------------------------------------------------------------------- -// Invalid TypeVar keyword argument combination -// --------------------------------------------------------------------------- - -#[test] -fn typevar_invalid_kwargs() -> Result<(), Box> { - let diags = run("errors/e0055_typevar_invalid_kwargs.py")?; - let filtered: Vec<_> = diags - .iter() - .filter(|d| d.code.code == "generics_typevartuple_basic") - .collect(); - assert!( - !filtered.is_empty(), - "expected at least one generics_typevartuple_basic diagnostic" - ); - Ok(()) -} - -// --------------------------------------------------------------------------- -// Mutation of ReadOnly TypedDict fields -// --------------------------------------------------------------------------- - -#[test] -fn readonly_typeddict() -> Result<(), Box> { - let diags = run("errors/e0056_readonly_typeddict.py")?; - let filtered: Vec<_> = diags - .iter() - .filter(|d| d.code.code == "typeddicts_readonly") - .collect(); - assert!( - !filtered.is_empty(), - "expected at least one typeddicts_readonly diagnostic" - ); - Ok(()) -} - -// --------------------------------------------------------------------------- -// Invalid RHS in PEP 695 type alias -// --------------------------------------------------------------------------- - -#[test] -fn pep695_type_alias_invalid() -> Result<(), Box> { - let diags = run("errors/e0057_pep695_type_alias_invalid.py")?; - assert!( - diags - .iter() - .any(|diagnostic| diagnostic.code.code == "aliases_type_statement"), - "expected aliases_type_statement for an invalid PEP 695 alias" - ); - Ok(()) -} - -// --------------------------------------------------------------------------- -// Annotated requires at least two arguments -// --------------------------------------------------------------------------- - -#[test] -fn annotated_too_few_args() -> Result<(), Box> { - let diags = run("errors/e0058_annotated_too_few_args.py")?; - let filtered: Vec<_> = diags - .iter() - .filter(|d| d.code.code == "qualifiers_annotated_2") - .collect(); - assert!( - !filtered.is_empty(), - "expected at least one qualifiers_annotated_2 diagnostic" - ); - Ok(()) -} - -// --------------------------------------------------------------------------- -// Access to __match_args__ on dataclass with match_args=False -// --------------------------------------------------------------------------- - -#[test] -fn dataclass_match_args_false() -> Result<(), Box> { - let diags = run("errors/e0059_dataclass_match_args_false.py")?; - let filtered: Vec<_> = diags - .iter() - .filter(|d| d.code.code == "dataclasses_match_args") - .collect(); - assert!( - !filtered.is_empty(), - "expected at least one dataclasses_match_args diagnostic" - ); - Ok(()) -} - -// --------------------------------------------------------------------------- -// Invalid ordering comparison of dataclass instances -// --------------------------------------------------------------------------- - -#[test] -fn dataclass_ordering_invalid() -> Result<(), Box> { - let diags = run("errors/e0060_dataclass_ordering_invalid.py")?; - let filtered: Vec<_> = diags - .iter() - .filter(|d| d.code.code == "dataclasses_order") - .collect(); - assert!( - !filtered.is_empty(), - "expected at least one dataclasses_order diagnostic" - ); - Ok(()) -} - -// --------------------------------------------------------------------------- -// assert_type with Literal[Enum.MEMBER] on enum-typed param -// --------------------------------------------------------------------------- - -#[test] -fn assert_type_enum_literal() -> Result<(), Box> { - let diags = run("errors/e0061_assert_type_enum_literal.py")?; - let filtered: Vec<_> = diags - .iter() - .filter(|d| d.code.code == "enums_expansion") - .collect(); - assert!( - !filtered.is_empty(), - "expected at least one enums_expansion diagnostic" - ); - Ok(()) -} - -// --------------------------------------------------------------------------- -// NoReturn/Never function can fall through -// --------------------------------------------------------------------------- - -#[test] -fn noreturn_fallthrough() -> Result<(), Box> { - let diags = run("errors/e0062_noreturn_fallthrough.py")?; - let filtered: Vec<_> = diags - .iter() - .filter(|d| d.code.code == "specialtypes_never") - .collect(); - assert!( - !filtered.is_empty(), - "expected at least one specialtypes_never diagnostic" - ); - Ok(()) -} - -// --------------------------------------------------------------------------- -// Non-hashable dataclass assigned to Hashable -// --------------------------------------------------------------------------- - -#[test] -fn non_hashable_dataclass() -> Result<(), Box> { - let diags = run("errors/e0063_non_hashable_dataclass.py")?; - let filtered: Vec<_> = diags - .iter() - .filter(|d| d.code.code == "dataclasses_hash") - .collect(); - assert!( - !filtered.is_empty(), - "expected at least one dataclasses_hash diagnostic" - ); - Ok(()) -} - -// --------------------------------------------------------------------------- -// Invalid argument in NamedTuple constructor -// --------------------------------------------------------------------------- - -#[test] -fn namedtuple_invalid_arg() -> Result<(), Box> { - let diags = run("errors/e0064_namedtuple_invalid_arg.py")?; - let filtered: Vec<_> = diags - .iter() - .filter(|d| d.code.code == "namedtuples_define_functional") - .collect(); - assert!( - !filtered.is_empty(), - "expected at least one namedtuples_define_functional diagnostic" - ); - Ok(()) -} - -// --------------------------------------------------------------------------- -// Access to int-only attribute on float-typed parameter -// --------------------------------------------------------------------------- - -#[test] -fn float_param_int_attr() -> Result<(), Box> { - let diags = run("errors/e0065_float_param_int_attr.py")?; - let filtered: Vec<_> = diags - .iter() - .filter(|d| d.code.code == "specialtypes_promotions") - .collect(); - assert!( - !filtered.is_empty(), - "expected at least one specialtypes_promotions diagnostic" - ); - Ok(()) -} - -// --------------------------------------------------------------------------- -// Enum member value incompatible with _value_ type -// --------------------------------------------------------------------------- - -#[test] -fn enum_value_type_mismatch() -> Result<(), Box> { - let diags = run("errors/e0066_enum_value_type_mismatch.py")?; - let filtered: Vec<_> = diags - .iter() - .filter(|d| d.code.code == "enums_member_values") - .collect(); - assert!( - !filtered.is_empty(), - "expected at least one enums_member_values diagnostic" - ); - Ok(()) -} - -// --------------------------------------------------------------------------- -// Non-member referenced in Literal[EnumClass.X] -// --------------------------------------------------------------------------- - -#[test] -fn enum_non_member_literal() -> Result<(), Box> { - let diags = run("errors/e0067_enum_non_member_literal.py")?; - let filtered: Vec<_> = diags - .iter() - .filter(|d| d.code.code == "enums_members_2") - .collect(); - assert!( - !filtered.is_empty(), - "expected at least one enums_members_2 diagnostic" - ); - Ok(()) -} - -// --------------------------------------------------------------------------- -// Literal string used where enum member reference required -// --------------------------------------------------------------------------- - -#[test] -fn literal_string_enum() -> Result<(), Box> { - let diags = run("errors/e0068_literal_string_enum.py")?; - let filtered: Vec<_> = diags - .iter() - .filter(|d| d.code.code == "literals_parameterizations_2") - .collect(); - assert!( - !filtered.is_empty(), - "expected at least one literals_parameterizations_2 diagnostic" - ); - Ok(()) -} - -// --------------------------------------------------------------------------- -// Dataclass keyword-only field violations -// --------------------------------------------------------------------------- - -#[test] -fn dataclass_kwonly() -> Result<(), Box> { - let diags = run("errors/e0069_dataclass_kwonly.py")?; - let filtered: Vec<_> = diags - .iter() - .filter(|d| d.code.code == "dataclasses_kwonly") - .collect(); - assert!( - !filtered.is_empty(), - "expected at least one dataclasses_kwonly diagnostic" - ); - Ok(()) -} diff --git a/crates/basilisk-cli/tests/e2e_rules_e.rs b/crates/basilisk-cli/tests/e2e_rules_e.rs deleted file mode 100644 index 137a862ce..000000000 --- a/crates/basilisk-cli/tests/e2e_rules_e.rs +++ /dev/null @@ -1,320 +0,0 @@ -//! Tests for [CHKARCH-CLI]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-CLI -#![allow( - clippy::allow_attributes, - clippy::indexing_slicing, - clippy::expect_used, - clippy::unwrap_used, - clippy::panic, - clippy::as_conversions -)] -//! E2E tests for error codes E0070 through E0086. - -mod common; - -use common::run; - -// --------------------------------------------------------------------------- -// Never type compatibility violations -// --------------------------------------------------------------------------- - -#[test] -fn never_type_compat() -> Result<(), Box> { - let diags = run("errors/e0070_never_type_compat.py")?; - let filtered: Vec<_> = diags - .iter() - .filter(|d| d.code.code == "specialtypes_never_2") - .collect(); - assert!( - !filtered.is_empty(), - "expected at least one specialtypes_never_2 diagnostic" - ); - Ok(()) -} - -// --------------------------------------------------------------------------- -// Historical positional-only parameter violations -// --------------------------------------------------------------------------- - -#[test] -fn historical_positional() -> Result<(), Box> { - let diags = run("errors/e0071_historical_positional.py")?; - let filtered: Vec<_> = diags - .iter() - .filter(|d| d.code.code == "historical_positional") - .collect(); - assert!( - !filtered.is_empty(), - "expected at least one historical_positional diagnostic" - ); - Ok(()) -} - -// --------------------------------------------------------------------------- -// No matching overload for subscript indexing -// --------------------------------------------------------------------------- - -#[test] -fn no_matching_overload() -> Result<(), Box> { - let diags = run("errors/e0072_no_matching_overload.py")?; - let filtered: Vec<_> = diags - .iter() - .filter(|d| d.code.code == "overloads_basic") - .collect(); - assert!( - !filtered.is_empty(), - "expected at least one overloads_basic diagnostic" - ); - Ok(()) -} - -// --------------------------------------------------------------------------- -// NamedTuple-to-tuple type incompatibility -// --------------------------------------------------------------------------- - -#[test] -fn namedtuple_tuple_compat() -> Result<(), Box> { - let diags = run("errors/e0073_namedtuple_tuple_compat.py")?; - let filtered: Vec<_> = diags - .iter() - .filter(|d| d.code.code == "namedtuples_type_compat") - .collect(); - assert!( - !filtered.is_empty(), - "expected at least one namedtuples_type_compat diagnostic" - ); - Ok(()) -} - -// --------------------------------------------------------------------------- -// Constructor call type mismatch with specialized generic -// --------------------------------------------------------------------------- - -#[test] -fn constructor_new_mismatch() -> Result<(), Box> { - let diags = run("errors/e0074_constructor_new_mismatch.py")?; - let filtered: Vec<_> = diags - .iter() - .filter(|d| d.code.code == "constructors_call_new") - .collect(); - assert!( - !filtered.is_empty(), - "expected at least one constructors_call_new diagnostic" - ); - Ok(()) -} - -// --------------------------------------------------------------------------- -// Incompatible type for Self-typed attribute -// --------------------------------------------------------------------------- - -#[test] -fn self_type_attr_incompat() -> Result<(), Box> { - let diags = run("errors/e0075_self_type_attr_incompat.py")?; - let filtered: Vec<_> = diags - .iter() - .filter(|d| d.code.code == "generics_self_attributes") - .collect(); - assert!( - !filtered.is_empty(), - "expected at least one generics_self_attributes diagnostic" - ); - Ok(()) -} - -// --------------------------------------------------------------------------- -// Overload union expansion failure -// --------------------------------------------------------------------------- - -#[test] -fn overload_union_expansion() -> Result<(), Box> { - let diags = run("errors/e0076_overload_union_expansion.py")?; - let filtered: Vec<_> = diags - .iter() - .filter(|d| d.code.code == "overloads_evaluation") - .collect(); - assert!( - !filtered.is_empty(), - "expected at least one overloads_evaluation diagnostic" - ); - Ok(()) -} - -// --------------------------------------------------------------------------- -// Protocol Self-return conformance violation -// --------------------------------------------------------------------------- - -#[test] -fn protocol_self_return() -> Result<(), Box> { - let diags = run("errors/e0077_protocol_self_return.py")?; - let filtered: Vec<_> = diags - .iter() - .filter(|d| d.code.code == "generics_self_protocols") - .collect(); - assert!( - !filtered.is_empty(), - "expected at least one generics_self_protocols diagnostic" - ); - Ok(()) -} - -// --------------------------------------------------------------------------- -// Self type violations in generics -// --------------------------------------------------------------------------- - -#[test] -fn self_type_violation() -> Result<(), Box> { - let diags = run("errors/e0078_self_type_violation.py")?; - let filtered: Vec<_> = diags - .iter() - .filter(|d| d.code.code == "generics_self_basic") - .collect(); - assert!( - !filtered.is_empty(), - "expected at least one generics_self_basic diagnostic" - ); - Ok(()) -} - -// --------------------------------------------------------------------------- -// Module assigned to incompatible protocol type -// --------------------------------------------------------------------------- - -#[test] -fn module_protocol_incompat() -> Result<(), Box> { - let diags = run("errors/e0079_module_protocol_incompat.py")?; - let filtered: Vec<_> = diags - .iter() - .filter(|d| d.code.code == "protocols_modules") - .collect(); - assert!( - !filtered.is_empty(), - "expected at least one protocols_modules diagnostic" - ); - Ok(()) -} - -// --------------------------------------------------------------------------- -// TypeVar upper bound violation at call site -// --------------------------------------------------------------------------- - -#[test] -fn typevar_bound_violation() -> Result<(), Box> { - let diags = run("errors/e0080_typevar_bound_violation.py")?; - let filtered: Vec<_> = diags - .iter() - .filter(|d| d.code.code == "generics_upper_bound") - .collect(); - assert!( - !filtered.is_empty(), - "expected at least one generics_upper_bound diagnostic" - ); - Ok(()) -} - -// --------------------------------------------------------------------------- -// TypeVarTuple unpack minimum type argument violation -// --------------------------------------------------------------------------- - -#[test] -fn typevartuple_unpack_min() -> Result<(), Box> { - let diags = run("errors/e0081_typevartuple_unpack_min.py")?; - let filtered: Vec<_> = diags - .iter() - .filter(|d| d.code.code == "generics_typevartuple_unpack") - .collect(); - assert!( - !filtered.is_empty(), - "expected at least one generics_typevartuple_unpack diagnostic" - ); - Ok(()) -} - -// --------------------------------------------------------------------------- -// TypeVarTuple callable/tuple argument mismatch -// --------------------------------------------------------------------------- - -#[test] -fn typevartuple_callable_mismatch() -> Result<(), Box> { - let diags = run("errors/e0082_typevartuple_callable_mismatch.py")?; - let filtered: Vec<_> = diags - .iter() - .filter(|d| d.code.code == "generics_typevartuple_callable") - .collect(); - assert!( - !filtered.is_empty(), - "expected at least one generics_typevartuple_callable diagnostic" - ); - Ok(()) -} - -// --------------------------------------------------------------------------- -// TypeVarTuple must be unpacked with * operator -// --------------------------------------------------------------------------- - -#[test] -fn typevartuple_unpack_required() -> Result<(), Box> { - let diags = run("errors/e0083_typevartuple_unpack_required.py")?; - let filtered: Vec<_> = diags - .iter() - .filter(|d| d.code.code == "generics_typevartuple_basic_2") - .collect(); - assert!( - !filtered.is_empty(), - "expected at least one generics_typevartuple_basic_2 diagnostic" - ); - Ok(()) -} - -// --------------------------------------------------------------------------- -// TypeVarTuple variance/bounds/constraints violation -// --------------------------------------------------------------------------- - -#[test] -fn typevartuple_invalid_params() -> Result<(), Box> { - let diags = run("errors/e0084_typevartuple_invalid_params.py")?; - let filtered: Vec<_> = diags - .iter() - .filter(|d| d.code.code == "generics_typevartuple_basic_3") - .collect(); - assert!( - !filtered.is_empty(), - "expected at least one generics_typevartuple_basic_3 diagnostic" - ); - Ok(()) -} - -// --------------------------------------------------------------------------- -// TypeVarTuple argument count mismatch -// --------------------------------------------------------------------------- - -#[test] -fn typevartuple_arg_count() -> Result<(), Box> { - let diags = run("errors/e0085_typevartuple_arg_count.py")?; - let filtered: Vec<_> = diags - .iter() - .filter(|d| d.code.code == "generics_typevartuple_args") - .collect(); - assert!( - !filtered.is_empty(), - "expected at least one generics_typevartuple_args diagnostic" - ); - Ok(()) -} - -// --------------------------------------------------------------------------- -// Multiple TypeVarTuple declarations in generic -// --------------------------------------------------------------------------- - -#[test] -fn multiple_typevartuple() -> Result<(), Box> { - let diags = run("errors/e0086_multiple_typevartuple.py")?; - let filtered: Vec<_> = diags - .iter() - .filter(|d| d.code.code == "generics_typevartuple_specialization") - .collect(); - assert!( - !filtered.is_empty(), - "expected at least one generics_typevartuple_specialization diagnostic" - ); - Ok(()) -} diff --git a/crates/basilisk-cli/tests/e2e_rules_f.rs b/crates/basilisk-cli/tests/e2e_rules_f.rs deleted file mode 100644 index 024470f96..000000000 --- a/crates/basilisk-cli/tests/e2e_rules_f.rs +++ /dev/null @@ -1,248 +0,0 @@ -//! Tests for [CHKARCH-CLI]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-CLI -#![allow( - clippy::allow_attributes, - clippy::indexing_slicing, - clippy::expect_used, - clippy::unwrap_used, - clippy::panic, - clippy::as_conversions -)] -//! E2E tests for error codes E0088 through E0100. - -mod common; - -use common::run; - -// --------------------------------------------------------------------------- -// TypedDict runtime violation (isinstance) -// --------------------------------------------------------------------------- - -#[test] -fn typeddict_isinstance() -> Result<(), Box> { - let diags = run("errors/e0088_typeddict_isinstance.py")?; - let filtered: Vec<_> = diags - .iter() - .filter(|d| d.code.code == "typeddicts_usage") - .collect(); - assert!( - !filtered.is_empty(), - "expected at least one typeddicts_usage diagnostic" - ); - Ok(()) -} - -// --------------------------------------------------------------------------- -// Invalid PEP 695 type parameter bound or constraint -// --------------------------------------------------------------------------- - -#[test] -fn pep695_invalid_bound() -> Result<(), Box> { - let diags = run("errors/e0089_pep695_invalid_bound.py")?; - let filtered: Vec<_> = diags - .iter() - .filter(|d| d.code.code == "generics_syntax_declarations") - .collect(); - assert!( - !filtered.is_empty(), - "expected at least one generics_syntax_declarations diagnostic" - ); - Ok(()) -} - -// --------------------------------------------------------------------------- -// Invalid tuple type syntax -// --------------------------------------------------------------------------- - -#[test] -fn invalid_tuple_syntax() -> Result<(), Box> { - let diags = run("errors/e0090_invalid_tuple_syntax.py")?; - let filtered: Vec<_> = diags - .iter() - .filter(|d| d.code.code == "tuples_type_form_2") - .collect(); - assert!( - !filtered.is_empty(), - "expected at least one tuples_type_form_2 diagnostic" - ); - Ok(()) -} - -// --------------------------------------------------------------------------- -// Incompatible TypeVar bound/constraint with default -// --------------------------------------------------------------------------- - -#[test] -fn typevar_default_incompat() -> Result<(), Box> { - let diags = run("errors/e0091_typevar_default_incompat.py")?; - let filtered: Vec<_> = diags - .iter() - .filter(|d| d.code.code == "generics_defaults_2") - .collect(); - assert!( - !filtered.is_empty(), - "expected at least one generics_defaults_2 diagnostic" - ); - Ok(()) -} - -// --------------------------------------------------------------------------- -// Too few type arguments to generic class -// --------------------------------------------------------------------------- - -#[test] -fn too_few_type_args() -> Result<(), Box> { - let diags = run("errors/e0092_too_few_type_args.py")?; - let filtered: Vec<_> = diags - .iter() - .filter(|d| d.code.code == "generics_defaults_specialization") - .collect(); - assert!( - !filtered.is_empty(), - "expected at least one generics_defaults_specialization diagnostic" - ); - Ok(()) -} - -// --------------------------------------------------------------------------- -// Invalid key or value type in TypedDict assignment -// --------------------------------------------------------------------------- - -#[test] -fn typeddict_key_validation() -> Result<(), Box> { - let diags = run("errors/e0093_typeddict_key_validation.py")?; - let filtered: Vec<_> = diags - .iter() - .filter(|d| d.code.code == "typeddicts_operations") - .collect(); - assert!( - !filtered.is_empty(), - "expected at least one typeddicts_operations diagnostic" - ); - Ok(()) -} - -// --------------------------------------------------------------------------- -// Self type used in an invalid location -// --------------------------------------------------------------------------- - -#[test] -fn self_type_invalid_location() -> Result<(), Box> { - let diags = run("errors/e0094_self_type_invalid_location.py")?; - let filtered: Vec<_> = diags - .iter() - .filter(|d| d.code.code == "generics_self_usage") - .collect(); - assert!( - !filtered.is_empty(), - "expected at least one generics_self_usage diagnostic" - ); - Ok(()) -} - -// --------------------------------------------------------------------------- -// InitVar field validation in dataclasses -// --------------------------------------------------------------------------- - -#[test] -fn initvar_field() -> Result<(), Box> { - let diags = run("errors/e0095_initvar_field.py")?; - let filtered: Vec<_> = diags - .iter() - .filter(|d| d.code.code == "dataclasses_postinit") - .collect(); - assert!( - !filtered.is_empty(), - "expected at least one dataclasses_postinit diagnostic" - ); - Ok(()) -} - -// --------------------------------------------------------------------------- -// Dataclass field default_factory type mismatch -// --------------------------------------------------------------------------- - -#[test] -fn dataclass_default_factory() -> Result<(), Box> { - let diags = run("errors/e0096_dataclass_default_factory.py")?; - let filtered: Vec<_> = diags - .iter() - .filter(|d| d.code.code == "dataclasses_usage") - .collect(); - assert!( - !filtered.is_empty(), - "expected at least one dataclasses_usage diagnostic" - ); - Ok(()) -} - -// --------------------------------------------------------------------------- -// Protocol __new__/__init__ sets undeclared self-attributes -// --------------------------------------------------------------------------- - -#[test] -fn protocol_self_attr() -> Result<(), Box> { - let diags = run("errors/e0097_protocol_self_attr.py")?; - let filtered: Vec<_> = diags - .iter() - .filter(|d| d.code.code == "protocols_definition") - .collect(); - assert!( - !filtered.is_empty(), - "expected at least one protocols_definition diagnostic" - ); - Ok(()) -} - -// --------------------------------------------------------------------------- -// Non-Protocol base class in Protocol definition -// --------------------------------------------------------------------------- - -#[test] -fn non_protocol_base() -> Result<(), Box> { - let diags = run("errors/e0098_non_protocol_base.py")?; - let filtered: Vec<_> = diags - .iter() - .filter(|d| d.code.code == "protocols_merging") - .collect(); - assert!( - !filtered.is_empty(), - "expected at least one protocols_merging diagnostic" - ); - Ok(()) -} - -// --------------------------------------------------------------------------- -// Direct instantiation of a Protocol class -// --------------------------------------------------------------------------- - -#[test] -fn protocol_instantiation() -> Result<(), Box> { - let diags = run("errors/e0099_protocol_instantiation.py")?; - let filtered: Vec<_> = diags - .iter() - .filter(|d| d.code.code == "protocols_explicit") - .collect(); - assert!( - !filtered.is_empty(), - "expected at least one protocols_explicit diagnostic" - ); - Ok(()) -} - -// --------------------------------------------------------------------------- -// Augmented assignment widens Literal type -// --------------------------------------------------------------------------- - -#[test] -fn literal_augmented_assign() -> Result<(), Box> { - let diags = run("errors/e0100_literal_augmented_assign.py")?; - let filtered: Vec<_> = diags - .iter() - .filter(|d| d.code.code == "literals_semantics") - .collect(); - assert!( - !filtered.is_empty(), - "expected at least one literals_semantics diagnostic" - ); - Ok(()) -} diff --git a/crates/basilisk-cli/tests/e2e_scope.rs b/crates/basilisk-cli/tests/e2e_scope.rs deleted file mode 100644 index e4c844c1b..000000000 --- a/crates/basilisk-cli/tests/e2e_scope.rs +++ /dev/null @@ -1,325 +0,0 @@ -//! Tests for [CHKARCH-COMMANDS] / [CHKARCH-CONFIG-MODEL]. See -//! docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-COMMANDS -#![allow( - clippy::allow_attributes, - clippy::expect_used, - clippy::unwrap_used, - clippy::panic -)] -//! E2E tests for the check/analyze command partition through the real binary. -//! -//! One rule universe, partitioned once by provenance tag: `basilisk check` -//! emits only `pep`-tagged rules and always runs them; `basilisk analyze` -//! emits only the rest and runs them only when configuration resolves them to -//! a non-disabled severity. A configuration that resolves a `pep` rule to -//! `disabled` is invalid and exits 2 ([CHKARCH-CLI-EXITCODES]). - -use std::path::{Path, PathBuf}; -use std::process::{Command, Output}; -use std::sync::atomic::{AtomicU64, Ordering}; - -/// A throwaway directory unique to this process and call. -fn unique_dir(prefix: &str) -> PathBuf { - static CTR: AtomicU64 = AtomicU64::new(0); - let n = CTR.fetch_add(1, Ordering::Relaxed); - let dir = std::env::temp_dir().join(format!("bsk_scope_{prefix}_{}_{n}", std::process::id())); - std::fs::create_dir_all(&dir).expect("create temp dir"); - dir -} - -/// Run `basilisk --output json --color never`. -fn run(subcommand: &str, path: &Path) -> Output { - Command::new(env!("CARGO_BIN_EXE_basilisk")) - .arg(subcommand) - .arg(path) - .args(["--output", "json", "--color", "never"]) - .output() - .expect("spawn basilisk") -} - -/// Run `basilisk --color never` in the default text format. -fn run_text(subcommand: &str, path: &Path) -> Output { - Command::new(env!("CARGO_BIN_EXE_basilisk")) - .arg(subcommand) - .arg(path) - .args(["--color", "never"]) - .output() - .expect("spawn basilisk") -} - -fn stdout(output: &Output) -> String { - String::from_utf8_lossy(&output.stdout).into_owned() -} - -/// The diagnostic codes in a JSON run's output. -fn json_codes(output: &Output) -> Vec { - let value: serde_json::Value = serde_json::from_str(&stdout(output)).expect("valid JSON"); - value - .as_array() - .expect("JSON output is an array") - .iter() - .map(|d| d["code"].as_str().expect("code is a string").to_owned()) - .collect() -} - -/// Source that violates the opt-in annotation house rules but no pep rule. -const HOUSE_DEBT_ONLY: &str = "def foo(x):\n return x\n"; - -/// [CHKARCH-COMMANDS]: on a bare tree (no `[tool.basilisk]` anywhere), -/// `analyze` runs nothing — no entry, no check — even on code full of -/// house-rule debt. -#[test] -fn analyze_runs_nothing_on_bare_tree() { - let dir = unique_dir("bare"); - let py = dir.join("m.py"); - std::fs::write(&py, HOUSE_DEBT_ONLY).expect("write module"); - - let out = run("analyze", &py); - assert_eq!( - out.status.code(), - Some(0), - "a bare tree must analyze clean, stdout: {}", - stdout(&out) - ); - assert!( - json_codes(&out).is_empty(), - "a bare tree must produce zero analyze diagnostics, got: {}", - stdout(&out) - ); -} - -/// [CHKARCH-CONFIG-MODEL]: one written `rule-tags` line (`"basilisk" = -/// "error"`) selects and grades every house rule — `analyze` then fires them. -#[test] -fn analyze_fires_house_rule_selected_by_tag_entry() { - let dir = unique_dir("tag"); - std::fs::write( - dir.join("pyproject.toml"), - "[tool.basilisk.rule-tags]\n\"basilisk\" = \"error\"\n", - ) - .expect("write config"); - let py = dir.join("m.py"); - std::fs::write(&py, HOUSE_DEBT_ONLY).expect("write module"); - - let out = run("analyze", &py); - assert_eq!( - out.status.code(), - Some(1), - "tag-selected house errors must exit 1, stdout: {}", - stdout(&out) - ); - let codes = json_codes(&out); - assert!( - codes.iter().any(|code| code == "BSK-0001"), - "the `basilisk` tag entry must select BSK-0001 under analyze, got: {codes:?}" - ); - assert!( - codes - .iter() - .all(|code| !basilisk_checker::is_pep_rule(code)), - "analyze must emit only non-pep diagnostics ([CHKARCH-COMMANDS]), got: {codes:?}" - ); -} - -/// [CHKARCH-COMMANDS]: `check` never emits house diagnostics — even when -/// configuration explicitly selects them at `error`. -#[test] -fn check_never_emits_house_diagnostics_even_when_configured() { - let dir = unique_dir("checkscope"); - std::fs::write( - dir.join("pyproject.toml"), - "[tool.basilisk.rules]\n\"BSK-0001\" = \"error\"\n\"BSK-0002\" = \"error\"\n", - ) - .expect("write config"); - let py = dir.join("m.py"); - std::fs::write(&py, HOUSE_DEBT_ONLY).expect("write module"); - - let out = run("check", &py); - assert_eq!( - out.status.code(), - Some(0), - "check must exit 0 — the debt is analyze-scope, stdout: {}", - stdout(&out) - ); - let codes = json_codes(&out); - assert!( - codes.is_empty(), - "check must emit no house diagnostics ([CHKARCH-COMMANDS]), got: {codes:?}" - ); - - // The same project's debt IS visible to `analyze` — the partition, not a - // silent drop. - let analyze = run("analyze", &py); - assert_eq!(analyze.status.code(), Some(1)); -} - -/// [CHKARCH-COMMANDS]: `check` emits pep diagnostics on a bare tree with the -/// documented JSON shape — the surface the conformance harness invokes. -#[test] -fn check_emits_pep_diagnostics_with_stable_json_shape() { - let dir = unique_dir("pepjson"); - let py = dir.join("m.py"); - std::fs::write(&py, "def bad() -> int:\n return \"x\"\n").expect("write module"); - - let out = run("check", &py); - assert_eq!(out.status.code(), Some(1), "pep errors must exit 1"); - let value: serde_json::Value = serde_json::from_str(&stdout(&out)).expect("valid JSON"); - let diagnostics = value.as_array().expect("array"); - assert!(!diagnostics.is_empty(), "a pep diagnostic must be emitted"); - for diagnostic in diagnostics { - for key in ["path", "line", "col", "severity", "message", "code"] { - assert!( - diagnostic.get(key).is_some(), - "JSON diagnostics must carry `{key}`, got: {diagnostic}" - ); - } - let code = diagnostic["code"].as_str().expect("code is a string"); - assert!( - basilisk_checker::is_pep_rule(code), - "check must emit only pep-tagged codes, got: {code}" - ); - } -} - -// ── the check/analyze split is never silent ([CHKARCH-CLI-SCOPE-NOTICE]) ───── - -/// Refs #334. `analyze` is the ONLY command that runs the opt-in rule layer, -/// so a user who cannot see it in `basilisk --help` cannot discover that their -/// configured rules were never evaluated. This guard keeps the subcommand -/// discoverable; the remaining #334 gap (a clean `check` not naming the -/// unrun rules) is covered by the scope-notice tests below. -#[test] -fn top_level_help_lists_every_rule_running_command() { - let out = Command::new(env!("CARGO_BIN_EXE_basilisk")) - .arg("--help") - .output() - .expect("spawn basilisk"); - let text = stdout(&out); - - for command in ["check", "analyze", "fix"] { - assert!( - text.lines() - .any(|line| line.trim_start().starts_with(&format!("{command} "))), - "`basilisk --help` must list the `{command}` subcommand, got: {text}" - ); - } -} - -/// Refs #334. A clean `check` on a project whose configuration selects -/// analyze-scope rules must say so. Otherwise a silent clean run is -/// indistinguishable from a real one: the reporter's project graded eight rule -/// tags `error`, ran `check` in CI, saw "All checked. No issues found." — and -/// 66 configured errors were never evaluated for the life of the pipeline. -#[test] -fn check_reports_configured_rules_its_scope_did_not_run() { - let dir = unique_dir("noticeclean"); - std::fs::write( - dir.join("pyproject.toml"), - "[tool.basilisk.rule-tags]\n\"basilisk\" = \"error\"\n", - ) - .expect("write config"); - let py = dir.join("m.py"); - std::fs::write(&py, HOUSE_DEBT_ONLY).expect("write module"); - - let out = run_text("check", &py); - assert_eq!( - out.status.code(), - Some(0), - "the debt is analyze-scope, so check still exits 0, stdout: {}", - stdout(&out) - ); - let text = stdout(&out); - assert!( - text.contains("All checked. No issues found."), - "check must still report its own clean result, got: {text}" - ); - assert!( - text.contains("basilisk analyze"), - "a clean check must point at `basilisk analyze` when configuration \ - selects rules this scope never ran ([CHKARCH-CLI-SCOPE-NOTICE]), got: {text}" - ); -} - -/// The notice is a fact about *this* project, not boilerplate: a bare tree -/// selects no analyze-scope rule, so a clean `check` says nothing extra. -#[test] -fn check_stays_quiet_when_configuration_selects_no_analyze_rule() { - let dir = unique_dir("noticebare"); - let py = dir.join("m.py"); - std::fs::write(&py, HOUSE_DEBT_ONLY).expect("write module"); - - let text = stdout(&run_text("check", &py)); - assert!( - !text.contains("basilisk analyze"), - "a bare tree selects nothing, so check must not advertise analyze, got: {text}" - ); -} - -/// `analyze` already ran those rules — it must never tell the user to run it. -#[test] -fn analyze_never_advertises_itself() { - let dir = unique_dir("noticeanalyze"); - std::fs::write( - dir.join("pyproject.toml"), - "[tool.basilisk.rule-tags]\n\"basilisk\" = \"error\"\n", - ) - .expect("write config"); - let py = dir.join("m.py"); - std::fs::write(&py, HOUSE_DEBT_ONLY).expect("write module"); - - let text = stdout(&run_text("analyze", &py)); - assert!( - !text.contains("basilisk analyze"), - "analyze ran the configured rules; pointing at itself is noise, got: {text}" - ); -} - -/// [CHKARCH-CONFIG-MODEL]: a config resolving a pep rule to `disabled` is -/// invalid — both commands fail with exit 2 and a stderr explanation, before -/// checking. -#[test] -fn pep_disable_is_a_config_error() { - let dir = unique_dir("pepdisable"); - std::fs::write( - dir.join("pyproject.toml"), - "[tool.basilisk.rules]\n\"imports_unresolved\" = \"disabled\"\n", - ) - .expect("write config"); - let py = dir.join("m.py"); - std::fs::write(&py, "x: int = 1\n").expect("write module"); - - for subcommand in ["check", "analyze"] { - let out = run(subcommand, &py); - assert_eq!( - out.status.code(), - Some(2), - "`{subcommand}` must exit 2 on a pep-disable config ([CHKARCH-CLI-EXITCODES])" - ); - let stderr = String::from_utf8_lossy(&out.stderr); - assert!( - stderr.contains("imports_unresolved"), - "`{subcommand}` must name the offending code on stderr, got: {stderr}" - ); - } -} - -/// A tag entry can also invalidly disable pep rules — `rule-tags."pep" = -/// "disabled"` resolves every pep rule to disabled and must exit 2. -#[test] -fn pep_tag_disable_is_a_config_error() { - let dir = unique_dir("peptagdisable"); - std::fs::write( - dir.join("pyproject.toml"), - "[tool.basilisk.rule-tags]\n\"pep\" = \"disabled\"\n", - ) - .expect("write config"); - let py = dir.join("m.py"); - std::fs::write(&py, "x: int = 1\n").expect("write module"); - - let out = run("check", &py); - assert_eq!( - out.status.code(), - Some(2), - "a pep tag-disable must exit 2 ([CHKARCH-CONFIG-MODEL])" - ); -} diff --git a/crates/basilisk-cli/tests/e2e_stub_paths_config.rs b/crates/basilisk-cli/tests/e2e_stub_paths_config.rs deleted file mode 100644 index b8c22a199..000000000 --- a/crates/basilisk-cli/tests/e2e_stub_paths_config.rs +++ /dev/null @@ -1,146 +0,0 @@ -//! Tests for [STUBRES-CONFIG]. See docs/specs/CHECKER-STUB-RESOLUTION-SPEC.md#STUBRES-CONFIG -#![allow( - clippy::allow_attributes, - clippy::expect_used, - clippy::unwrap_used, - clippy::panic -)] -//! Coarse end-to-end tests for `[tool.basilisk] stub-paths` (issue #173): a -//! local `.pyi` stub placed in a *named* directory (`stubs/`, `typings/`) — the -//! docs-recommended layout — must be discovered, not just stubs dumped in the -//! project root. The reported regression was that only a `stub-paths = ["."]` -//! entry worked; any subdirectory entry was silently ignored because the -//! `pyproject.toml` array was never parsed. - -use std::path::{Path, PathBuf}; -use std::process::{Command, Output}; -use std::sync::atomic::{AtomicU64, Ordering}; - -/// A throwaway directory unique to this process and call. -fn unique_dir(prefix: &str) -> PathBuf { - static CTR: AtomicU64 = AtomicU64::new(0); - let n = CTR.fetch_add(1, Ordering::Relaxed); - let dir = - std::env::temp_dir().join(format!("bsk_stubpaths_{prefix}_{}_{n}", std::process::id())); - std::fs::create_dir_all(&dir).expect("create temp dir"); - dir -} - -/// Run `basilisk check use.py` from inside `dir` with no ambient venv. -fn check_use_py(dir: &Path) -> Output { - Command::new(env!("CARGO_BIN_EXE_basilisk")) - .arg("check") - .arg("use.py") - .current_dir(dir) - .env_remove("VIRTUAL_ENV") - .output() - .expect("spawn basilisk") -} - -/// Lay down the issue #173 repro: a not-installed module whose only typing -/// information is a hand-written `.pyi` stub in `/`, with -/// `stub-paths = []` configured in `pyproject.toml`. -fn write_stub_project(dir: &Path, stub_dir: &str) { - std::fs::write( - dir.join("pyproject.toml"), - format!("[project]\nname = \"x\"\nversion = \"0.1.0\"\n\n[tool.basilisk]\nstub-paths = [\"{stub_dir}\"]\n"), - ) - .expect("write pyproject"); - std::fs::create_dir_all(dir.join(stub_dir)).expect("mkdir stub dir"); - std::fs::write( - dir.join(stub_dir).join("notinstalledpkg.pyi"), - "def thing() -> int: ...\n", - ) - .expect("write stub"); - std::fs::write( - dir.join("use.py"), - "from notinstalledpkg import thing\n\nx: int = thing()\n", - ) - .expect("write use.py"); -} - -/// Issue #173: a stub under `stubs/` with `stub-paths = ["stubs"]` must resolve -/// the import — no unresolved-import diagnostic, clean run. -#[test] -fn stub_paths_stubs_subdir_resolves_local_stub() { - let dir = unique_dir("stubs"); - write_stub_project(&dir, "stubs"); - - let output = check_use_py(&dir); - let stdout = String::from_utf8_lossy(&output.stdout); - - assert!( - !stdout.contains("imports_unresolved"), - "stub-paths = [\"stubs\"] must resolve notinstalledpkg from stubs/notinstalledpkg.pyi \ - (no unresolved-import diagnostic), got: {stdout}" - ); - assert!( - !stdout.contains("notinstalledpkg"), - "no diagnostic should mention the now-resolved module, got: {stdout}" - ); - assert_eq!( - output.status.code(), - Some(0), - "a resolved stub must yield a clean exit, got status {:?}, stderr: {}", - output.status, - String::from_utf8_lossy(&output.stderr) - ); -} - -/// Issue #173: the same must hold for the other docs-recommended directory -/// name, `typings/`. -#[test] -fn stub_paths_typings_subdir_resolves_local_stub() { - let dir = unique_dir("typings"); - write_stub_project(&dir, "typings"); - - let output = check_use_py(&dir); - let stdout = String::from_utf8_lossy(&output.stdout); - - assert!( - !stdout.contains("imports_unresolved"), - "stub-paths = [\"typings\"] must resolve notinstalledpkg from typings/notinstalledpkg.pyi, \ - got: {stdout}" - ); - assert_eq!( - output.status.code(), - Some(0), - "a resolved stub must yield a clean exit, got status {:?}, stderr: {}", - output.status, - String::from_utf8_lossy(&output.stderr) - ); -} - -/// Control: with NO `stub-paths` configured, the identical stub sitting in a -/// subdirectory is *not* on the search path, so the import stays unresolved. -/// This proves the two tests above pass because of `stub-paths`, not because -/// the `.pyi` is discovered through some unrelated path. -#[test] -fn without_stub_paths_subdir_stub_is_not_found() { - let dir = unique_dir("control"); - std::fs::write( - dir.join("pyproject.toml"), - "[project]\nname = \"x\"\nversion = \"0.1.0\"\n", - ) - .expect("write pyproject"); - std::fs::create_dir_all(dir.join("stubs")).expect("mkdir stubs"); - std::fs::write( - dir.join("stubs/notinstalledpkg.pyi"), - "def thing() -> int: ...\n", - ) - .expect("write stub"); - std::fs::write( - dir.join("use.py"), - "from notinstalledpkg import thing\n\nx: int = thing()\n", - ) - .expect("write use.py"); - - let output = check_use_py(&dir); - let stdout = String::from_utf8_lossy(&output.stdout); - - assert!( - stdout.contains("imports_unresolved"), - "without stub-paths the subdir stub must NOT be searched, so the import is unresolved, \ - got: {stdout}" - ); -} diff --git a/crates/basilisk-cli/tests/e2e_stub_resolution.rs b/crates/basilisk-cli/tests/e2e_stub_resolution.rs deleted file mode 100644 index d44d63fc1..000000000 --- a/crates/basilisk-cli/tests/e2e_stub_resolution.rs +++ /dev/null @@ -1,496 +0,0 @@ -//! Tests for [CHKARCH-CLI]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-CLI -#![allow( - clippy::allow_attributes, - clippy::indexing_slicing, - clippy::expect_used, - clippy::unwrap_used, - clippy::panic, - clippy::as_conversions, - unused_results, - dead_code -)] -//! E2E tests for `.pyi` stub resolution through the resolver and parser. - -mod common; - -use std::fs; -use std::path::Path; -use std::sync::atomic::{AtomicU64, Ordering}; -use std::sync::Arc; - -use basilisk_lsp::import_resolver::{ - has_stub_package, is_inline_typed_package, resolve_module, ActiveTypeshed, ImportSearchPaths, -}; -use basilisk_resolver::scope::ImportResolution; -use basilisk_stubs::types::{StubSource, StubTier, TypeProvenance}; -use basilisk_stubs::{parse_pyi_file, parse_pyi_source}; - -static TEST_CTR: AtomicU64 = AtomicU64::new(0); - -/// Generate a unique temp dir to avoid races between parallel tests. -fn unique_tmp(prefix: &str) -> std::path::PathBuf { - let ctr = TEST_CTR.fetch_add(1, Ordering::Relaxed); - std::env::temp_dir().join(format!("{prefix}_{ctr}_{}", std::process::id())) -} - -fn search_paths( - roots: Vec, - stub_paths: Vec, - site_packages: Option, -) -> ImportSearchPaths { - ImportSearchPaths { - roots, - extra_paths: vec![], - stub_paths, - workspace_members: vec![], - site_packages, - registry: None, - typeshed_snapshot: None, - } -} - -#[test] -fn resolver_prefers_pyi_stub_over_py_source() { - let dir = unique_tmp("e2e_stub_pyi_pref"); - fs::create_dir_all(&dir).unwrap(); - fs::write(dir.join("mymod.py"), "x = 1\n").unwrap(); - fs::write(dir.join("mymod.pyi"), "x: int\n").unwrap(); - - let paths = search_paths(vec![dir.clone()], vec![], None); - let result = resolve_module("mymod", &paths).expect("should resolve mymod"); - assert_eq!(result.resolution, ImportResolution::StubPyi); - assert!( - result.path.ends_with("mymod.pyi"), - "should prefer .pyi, got: {:?}", - result.path - ); - - let _ = fs::remove_dir_all(&dir); -} - -#[test] -fn user_stub_paths_take_priority_over_source() { - let root = unique_tmp("e2e_stub_user_root"); - let stubs = unique_tmp("e2e_stub_user_stubs"); - fs::create_dir_all(&root).unwrap(); - fs::create_dir_all(&stubs).unwrap(); - fs::write(root.join("mymod.py"), "x = 1\n").unwrap(); - fs::write(stubs.join("mymod.pyi"), "x: int\n").unwrap(); - - let paths = search_paths(vec![root.clone()], vec![stubs.clone()], None); - let result = resolve_module("mymod", &paths).expect("should resolve mymod"); - assert_eq!(result.resolution, ImportResolution::StubPyi); - assert!( - result.path.starts_with(&stubs), - "should come from user stubs dir, got: {:?}", - result.path - ); - - let _ = fs::remove_dir_all(&root); - let _ = fs::remove_dir_all(&stubs); -} - -#[test] -fn custom_typeshed_overrides_stdlib_and_parses() { - let ts = unique_tmp("e2e_typeshed"); - let stdlib = ts.join("stdlib"); - fs::create_dir_all(&stdlib).unwrap(); - // A MicroPython-flavoured `os` whose surface differs from CPython typeshed. - fs::write( - stdlib.join("os.pyi"), - "def uname() -> str: ...\ndef dupterm(stream: object) -> None: ...\n", - ) - .unwrap(); - - let config = basilisk_lsp::config::WorkspaceConfig { - typeshed_path: Some(ts.clone()), - ..Default::default() - }; - let request = basilisk_lsp::config::typeshed_request(&config).unwrap(); - let snapshot = basilisk_stubs::typeshed::runtime::production_manager(request) - .snapshot() - .unwrap(); - assert_eq!( - snapshot.status.active_source, - basilisk_stubs::typeshed::source::SourceKind::Custom - ); - - let paths = ImportSearchPaths { - roots: vec![], - extra_paths: vec![], - stub_paths: vec![], - workspace_members: vec![], - site_packages: None, - registry: None, - typeshed_snapshot: Some(ActiveTypeshed::new(Arc::clone(&snapshot), None)), - }; - assert!( - stdlib.join("os.pyi").is_file(), - "precondition: custom typeshed supplies os.pyi" - ); - assert!( - !stdlib.join("fractions.pyi").exists(), - "precondition: custom typeshed deliberately omits fractions.pyi" - ); - let result = resolve_module("os", &paths).expect("custom typeshed resolves `os`"); - assert_eq!(result.resolution, ImportResolution::StubPyi); - let logical_uri = result.path.to_string_lossy(); - assert!(logical_uri.starts_with("typeshed:custom-")); - let source = snapshot - .vfs - .read_uri(&logical_uri) - .expect("resolved URI belongs to active snapshot"); - - let module = parse_pyi_source( - source, - &result.path, - "os", - StubSource::CustomTypeshed, - StubTier::Tier1, - ) - .expect("parse custom os.pyi"); - assert_eq!(module.source, StubSource::CustomTypeshed); - assert_eq!(module.tier, StubTier::Tier1); - assert_eq!( - TypeProvenance::from((&module.source, &module.tier)), - TypeProvenance::StubCustomTypeshed - ); - assert!(module.functions.contains_key("uname")); - assert!( - module.functions.contains_key("dupterm"), - "custom MicroPython-only symbol must be visible after override" - ); - - assert!( - resolve_module("fractions", &paths).is_none(), - "stdlib modules absent from a custom typeshed must fall through unresolved" - ); - - fs::write( - stdlib.join("requests.pyi"), - "def get(url: str) -> bytes: ...\n", - ) - .unwrap(); - assert!( - resolve_module("requests", &paths).is_none(), - "the immutable snapshot must not observe later filesystem mutation" - ); - - let shadow = unique_tmp("e2e_typeshed_shadow_stubs"); - fs::create_dir_all(&shadow).unwrap(); - fs::write(shadow.join("os.pyi"), "def getcwd() -> str: ...\n").unwrap(); - let shadow_paths = ImportSearchPaths { - stub_paths: vec![shadow.clone()], - ..paths - }; - let shadowed = resolve_module("os", &shadow_paths).expect("stub-path os resolves"); - assert!( - shadowed.path.starts_with(&shadow), - "stub-paths must shadow custom typeshed, got: {:?}", - shadowed.path - ); - - let _ = fs::remove_dir_all(&ts); - let _ = fs::remove_dir_all(&shadow); -} - -#[test] -fn stub_package_resolved_before_inline_typed() { - let root = unique_tmp("e2e_stub_pep561_root"); - let sp = unique_tmp("e2e_stub_pep561_sp"); - fs::create_dir_all(&root).unwrap(); - - let stubs_dir = sp.join("requests-stubs"); - fs::create_dir_all(&stubs_dir).unwrap(); - fs::write( - stubs_dir.join("__init__.pyi"), - "def get(url: str) -> bytes: ...\n", - ) - .unwrap(); - - let inline_dir = sp.join("requests"); - fs::create_dir_all(&inline_dir).unwrap(); - fs::write(inline_dir.join("py.typed"), "").unwrap(); - fs::write(inline_dir.join("__init__.py"), "def get(url): pass\n").unwrap(); - - let paths = search_paths(vec![root.clone()], vec![], Some(sp.clone())); - let result = resolve_module("requests", &paths).expect("should resolve requests"); - assert_eq!(result.resolution, ImportResolution::StubPyi); - assert!( - result.path.to_string_lossy().contains("requests-stubs"), - "should come from stubs package, got: {:?}", - result.path - ); - - let _ = fs::remove_dir_all(&root); - let _ = fs::remove_dir_all(&sp); -} - -#[test] -fn autofix_stub_install_flips_source_resolution_to_stub() { - let sp = unique_tmp("e2e_stub_autofix_flip"); - - let pkg = sp.join("requests"); - fs::create_dir_all(&pkg).unwrap(); - fs::write(pkg.join("__init__.py"), "def get(url): pass\n").unwrap(); - - let paths = search_paths(vec![], vec![], Some(sp.clone())); - let before = resolve_module("requests", &paths).expect("should resolve requests"); - assert_eq!( - before.resolution, - ImportResolution::SourcePy, - "precondition: plain site-packages package resolves to SourcePy (E0152 fires)" - ); - - let stubs_dir = sp.join("requests-stubs"); - fs::create_dir_all(&stubs_dir).unwrap(); - fs::write( - stubs_dir.join("__init__.pyi"), - "def get(url: str) -> bytes: ...\n", - ) - .unwrap(); - - let after = resolve_module("requests", &paths).expect("should resolve requests"); - assert_eq!( - after.resolution, - ImportResolution::StubPyi, - "after `uv add --dev types-requests` the import must resolve to the stub \ - package so BSK-0152 clears, got: {:?} at {:?}", - after.resolution, - after.path - ); - - let _ = fs::remove_dir_all(&sp); -} - -#[test] -fn stub_package_submodule_resolution() { - let sp = unique_tmp("e2e_stub_pep561_sub"); - let stubs_dir = sp.join("requests-stubs"); - fs::create_dir_all(&stubs_dir).unwrap(); - fs::write(stubs_dir.join("__init__.pyi"), "").unwrap(); - fs::write( - stubs_dir.join("api.pyi"), - "def get(url: str) -> bytes: ...\n", - ) - .unwrap(); - - let paths = search_paths(vec![], vec![], Some(sp.clone())); - let result = resolve_module("requests.api", &paths).expect("should resolve requests.api"); - assert_eq!(result.resolution, ImportResolution::StubPyi); - assert!( - result.path.ends_with("api.pyi"), - "should resolve to api.pyi, got: {:?}", - result.path - ); - - let _ = fs::remove_dir_all(&sp); -} - -#[test] -fn py_typed_marker_detected() { - let sp = unique_tmp("e2e_stub_pytyped"); - let pkg = sp.join("rich"); - fs::create_dir_all(&pkg).unwrap(); - fs::write(pkg.join("py.typed"), "").unwrap(); - fs::write(pkg.join("__init__.py"), "").unwrap(); - - assert!(is_inline_typed_package("rich", &sp)); - assert!(is_inline_typed_package("rich.console", &sp)); - assert!(!is_inline_typed_package("flask", &sp)); - - let _ = fs::remove_dir_all(&sp); -} - -#[test] -fn has_stub_package_detected() { - let sp = unique_tmp("e2e_stub_has_stubs"); - let stubs = sp.join("requests-stubs"); - fs::create_dir_all(&stubs).unwrap(); - - assert!(has_stub_package("requests", &sp)); - assert!(has_stub_package("requests.api", &sp)); - assert!(!has_stub_package("flask", &sp)); - - let _ = fs::remove_dir_all(&sp); -} - -#[test] -fn parse_pyi_file_from_disk() { - let dir = unique_tmp("e2e_stub_parse_disk"); - fs::create_dir_all(&dir).unwrap(); - let pyi_path = dir.join("mymod.pyi"); - fs::write( - &pyi_path, - "def greet(name: str) -> str: ...\nVERSION: str\n", - ) - .unwrap(); - - let module = parse_pyi_file(&pyi_path, "mymod", StubSource::UserStub, StubTier::Tier1) - .expect("should parse .pyi from disk"); - assert!(module.functions.contains_key("greet")); - let greet = module.functions.get("greet").expect("greet should exist"); - assert_eq!(greet.return_type.as_deref(), Some("str")); - assert_eq!(greet.params.len(), 1); - - assert!(module.variables.contains_key("VERSION")); - let version = module - .variables - .get("VERSION") - .expect("VERSION should exist"); - assert_eq!(version.annotation.as_deref(), Some("str")); - - let _ = fs::remove_dir_all(&dir); -} - -#[test] -fn parse_pyi_source_with_overloads() { - let source = "\ -from typing import overload - -@overload -def process(x: int) -> int: ... -@overload -def process(x: str) -> str: ... -def process(x: int | str) -> int | str: ... - -class Parser: - @overload - def parse(self, data: str) -> str: ... - @overload - def parse(self, data: bytes) -> bytes: ... - def parse(self, data: str | bytes) -> str | bytes: ... -"; - let module = parse_pyi_source( - source, - Path::new("test.pyi"), - "test", - StubSource::UserStub, - StubTier::Tier1, - ) - .expect("should parse"); - - // Top-level overloads - assert!(module.overloads.contains_key("process")); - let overloads = module.overloads.get("process").expect("process overloads"); - assert_eq!(overloads.len(), 2); - assert!(module.functions.contains_key("process")); - let impl_fn = module.functions.get("process").expect("process impl"); - assert!(!impl_fn.is_overload); - - // Class method overloads - assert!(module.classes.contains_key("Parser")); - let parser = module.classes.get("Parser").expect("Parser class"); - assert_eq!(parser.methods.len(), 3); - assert!(module.overloads.contains_key("Parser.parse")); - let class_overloads = module - .overloads - .get("Parser.parse") - .expect("Parser.parse overloads"); - assert_eq!(class_overloads.len(), 2); -} - -#[test] -fn parse_pyi_class_with_bases_and_attributes() { - let source = "\ -class Animal: - name: str - age: int - def speak(self) -> str: ... - -class Dog(Animal): - breed: str - def fetch(self, item: str) -> bool: ... -"; - let module = parse_pyi_source( - source, - Path::new("animals.pyi"), - "animals", - StubSource::UserStub, - StubTier::Tier1, - ) - .expect("should parse"); - - let animal = module.classes.get("Animal").expect("Animal class"); - assert_eq!(animal.attributes.len(), 2); - assert_eq!(animal.methods.len(), 1); - let speak = animal.methods.first().expect("speak method"); - assert_eq!(speak.name, "speak"); - assert!(speak.params.is_empty(), "self should be stripped"); - - let dog = module.classes.get("Dog").expect("Dog class"); - assert_eq!(dog.bases, vec!["Animal"]); - assert_eq!(dog.attributes.len(), 1); - assert_eq!(dog.methods.len(), 1); -} - -#[test] -fn full_roundtrip_resolve_then_parse_stub() { - let root = unique_tmp("e2e_stub_roundtrip"); - let stubs = unique_tmp("e2e_stub_roundtrip_stubs"); - fs::create_dir_all(&root).unwrap(); - fs::create_dir_all(&stubs).unwrap(); - - // Source file (no type annotations) - fs::write(root.join("mylib.py"), "def compute(x): return x + 1\n").unwrap(); - // Stub file (with full annotations) - fs::write(stubs.join("mylib.pyi"), "def compute(x: int) -> int: ...\n").unwrap(); - - // Step 1: Resolve — stub should win - let paths = search_paths(vec![root.clone()], vec![stubs.clone()], None); - let resolved = resolve_module("mylib", &paths).expect("should resolve mylib"); - assert_eq!(resolved.resolution, ImportResolution::StubPyi); - assert!(resolved.path.ends_with("mylib.pyi")); - - // Step 2: Parse the resolved stub - let module = parse_pyi_file( - &resolved.path, - "mylib", - StubSource::UserStub, - StubTier::Tier1, - ) - .expect("should parse resolved stub"); - - let compute = module.functions.get("compute").expect("compute function"); - assert_eq!(compute.return_type.as_deref(), Some("int")); - assert_eq!(compute.params.len(), 1); - let param = compute.params.first().expect("should have param"); - assert_eq!(param.name, "x"); - assert_eq!(param.annotation.as_deref(), Some("int")); - - let _ = fs::remove_dir_all(&root); - let _ = fs::remove_dir_all(&stubs); -} - -#[test] -fn full_roundtrip_stub_package_resolve_then_parse() { - let sp = unique_tmp("e2e_stub_roundtrip_sp"); - let stubs_dir = sp.join("mylib-stubs"); - fs::create_dir_all(&stubs_dir).unwrap(); - fs::write( - stubs_dir.join("__init__.pyi"), - "VERSION: str\ndef init(config: dict[str, str]) -> None: ...\n", - ) - .unwrap(); - - // Step 1: Resolve via stub package - let paths = search_paths(vec![], vec![], Some(sp.clone())); - let resolved = resolve_module("mylib", &paths).expect("should resolve mylib"); - assert_eq!(resolved.resolution, ImportResolution::StubPyi); - - // Step 2: Parse - let module = parse_pyi_file( - &resolved.path, - "mylib", - StubSource::StubPackage, - StubTier::Tier1, - ) - .expect("should parse stub package"); - - assert!(module.variables.contains_key("VERSION")); - assert!(module.functions.contains_key("init")); - let init = module.functions.get("init").expect("init function"); - assert_eq!(init.return_type.as_deref(), Some("None")); - - let _ = fs::remove_dir_all(&sp); -} diff --git a/crates/basilisk-cli/tests/e2e_typeshed_config_validation.rs b/crates/basilisk-cli/tests/e2e_typeshed_config_validation.rs deleted file mode 100644 index f978aa286..000000000 --- a/crates/basilisk-cli/tests/e2e_typeshed_config_validation.rs +++ /dev/null @@ -1,341 +0,0 @@ -//! End-to-end tests for typeshed configuration validation -//! ([STUBRES-TYPESHED-CONFIG]). -//! See docs/specs/CHECKER-STUB-RESOLUTION-SPEC.md#STUBRES-TYPESHED-CONFIG -//! -//! The contract under test, exactly as a user experiences it through the -//! real binary: every invalid typeshed setting FAILS CLOSED. The CLI exits -//! 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-OFFLINE]). -#![allow( - clippy::allow_attributes, - clippy::expect_used, - clippy::unwrap_used, - 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::{ - CacheConfigUpdate, ConfigurationUpdate, RuleConfigUpdate, TypeshedConfigKey, - TypeshedConfigUpdate, -}; - -fn unique_dir(prefix: &str) -> PathBuf { - static CTR: AtomicU64 = AtomicU64::new(0); - let n = CTR.fetch_add(1, Ordering::Relaxed); - let dir = std::env::temp_dir().join(format!( - "bsk_typeshed_validation_{prefix}_{}_{n}", - std::process::id() - )); - std::fs::create_dir_all(&dir).expect("create temp dir"); - dir -} - -/// Write a one-file project whose only variable is the `[tool.basilisk]` -/// typeshed table, then run `basilisk check app.py` in it. -fn check_with_config(dir: &Path, basilisk_table: &str) -> Output { - std::fs::write( - dir.join("pyproject.toml"), - format!( - "[project]\nname = \"x\"\nversion = \"0.1.0\"\n\n[tool.basilisk]\n{basilisk_table}" - ), - ) - .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") - .current_dir(dir) - .env_remove("VIRTUAL_ENV") - .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::>(), - }, - cache: CacheConfigUpdate::default(), - } -} - -/// 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) { - let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); - - assert_eq!( - output.status.code(), - Some(2), - "an invalid typeshed setting is a configuration error (exit 2), not a diagnostics failure, stdout: {stdout}, stderr: {stderr}" - ); - assert!( - stderr.contains(expected_reason), - "stderr must carry the user-facing reason `{expected_reason}`, stderr: {stderr}" - ); - assert!( - !stdout.contains("error["), - "a config error must produce no diagnostics — the check never ran, stdout: {stdout}" - ); -} - -#[test] -fn short_typeshed_commit_fails_closed() { - let dir = unique_dir("short_commit"); - let output = check_with_config(&dir, "typeshed-commit = \"abc123\"\n"); - assert_fails_closed( - &output, - "typeshed-commit must be a full 40-character hex SHA", - ); - let _ = std::fs::remove_dir_all(&dir); -} - -#[test] -fn full_length_non_hex_typeshed_commit_fails_closed() { - let dir = unique_dir("non_hex_commit"); - let output = check_with_config( - &dir, - "typeshed-commit = \"zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz\"\n", - ); - assert_fails_closed( - &output, - "typeshed-commit must be a full 40-character hex SHA", - ); - 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 a_valid_missing_pin_tanks_hard_and_never_downloads() { - let dir = unique_dir("missing_pin"); - let pin = "0123456789012345678901234567890123456789"; - let output = check_with_config( - &dir, - &format!("typeshed-commit = \"{pin}\"\ntypeshed-store-path = \"store\"\n"), - ); - 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 retired_download_policy_keys_are_inert_and_never_echoed() { - let dir = unique_dir("retired_keys"); - let output = check_with_config( - &dir, - "typeshed-url = \"http://internal-host.corp.example/secret-{sha}.zip\"\ntypeshed-cache = false\ntypeshed-verify = false\ntypeshed-cache-path = \"secret-cache\"\n", - ); - 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 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" - ); - - 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); -} - -/// The one reason every mutually-exclusive source pairing must report. All -/// three step-3 sources are named so the user can see which keys compete -/// ([STUBRES-TYPESHED-CONFIG]). -const EXCLUSION_REASON: &str = - "typeshed-path, typeshed-commit, and typeshed-package are mutually exclusive"; - -/// A well-formed package pin, pinned by wheel SHA-256 ([STUBRES-TYPESHED-PYPI]). -const PACKAGE_PIN: &str = - "micropython-stdlib-stubs@sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; - -/// Every pairing of the three mutually-exclusive step-3 sources fails closed -/// with the same reason — no pair may slip through as a silently-preferred -/// source ([STUBRES-TYPESHED-CONFIG], [STUBRES-TYPESHED-PYPI]). -#[test] -fn every_pair_of_typeshed_sources_fails_closed() { - let commit = "0123456789012345678901234567890123456789"; - let cases = [ - ( - "path_commit_conflict", - format!("typeshed-path = \"ts\"\ntypeshed-commit = \"{commit}\"\n"), - ), - ( - "path_package_conflict", - format!("typeshed-path = \"ts\"\ntypeshed-package = \"{PACKAGE_PIN}\"\n"), - ), - ( - "commit_package_conflict", - format!("typeshed-commit = \"{commit}\"\ntypeshed-package = \"{PACKAGE_PIN}\"\n"), - ), - ( - "all_three_conflict", - format!( - "typeshed-path = \"ts\"\ntypeshed-commit = \"{commit}\"\ntypeshed-package = \"{PACKAGE_PIN}\"\n" - ), - ), - ]; - for (label, config) in cases { - let dir = unique_dir(label); - std::fs::create_dir_all(dir.join("ts/stdlib")).expect("create custom tree"); - let output = check_with_config(&dir, &config); - assert_fails_closed(&output, EXCLUSION_REASON); - 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 valid_full_sha_clears_validation_and_fails_only_on_resolution() { - let dir = unique_dir("valid_pin_shape"); - let output = check_with_config( - &dir, - "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 must clear validation — any failure past this point is resolution, not configuration, stderr: {stderr}" - ); - assert_ne!( - output.status.code(), - Some(0), - "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_config.rs b/crates/basilisk-cli/tests/e2e_typeshed_path_config.rs deleted file mode 100644 index 474c1f977..000000000 --- a/crates/basilisk-cli/tests/e2e_typeshed_path_config.rs +++ /dev/null @@ -1,76 +0,0 @@ -//! Tests for [STUBRES-CUSTOM-TYPESHED]. -//! See docs/specs/CHECKER-STUB-RESOLUTION-SPEC.md#STUBRES-CUSTOM-TYPESHED -#![allow( - clippy::allow_attributes, - clippy::expect_used, - clippy::unwrap_used, - clippy::panic -)] - -use std::path::{Path, PathBuf}; -use std::process::{Command, Output}; -use std::sync::atomic::{AtomicU64, Ordering}; - -fn unique_dir(prefix: &str) -> PathBuf { - static CTR: AtomicU64 = AtomicU64::new(0); - let n = CTR.fetch_add(1, Ordering::Relaxed); - let dir = std::env::temp_dir().join(format!( - "bsk_typeshed_path_{prefix}_{}_{n}", - std::process::id() - )); - std::fs::create_dir_all(&dir).expect("create temp dir"); - dir -} - -fn check_app(dir: &Path) -> Output { - Command::new(env!("CARGO_BIN_EXE_basilisk")) - .arg("check") - .arg("app.py") - .current_dir(dir) - .env_remove("VIRTUAL_ENV") - .output() - .expect("spawn basilisk") -} - -#[test] -fn cli_uses_typeshed_path_and_absent_stdlib_falls_through() { - let dir = unique_dir("fake_stdlib"); - let typeshed = dir.join("fake-typeshed"); - let stdlib = typeshed.join("stdlib"); - std::fs::create_dir_all(&stdlib).expect("create fake stdlib"); - std::fs::write(stdlib.join("os.pyi"), "def uname() -> str: ...\n").expect("write fake os stub"); - std::fs::write( - dir.join("pyproject.toml"), - "[project]\nname = \"x\"\nversion = \"0.1.0\"\n\n[tool.basilisk]\ntypeshed-path = \"fake-typeshed\"\n", - ) - .expect("write pyproject"); - std::fs::write( - dir.join("app.py"), - "from os import uname\nfrom fractions import Fraction\n\nsystem_name: str = uname()\nmissing = Fraction(1, 2)\n", - ) - .expect("write app"); - - let output = check_app(&dir); - let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); - - assert!( - stdout.contains("imports_unresolved"), - "absent stdlib module must fall through to imports_unresolved, stdout: {stdout}, stderr: {stderr}" - ); - assert!( - stdout.contains("fractions"), - "diagnostic must name the stdlib module absent from custom typeshed, stdout: {stdout}" - ); - assert!( - !stdout.contains("`os`") && !stdout.contains("`uname`"), - "custom typeshed os.pyi must resolve the uname import without diagnostics, stdout: {stdout}" - ); - assert_ne!( - output.status.code(), - Some(0), - "absent stdlib module must fail the CLI check" - ); - - 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 deleted file mode 100644 index 142e87458..000000000 --- a/crates/basilisk-cli/tests/e2e_typeshed_path_heavy.rs +++ /dev/null @@ -1,377 +0,0 @@ -//! Heavy end-to-end coverage for the custom-typeshed (`typeshed-path`) feature — -//! GitHub #271, spec [STUBRES-CUSTOM-TYPESHED]. -//! See docs/specs/CHECKER-STUB-RESOLUTION-SPEC.md#STUBRES-CUSTOM-TYPESHED -//! -//! These tests drive the REAL `basilisk` binary the way a user would, with many -//! sequential interactions per test (edit config, edit the typeshed on disk, -//! re-run `check`) and many assertions per interaction (exit code, every -//! diagnostic that must appear, and every diagnostic that must NOT). They pin the -//! load-bearing consequence of a custom typeshed being *canonical for stdlib -//! resolution* (typing-spec import-resolution step 3): a stdlib module absent -//! from the configured typeshed is surfaced as unresolved instead of being -//! mixed with the bundled snapshot. -#![allow( - clippy::allow_attributes, - clippy::expect_used, - clippy::unwrap_used, - clippy::panic -)] - -use std::path::{Path, PathBuf}; -use std::process::{Command, Output}; -use std::sync::atomic::{AtomicU64, Ordering}; - -/// A unique temp directory per call so parallel tests never collide. -fn unique_dir(prefix: &str) -> PathBuf { - static CTR: AtomicU64 = AtomicU64::new(0); - let n = CTR.fetch_add(1, Ordering::Relaxed); - let dir = - std::env::temp_dir().join(format!("bsk_ts_heavy_{prefix}_{}_{n}", std::process::id())); - std::fs::create_dir_all(&dir).expect("create temp dir"); - dir -} - -fn write(dir: &Path, rel: &str, contents: &str) { - let path = dir.join(rel); - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent).expect("create parent dir"); - } - std::fs::write(path, contents).expect("write file"); -} - -/// Create a `//stdlib/` tree seeded with the given `.pyi` files. -fn seed_typeshed(dir: &Path, typeshed: &str, stubs: &[(&str, &str)]) { - let stdlib = dir.join(typeshed).join("stdlib"); - std::fs::create_dir_all(&stdlib).expect("create stdlib dir"); - for (name, body) in stubs { - std::fs::write(stdlib.join(name), body).expect("write stub"); - } -} - -/// Run `basilisk check app.py` in `dir` with the ambient venv scrubbed so the -/// result depends only on the on-disk config + typeshed. -fn check(dir: &Path) -> Output { - Command::new(env!("CARGO_BIN_EXE_basilisk")) - .arg("check") - .arg("app.py") - .current_dir(dir) - .env_remove("VIRTUAL_ENV") - .output() - .expect("spawn basilisk") -} - -fn stdout_of(output: &Output) -> String { - String::from_utf8_lossy(&output.stdout).into_owned() -} - -/// Assert the run is clean: exit 0 and the "no issues" banner, with none of the -/// forbidden markers present. -#[track_caller] -fn assert_clean(output: &Output, forbidden: &[&str]) { - let out = stdout_of(output); - assert_eq!( - output.status.code(), - Some(0), - "expected a clean exit, got {:?}; stdout: {out}", - output.status.code() - ); - assert!( - out.contains("No issues found"), - "expected the no-issues banner; stdout: {out}" - ); - assert!( - !out.contains("imports_unresolved"), - "clean run must emit no imports_unresolved; stdout: {out}" - ); - for marker in forbidden { - assert!( - !out.contains(marker), - "clean run must not mention `{marker}`; stdout: {out}" - ); - } -} - -/// Assert the run flags `module` as unresolved: non-zero exit, the -/// `imports_unresolved` code, the module name, and none of the `resolved` -/// modules named. -#[track_caller] -fn assert_flags_unresolved(output: &Output, module: &str, resolved: &[&str]) { - let out = stdout_of(output); - assert_ne!( - output.status.code(), - Some(0), - "expected a non-zero exit because `{module}` is unresolved; stdout: {out}" - ); - assert!( - out.contains("imports_unresolved"), - "expected the imports_unresolved diagnostic code; stdout: {out}" - ); - assert!( - out.contains(module), - "diagnostic must name the unresolved module `{module}`; stdout: {out}" - ); - for ok in resolved { - assert!( - !out.contains(&format!("`{ok}`")), - "module `{ok}` resolves from the custom typeshed and must NOT be \ - flagged; stdout: {out}" - ); - } -} - -const APP_OS_AND_FRACTIONS: &str = - "from os import uname\nfrom fractions import Fraction\n\nname: str = uname()\nvalue = Fraction(1, 2)\n"; - -/// The full custom-typeshed lifecycle through `pyproject.toml`, exercised as one -/// continuous user session: five `check` interactions as the config and the -/// typeshed contents change on disk. Pins that canonicality is evaluated LIVE on -/// every run — not cached from a previous state. -#[test] -fn typeshed_path_full_lifecycle_through_pyproject() { - let dir = unique_dir("lifecycle"); - 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 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", - "[project]\nname = \"x\"\nversion = \"0.1.0\"\n", - ); - assert_clean(&check(&dir), &["imports_unresolved"]); - - // ── Interaction 2: add typeshed-path. The custom typeshed is now canonical - // for step 3: `os` resolves from its stdlib/, but `fractions` — absent from - // it — cannot be mixed in from the bundled snapshot and surfaces unresolved. ── - write( - &dir, - "pyproject.toml", - "[project]\nname = \"x\"\nversion = \"0.1.0\"\n\n[tool.basilisk]\ntypeshed-path = \"ts\"\n", - ); - let out2 = check(&dir); - assert_flags_unresolved(&out2, "fractions", &["os", "uname"]); - let out2_text = stdout_of(&out2); - assert_eq!( - // Count the bracketed diagnostic HEADER, emitted exactly once per - // diagnostic. The bare code `imports_unresolved` also appears in the - // `see:` docs URL, so a raw substring count would double-report. - out2_text.matches("error[imports_unresolved]").count(), - 1, - "exactly one import (`fractions`) must be unresolved; stdout: {out2_text}" - ); - assert!( - out2_text.contains("Found 1 diagnostic (1 error)."), - "the summary must report exactly one diagnostic; stdout: {out2_text}" - ); - - // ── Interaction 3: supply `fractions.pyi` in the custom typeshed → clean. ── - seed_typeshed( - &dir, - "ts", - &[( - "fractions.pyi", - "class Fraction:\n def __init__(self, a: int, b: int) -> None: ...\n", - )], - ); - assert_clean(&check(&dir), &["fractions", "imports_unresolved"]); - - // ── Interaction 4: delete `fractions.pyi` again → it must flip back to - // unresolved on the very next run (canonicality is live, not stale-cached). ── - std::fs::remove_file(dir.join("ts").join("stdlib").join("fractions.pyi")) - .expect("remove fractions stub"); - assert_flags_unresolved(&check(&dir), "fractions", &["os", "uname"]); - - // ── 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", - "[project]\nname = \"x\"\nversion = \"0.1.0\"\n", - ); - assert_clean(&check(&dir), &["imports_unresolved"]); - - let _ = std::fs::remove_dir_all(&dir); -} - -/// `pyproject.toml [tool.basilisk]` is the ONLY config source: a stray -/// `basilisk.json` sitting next to it is NEVER read. The stray file points at a -/// typeshed that would flip the outcome (it ships `fractions.pyi`, so honoring -/// it would make the project clean); the run must instead match the -/// pyproject-only baseline byte for byte — proving the JSON file has NO effect. -#[test] -fn stray_basilisk_json_is_ignored() { - let run = |dir: &Path| -> Output { - seed_typeshed(dir, "ts", &[("os.pyi", "def uname() -> str: ...\n")]); - write(dir, "app.py", APP_OS_AND_FRACTIONS); - check(dir) - }; - - // Baseline: pyproject.toml only, kebab-case `typeshed-path`. - let baseline_dir = unique_dir("stray_json_baseline"); - write( - &baseline_dir, - "pyproject.toml", - "[project]\nname = \"x\"\nversion = \"0.1.0\"\n\n[tool.basilisk]\ntypeshed-path = \"ts\"\n", - ); - let baseline_out = run(&baseline_dir); - - // Same project PLUS a stray `basilisk.json` whose camelCase `typeshedPath` - // points at a DIFFERENT typeshed that also ships `fractions.pyi`. If the - // JSON were read (it used to take priority over pyproject), `fractions` - // would resolve and the run would be clean — a visible behaviour flip. - let stray_dir = unique_dir("stray_json_present"); - seed_typeshed( - &stray_dir, - "wrong_ts", - &[ - ("os.pyi", "def uname() -> str: ...\n"), - ( - "fractions.pyi", - "class Fraction:\n def __init__(self, a: int, b: int) -> None: ...\n", - ), - ], - ); - write( - &stray_dir, - "basilisk.json", - "{ \"typeshedPath\": \"wrong_ts\" }\n", - ); - write( - &stray_dir, - "pyproject.toml", - "[project]\nname = \"x\"\nversion = \"0.1.0\"\n\n[tool.basilisk]\ntypeshed-path = \"ts\"\n", - ); - let stray_out = run(&stray_dir); - - // Both must flag `fractions` (absent from the REAL typeshed) while - // resolving `os` — the stray JSON's fractions-bearing typeshed is ignored. - assert_flags_unresolved(&baseline_out, "fractions", &["os", "uname"]); - assert_flags_unresolved(&stray_out, "fractions", &["os", "uname"]); - - // …and the stray file must have NO effect at all: exit code and diagnostic - // body match the baseline exactly (temp-dir path prefixes are the only - // permitted difference, and app.py is relative so the diagnostic text - // itself matches). - assert_eq!( - baseline_out.status.code(), - stray_out.status.code(), - "a stray basilisk.json must not change the exit code" - ); - assert_eq!( - stdout_of(&baseline_out), - stdout_of(&stray_out), - "a stray basilisk.json must not change the diagnostics" - ); - - let _ = std::fs::remove_dir_all(&baseline_dir); - let _ = std::fs::remove_dir_all(&stray_dir); -} - -/// `stub-paths` (import-resolution step 1) is consulted BEFORE the custom -/// typeshed (step 3), so a user stub shadows a stdlib module even when a custom -/// typeshed is canonical — and a stdlib module absent from the typeshed but -/// present in `stub-paths` still resolves. -#[test] -fn stub_paths_shadow_custom_typeshed_end_to_end() { - let dir = unique_dir("shadow"); - // Custom typeshed ships `os` only; `fractions` is deliberately absent. - seed_typeshed(&dir, "ts", &[("os.pyi", "def uname() -> str: ...\n")]); - // A user stub dir supplies `fractions` (the module the typeshed lacks). - write( - &dir, - "mystubs/fractions.pyi", - "class Fraction:\n def __init__(self, a: int, b: int) -> None: ...\n", - ); - write( - &dir, - "pyproject.toml", - "[project]\nname = \"x\"\nversion = \"0.1.0\"\n\n[tool.basilisk]\ntypeshed-path = \"ts\"\nstub-paths = [\"mystubs\"]\n", - ); - write(&dir, "app.py", APP_OS_AND_FRACTIONS); - - // `os` resolves from the custom typeshed, `fractions` from stub-paths → - // the whole project is clean despite `fractions` being absent from typeshed. - assert_clean(&check(&dir), &["imports_unresolved", "fractions"]); - - let _ = std::fs::remove_dir_all(&dir); -} - -/// A configured custom typeshed governs ONLY the standard library. Third-party -/// imports are unaffected: they resolve (or fail) exactly as they would without -/// any `typeshed-path`. -#[test] -fn typeshed_path_leaves_third_party_imports_untouched() { - let dir = unique_dir("thirdparty"); - seed_typeshed(&dir, "ts", &[("os.pyi", "def uname() -> str: ...\n")]); - write( - &dir, - "pyproject.toml", - "[project]\nname = \"x\"\nversion = \"0.1.0\"\n\n[tool.basilisk]\ntypeshed-path = \"ts\"\n", - ); - // `os` resolves from the custom typeshed; the third-party import must - // still be flagged. The package name is deliberately one that can never - // be installed — a real name (e.g. `requests`) resolves from the - // developer's global site-packages and makes the test machine-dependent. - write( - &dir, - "app.py", - "import bsk_test_missing_thirdparty_pkg\nfrom os import uname\n\nname: str = uname()\n", - ); - - let out = check(&dir); - assert_flags_unresolved(&out, "bsk_test_missing_thirdparty_pkg", &["os", "uname"]); - // The stdlib resolution must not leak into the third-party diagnostic. - assert!( - !stdout_of(&out).contains("fractions"), - "unrelated stdlib names must not appear; stdout: {}", - stdout_of(&out) - ); - - let _ = std::fs::remove_dir_all(&dir); -} - -/// A relative `typeshed-path` (resolved against the project root) and the -/// equivalent absolute path must behave identically. -#[test] -fn relative_and_absolute_typeshed_path_are_equivalent() { - // Relative form. - let rel_dir = unique_dir("rel"); - seed_typeshed(&rel_dir, "ts", &[("os.pyi", "def uname() -> str: ...\n")]); - write(&rel_dir, "app.py", APP_OS_AND_FRACTIONS); - write( - &rel_dir, - "pyproject.toml", - "[project]\nname = \"x\"\nversion = \"0.1.0\"\n\n[tool.basilisk]\ntypeshed-path = \"ts\"\n", - ); - let rel_out = check(&rel_dir); - - // Absolute form: point at the SAME on-disk typeshed by absolute path. - let abs_dir = unique_dir("abs"); - seed_typeshed(&abs_dir, "ts", &[("os.pyi", "def uname() -> str: ...\n")]); - write(&abs_dir, "app.py", APP_OS_AND_FRACTIONS); - let abs_typeshed = abs_dir.join("ts"); - write( - &abs_dir, - "pyproject.toml", - &format!( - "[project]\nname = \"x\"\nversion = \"0.1.0\"\n\n[tool.basilisk]\ntypeshed-path = \"{}\"\n", - abs_typeshed.display() - ), - ); - let abs_out = check(&abs_dir); - - // Both resolve `os` and flag `fractions`, with the same exit code. - assert_flags_unresolved(&rel_out, "fractions", &["os", "uname"]); - assert_flags_unresolved(&abs_out, "fractions", &["os", "uname"]); - assert_eq!( - rel_out.status.code(), - abs_out.status.code(), - "relative and absolute typeshed-path must share an exit code" - ); - - let _ = std::fs::remove_dir_all(&rel_dir); - let _ = std::fs::remove_dir_all(&abs_dir); -} diff --git a/crates/basilisk-cli/tests/fixtures/all_annotated.py b/crates/basilisk-cli/tests/fixtures/all_annotated.py deleted file mode 100644 index c7232d0cb..000000000 --- a/crates/basilisk-cli/tests/fixtures/all_annotated.py +++ /dev/null @@ -1,14 +0,0 @@ -from __future__ import annotations - - -def greet(name: str, count: int) -> str: - return name * count - - -def add(a: float, b: float) -> float: - return a + b - - -class Calculator: - def multiply(self, x: int, y: int) -> int: - return x * y diff --git a/crates/basilisk-cli/tests/fixtures/clean/fully_typed_module.py b/crates/basilisk-cli/tests/fixtures/clean/fully_typed_module.py deleted file mode 100644 index 99508bf83..000000000 --- a/crates/basilisk-cli/tests/fixtures/clean/fully_typed_module.py +++ /dev/null @@ -1,25 +0,0 @@ -from __future__ import annotations - - -def add(a: int, b: int) -> int: - return a + b - - -def greet(name: str) -> str: - return f"Hello, {name}" - - -def identity(value: float) -> float: - return value - - -class Point: - def __init__(self, x: float, y: float) -> None: - self.x = x - self.y = y - - def distance(self) -> float: - return (self.x ** 2 + self.y ** 2) ** 0.5 - - def scale(self, factor: float) -> Point: - return Point(self.x * factor, self.y * factor) diff --git a/crates/basilisk-cli/tests/fixtures/clean/nested_functions.py b/crates/basilisk-cli/tests/fixtures/clean/nested_functions.py deleted file mode 100644 index 83f8aa7ff..000000000 --- a/crates/basilisk-cli/tests/fixtures/clean/nested_functions.py +++ /dev/null @@ -1,15 +0,0 @@ -def outer(x: int) -> int: - def inner(y: int) -> int: - return x + y - - return inner(1) - - -def deep(a: str) -> str: - def middle(b: str) -> str: - def innermost(c: str) -> str: - return a + b + c - - return innermost("z") - - return middle("y") diff --git a/crates/basilisk-cli/tests/fixtures/clean/typed_any_justified.py b/crates/basilisk-cli/tests/fixtures/clean/typed_any_justified.py deleted file mode 100644 index 48cde5cd2..000000000 --- a/crates/basilisk-cli/tests/fixtures/clean/typed_any_justified.py +++ /dev/null @@ -1,9 +0,0 @@ -from __future__ import annotations - - -def stringify(value: object) -> str: - return str(value) - - -def identity(value: int) -> int: - return value diff --git a/crates/basilisk-cli/tests/fixtures/clean/typed_class_attrs.py b/crates/basilisk-cli/tests/fixtures/clean/typed_class_attrs.py deleted file mode 100644 index 7c25ec24d..000000000 --- a/crates/basilisk-cli/tests/fixtures/clean/typed_class_attrs.py +++ /dev/null @@ -1,14 +0,0 @@ -from __future__ import annotations - - -class Config: - # Annotations without defaults: pure type declarations, no W0050 - host: str - port: int - debug: bool - - -class Point: - # Annotations without defaults: no W0050 - x: float - y: float diff --git a/crates/basilisk-cli/tests/fixtures/clean/typed_control_flow.py b/crates/basilisk-cli/tests/fixtures/clean/typed_control_flow.py deleted file mode 100644 index 18c2b431f..000000000 --- a/crates/basilisk-cli/tests/fixtures/clean/typed_control_flow.py +++ /dev/null @@ -1,30 +0,0 @@ -from __future__ import annotations - - -def classify(n: int) -> str: - if n < 0: - def negative() -> str: - return "negative" - return negative() - elif n == 0: - return "zero" - else: - return "positive" - - -def first_even(numbers: list[int]) -> int: - for n in numbers: - def is_even(x: int) -> bool: - return x % 2 == 0 - if is_even(n): - return n - return -1 - - -def safe_divide(a: float, b: float) -> float: - try: - def do_divide(x: float, y: float) -> float: - return x / y - return do_divide(a, b) - except ZeroDivisionError: - return 0.0 diff --git a/crates/basilisk-cli/tests/fixtures/clean/typed_dataclass_style.py b/crates/basilisk-cli/tests/fixtures/clean/typed_dataclass_style.py deleted file mode 100644 index 3ebf7c7b0..000000000 --- a/crates/basilisk-cli/tests/fixtures/clean/typed_dataclass_style.py +++ /dev/null @@ -1,29 +0,0 @@ -from __future__ import annotations - - -class Config: - def __init__(self, host: str, port: int, debug: bool) -> None: - self.host = host - self.port = port - self.debug = debug - - def url(self) -> str: - return f"http://{self.host}:{self.port}" - - def with_debug(self, enabled: bool) -> Config: - return Config(self.host, self.port, enabled) - - -class Rect: - def __init__(self, width: float, height: float) -> None: - self.width = width - self.height = height - - def area(self) -> float: - return self.width * self.height - - def perimeter(self) -> float: - return 2 * (self.width + self.height) - - def scale(self, factor: float) -> Rect: - return Rect(self.width * factor, self.height * factor) diff --git a/crates/basilisk-cli/tests/fixtures/clean/typed_generics.py b/crates/basilisk-cli/tests/fixtures/clean/typed_generics.py deleted file mode 100644 index f03e229d9..000000000 --- a/crates/basilisk-cli/tests/fixtures/clean/typed_generics.py +++ /dev/null @@ -1,19 +0,0 @@ -from __future__ import annotations - -from typing import Optional - - -def first(items: list[int]) -> Optional[int]: - return items[0] if items else None - - -def zip_lists(a: list[str], b: list[int]) -> list[tuple[str, int]]: - return list(zip(a, b)) - - -def flatten(matrix: list[list[float]]) -> list[float]: - return [x for row in matrix for x in row] - - -def lookup(table: dict[str, int], key: str) -> Optional[int]: - return table.get(key) diff --git a/crates/basilisk-cli/tests/fixtures/clean/typed_inheritance.py b/crates/basilisk-cli/tests/fixtures/clean/typed_inheritance.py deleted file mode 100644 index 40629779e..000000000 --- a/crates/basilisk-cli/tests/fixtures/clean/typed_inheritance.py +++ /dev/null @@ -1,29 +0,0 @@ -from __future__ import annotations - -from typing import override - - -class Animal: - def speak(self) -> str: - return "" - - def name(self) -> str: - return "animal" - - -class Dog(Animal): - @override - def speak(self) -> str: - return "woof" - - def fetch(self, item: str) -> str: - return f"fetched {item}" - - -class Cat(Animal): - @override - def speak(self) -> str: - return "meow" - - def purr(self, duration: float) -> None: - pass diff --git a/crates/basilisk-cli/tests/fixtures/clean/typed_match.py b/crates/basilisk-cli/tests/fixtures/clean/typed_match.py deleted file mode 100644 index d32119f68..000000000 --- a/crates/basilisk-cli/tests/fixtures/clean/typed_match.py +++ /dev/null @@ -1,19 +0,0 @@ -from __future__ import annotations - - -def classify(status: int) -> str: - match status: - case 200: - return "ok" - case 404: - return "not found" - case _: - return "unknown" - - -def describe(value: str) -> str: - match value: - case "a" | "e" | "i" | "o" | "u": - return "vowel" - case _: - return "consonant" diff --git a/crates/basilisk-cli/tests/fixtures/clean/typed_module_vars.py b/crates/basilisk-cli/tests/fixtures/clean/typed_module_vars.py deleted file mode 100644 index dda59550d..000000000 --- a/crates/basilisk-cli/tests/fixtures/clean/typed_module_vars.py +++ /dev/null @@ -1,11 +0,0 @@ -from __future__ import annotations - -# Widening: int literal declared as float — annotation adds information, no W0050 -ratio: float = 42 - -# Empty containers: element type unknown from empty literal, annotation adds info, no W0050 -data: list[int] = [] -lookup: dict[str, int] = {} - -# Union annotation: inferred None does not match int | None, no W0050 -value: int | None = None diff --git a/crates/basilisk-cli/tests/fixtures/clean/typed_optional.py b/crates/basilisk-cli/tests/fixtures/clean/typed_optional.py deleted file mode 100644 index 564685ff2..000000000 --- a/crates/basilisk-cli/tests/fixtures/clean/typed_optional.py +++ /dev/null @@ -1,18 +0,0 @@ -from __future__ import annotations - -from typing import Optional - - -def find(haystack: str, needle: str) -> Optional[int]: - idx = haystack.find(needle) - return idx if idx >= 0 else None - - -def coerce(value: Optional[int]) -> int: - return value if value is not None else 0 - - -def chain(a: Optional[str], b: Optional[str]) -> Optional[str]: - if a is None or b is None: - return None - return a + b diff --git a/crates/basilisk-cli/tests/fixtures/clean/typed_overloads.py b/crates/basilisk-cli/tests/fixtures/clean/typed_overloads.py deleted file mode 100644 index da01cfa50..000000000 --- a/crates/basilisk-cli/tests/fixtures/clean/typed_overloads.py +++ /dev/null @@ -1,17 +0,0 @@ -from __future__ import annotations - -from typing import overload - - -@overload -def double(x: int) -> int: ... - - -@overload -def double(x: str) -> str: ... - - -def double(x: int | str) -> int | str: - if isinstance(x, int): - return x * 2 - return x + x diff --git a/crates/basilisk-cli/tests/fixtures/clean/typed_overloads_multi_arity.py b/crates/basilisk-cli/tests/fixtures/clean/typed_overloads_multi_arity.py deleted file mode 100644 index f58cd00bb..000000000 --- a/crates/basilisk-cli/tests/fixtures/clean/typed_overloads_multi_arity.py +++ /dev/null @@ -1,15 +0,0 @@ -from __future__ import annotations - -from typing import overload - - -@overload -def resize(x: int) -> str: ... - - -@overload -def resize(x: int, y: int) -> str: ... - - -def resize(*args: int) -> str: - return str(args[0]) diff --git a/crates/basilisk-cli/tests/fixtures/clean/typed_override.py b/crates/basilisk-cli/tests/fixtures/clean/typed_override.py deleted file mode 100644 index 2452ebb3a..000000000 --- a/crates/basilisk-cli/tests/fixtures/clean/typed_override.py +++ /dev/null @@ -1,23 +0,0 @@ -from __future__ import annotations - -from typing import Any, override - - -class Animal: - def speak(self) -> str: - return "" - - def __init__(self, dna: Any) -> None: - self.dna = dna - - def name(self) -> str: - return "animal" - - -class Dog(Animal): - @override - def speak(self) -> str: - return "woof" - - def fetch(self, item: str) -> str: - return f"fetched {item}" diff --git a/crates/basilisk-cli/tests/fixtures/clean/typed_stdlib_imports.py b/crates/basilisk-cli/tests/fixtures/clean/typed_stdlib_imports.py deleted file mode 100644 index 484a04e7b..000000000 --- a/crates/basilisk-cli/tests/fixtures/clean/typed_stdlib_imports.py +++ /dev/null @@ -1,25 +0,0 @@ -from __future__ import annotations - -import os -import sys -import re -import json -import pathlib -from typing import Optional -from collections import defaultdict -from dataclasses import dataclass - - -def get_path() -> str: - return os.getcwd() - - -def get_version() -> str: - return sys.version - - -def find_pattern(text: str, pattern: str) -> Optional[str]: - match = re.search(pattern, text) - if match: - return match.group(0) - return None diff --git a/crates/basilisk-cli/tests/fixtures/clean/typed_try_except.py b/crates/basilisk-cli/tests/fixtures/clean/typed_try_except.py deleted file mode 100644 index b70f46a4f..000000000 --- a/crates/basilisk-cli/tests/fixtures/clean/typed_try_except.py +++ /dev/null @@ -1,22 +0,0 @@ -def safe_open(path: str) -> str: - try: - with open(path) as f: - return f.read() - except OSError: - return "" - - -def safe_divide(a: float, b: float) -> float: - try: - return a / b - except ZeroDivisionError: - return 0.0 - finally: - pass - - -def safe_int(value: str) -> int: - try: - return int(value) - except (ValueError, TypeError): - return 0 diff --git a/crates/basilisk-cli/tests/fixtures/clean/typed_while_for.py b/crates/basilisk-cli/tests/fixtures/clean/typed_while_for.py deleted file mode 100644 index 2ee96a947..000000000 --- a/crates/basilisk-cli/tests/fixtures/clean/typed_while_for.py +++ /dev/null @@ -1,18 +0,0 @@ -def sum_while(limit: int) -> int: - total = 0 - i = 0 - while i < limit: - def step(x: int) -> int: - return x + 1 - i = step(i) - total += i - return total - - -def find_in_list(items: list[str], target: str) -> int: - for idx, item in enumerate(items): - def matches(a: str, b: str) -> bool: - return a == b - if matches(item, target): - return idx - return -1 diff --git a/crates/basilisk-cli/tests/fixtures/clean/typed_with_statement.py b/crates/basilisk-cli/tests/fixtures/clean/typed_with_statement.py deleted file mode 100644 index 582784d5c..000000000 --- a/crates/basilisk-cli/tests/fixtures/clean/typed_with_statement.py +++ /dev/null @@ -1,10 +0,0 @@ -def read_file(path: str) -> str: - with open(path) as f: - def read_all(handle: object) -> str: - return handle.read() # type: ignore[union-attr] - return read_all(f) - - -def write_file(path: str, content: str) -> None: - with open(path, "w") as f: - f.write(content) diff --git a/crates/basilisk-cli/tests/fixtures/clean/typed_with_varargs.py b/crates/basilisk-cli/tests/fixtures/clean/typed_with_varargs.py deleted file mode 100644 index f1253af1a..000000000 --- a/crates/basilisk-cli/tests/fixtures/clean/typed_with_varargs.py +++ /dev/null @@ -1,10 +0,0 @@ -def log(*messages: str, level: str) -> None: - pass - - -def merge(**kwargs: int) -> int: - return sum(kwargs.values()) - - -def mixed(first: str, *rest: int, key: str) -> str: - return first diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0001_all_param_kinds.py b/crates/basilisk-cli/tests/fixtures/errors/e0001_all_param_kinds.py deleted file mode 100644 index fde6a7409..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0001_all_param_kinds.py +++ /dev/null @@ -1,2 +0,0 @@ -def everything(pos_only, /, normal, *args, kw_only, **kwargs) -> None: - pass diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0001_and_e0002_class_methods.py b/crates/basilisk-cli/tests/fixtures/errors/e0001_and_e0002_class_methods.py deleted file mode 100644 index 96e31b2fa..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0001_and_e0002_class_methods.py +++ /dev/null @@ -1,9 +0,0 @@ -class Service: - def connect(self, host, port): - return object() - - def disconnect(self) -> None: - pass - - def send(self, payload: str): - return object() diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0001_and_e0002_inheritance.py b/crates/basilisk-cli/tests/fixtures/errors/e0001_and_e0002_inheritance.py deleted file mode 100644 index 15357ae61..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0001_and_e0002_inheritance.py +++ /dev/null @@ -1,11 +0,0 @@ -class Base: - def process(self, data: str) -> str: - return data - - -class Child(Base): - def process(self, data): - return data.upper() - - def extra(self, value): - return value diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0001_and_e0002_module_level.py b/crates/basilisk-cli/tests/fixtures/errors/e0001_and_e0002_module_level.py deleted file mode 100644 index 5240bd2f1..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0001_and_e0002_module_level.py +++ /dev/null @@ -1,14 +0,0 @@ -def parse(raw): - return raw - - -def validate(value): - return True - - -def transform(data): - return data - - -def serialize(obj): - return str(obj) diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0001_and_e0002_try_except.py b/crates/basilisk-cli/tests/fixtures/errors/e0001_and_e0002_try_except.py deleted file mode 100644 index fcae0c284..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0001_and_e0002_try_except.py +++ /dev/null @@ -1,12 +0,0 @@ -def risky(value): - try: - return int(value) - except ValueError: - return 0 - - -def also_risky(a, b): - try: - return a / b - except ZeroDivisionError: - return 0.0 diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0001_and_e0002_while_for.py b/crates/basilisk-cli/tests/fixtures/errors/e0001_and_e0002_while_for.py deleted file mode 100644 index 2ac8a3769..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0001_and_e0002_while_for.py +++ /dev/null @@ -1,12 +0,0 @@ -def count(limit): - total = 0 - while total < limit: - total += 1 - return total - - -def search(items, target): - for item in items: - if item == target: - return item - return None diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0001_deeply_nested_class.py b/crates/basilisk-cli/tests/fixtures/errors/e0001_deeply_nested_class.py deleted file mode 100644 index ef9d8b4b5..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0001_deeply_nested_class.py +++ /dev/null @@ -1,7 +0,0 @@ -class Outer: - class Inner: - def method(self, value) -> None: - pass - - def outer_method(self, x, y) -> None: - pass diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0001_kwargs.py b/crates/basilisk-cli/tests/fixtures/errors/e0001_kwargs.py deleted file mode 100644 index 9066e0025..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0001_kwargs.py +++ /dev/null @@ -1,2 +0,0 @@ -def configure(**options) -> None: - pass diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0001_kwonly_params.py b/crates/basilisk-cli/tests/fixtures/errors/e0001_kwonly_params.py deleted file mode 100644 index e2755bcb8..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0001_kwonly_params.py +++ /dev/null @@ -1,2 +0,0 @@ -def render(*, width: int, height, background, scale: float) -> str: - return "" diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0001_mixed_annotated.py b/crates/basilisk-cli/tests/fixtures/errors/e0001_mixed_annotated.py deleted file mode 100644 index e81ef6b9b..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0001_mixed_annotated.py +++ /dev/null @@ -1,2 +0,0 @@ -def transfer(source: str, destination, amount: float, currency) -> bool: - return True diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0001_multi_param.py b/crates/basilisk-cli/tests/fixtures/errors/e0001_multi_param.py deleted file mode 100644 index ef1677320..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0001_multi_param.py +++ /dev/null @@ -1,2 +0,0 @@ -def compute(x, y, z) -> int: - return 0 diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0001_nested.py b/crates/basilisk-cli/tests/fixtures/errors/e0001_nested.py deleted file mode 100644 index be47af70c..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0001_nested.py +++ /dev/null @@ -1,5 +0,0 @@ -def outer(x: int) -> int: - def inner(y) -> int: - return x + y - - return inner(1) diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0001_posonly_params.py b/crates/basilisk-cli/tests/fixtures/errors/e0001_posonly_params.py deleted file mode 100644 index d965601f5..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0001_posonly_params.py +++ /dev/null @@ -1,2 +0,0 @@ -def divide(numerator, denominator, /) -> float: - return numerator / denominator diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0001_single_param.py b/crates/basilisk-cli/tests/fixtures/errors/e0001_single_param.py deleted file mode 100644 index 78f33fbb4..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0001_single_param.py +++ /dev/null @@ -1,2 +0,0 @@ -def process(data) -> None: - pass diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0001_varargs.py b/crates/basilisk-cli/tests/fixtures/errors/e0001_varargs.py deleted file mode 100644 index a3c8f35d5..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0001_varargs.py +++ /dev/null @@ -1,2 +0,0 @@ -def log(*messages, level: str) -> None: - pass diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0002_deeply_nested.py b/crates/basilisk-cli/tests/fixtures/errors/e0002_deeply_nested.py deleted file mode 100644 index 74da0e82d..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0002_deeply_nested.py +++ /dev/null @@ -1,6 +0,0 @@ -def outer(x: int) -> int: - def middle(y: int) -> int: - def inner(z: int): - return x + y + z - return inner(0) - return middle(0) diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0002_dunder_methods.py b/crates/basilisk-cli/tests/fixtures/errors/e0002_dunder_methods.py deleted file mode 100644 index b8566d226..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0002_dunder_methods.py +++ /dev/null @@ -1,13 +0,0 @@ -class Vector: - def __init__(self, x: float, y: float): - self.x = x - self.y = y - - def __repr__(self): - return f"Vector({self.x}, {self.y})" - - def __add__(self, other: Vector): - return Vector(self.x + other.x, self.y + other.y) - - def __len__(self): - return 2 diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0002_in_if_block.py b/crates/basilisk-cli/tests/fixtures/errors/e0002_in_if_block.py deleted file mode 100644 index aef6b24c4..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0002_in_if_block.py +++ /dev/null @@ -1,8 +0,0 @@ -import sys - -if sys.version_info >= (3, 11): - def new_feature(x: int) -> int: - return x -else: - def new_feature(x: int): - return x diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0002_multiple_funcs.py b/crates/basilisk-cli/tests/fixtures/errors/e0002_multiple_funcs.py deleted file mode 100644 index 160142f86..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0002_multiple_funcs.py +++ /dev/null @@ -1,10 +0,0 @@ -def fetch(url: str): - return url.encode() - - -def compute(x: int, y: int): - return x + y - - -def noop(): - return print() diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0002_no_params.py b/crates/basilisk-cli/tests/fixtures/errors/e0002_no_params.py deleted file mode 100644 index 432e3baf8..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0002_no_params.py +++ /dev/null @@ -1,10 +0,0 @@ -def get_version(): - return "1.0.0".strip() - - -def get_timestamp(): - return abs(0) - - -def noop(): - return print() diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0002_single_func.py b/crates/basilisk-cli/tests/fixtures/errors/e0002_single_func.py deleted file mode 100644 index 6e3b8dcf8..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0002_single_func.py +++ /dev/null @@ -1,2 +0,0 @@ -def fetch(url: str): - return url.encode() diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0003_module_vars.py b/crates/basilisk-cli/tests/fixtures/errors/e0003_module_vars.py deleted file mode 100644 index 792c53527..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0003_module_vars.py +++ /dev/null @@ -1,3 +0,0 @@ -items = [] -data = {} -empty = None diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0005_class_attrs.py b/crates/basilisk-cli/tests/fixtures/errors/e0005_class_attrs.py deleted file mode 100644 index ec59b8f9e..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0005_class_attrs.py +++ /dev/null @@ -1,5 +0,0 @@ -class Config: - host = "localhost" - port = 8080 - debug = False - connection = create_connection() diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0010_untyped_import.py b/crates/basilisk-cli/tests/fixtures/errors/e0010_untyped_import.py deleted file mode 100644 index 20b15530d..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0010_untyped_import.py +++ /dev/null @@ -1 +0,0 @@ -import requests diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0011_explicit_any.py b/crates/basilisk-cli/tests/fixtures/errors/e0011_explicit_any.py deleted file mode 100644 index fd33ca1c1..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0011_explicit_any.py +++ /dev/null @@ -1,5 +0,0 @@ -from typing import Any - - -def process(data: Any) -> str: - return str(data) diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0011_vararg_kwarg_any.py b/crates/basilisk-cli/tests/fixtures/errors/e0011_vararg_kwarg_any.py deleted file mode 100644 index e37ef0ee7..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0011_vararg_kwarg_any.py +++ /dev/null @@ -1,5 +0,0 @@ -from typing import Any - - -def process(*args: Any, **kwargs: Any) -> Any: - pass diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0012_wrong_arg_type.py b/crates/basilisk-cli/tests/fixtures/errors/e0012_wrong_arg_type.py deleted file mode 100644 index d6227a0b4..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0012_wrong_arg_type.py +++ /dev/null @@ -1,5 +0,0 @@ -def add(x: int, y: int) -> int: - return x + y - - -result: int = add("hello", "world") diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0013_return_mismatch.py b/crates/basilisk-cli/tests/fixtures/errors/e0013_return_mismatch.py deleted file mode 100644 index f4418cd14..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0013_return_mismatch.py +++ /dev/null @@ -1,2 +0,0 @@ -def compute(x: int) -> None: - return x * 2 diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0014_assignment_incompatible.py b/crates/basilisk-cli/tests/fixtures/errors/e0014_assignment_incompatible.py deleted file mode 100644 index f82388fce..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0014_assignment_incompatible.py +++ /dev/null @@ -1,2 +0,0 @@ -count: int = "hello" -label: str = 42 diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0014_bytes_float_mismatches.py b/crates/basilisk-cli/tests/fixtures/errors/e0014_bytes_float_mismatches.py deleted file mode 100644 index a3e06f028..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0014_bytes_float_mismatches.py +++ /dev/null @@ -1,3 +0,0 @@ -ratio: float = b"bytes_value" -name: str = 3.14 -raw: bytes = 42 diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0015_invalid_type_arg.py b/crates/basilisk-cli/tests/fixtures/errors/e0015_invalid_type_arg.py deleted file mode 100644 index 43a74a178..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0015_invalid_type_arg.py +++ /dev/null @@ -1,5 +0,0 @@ -from __future__ import annotations - - -def process(items: list[int, str]) -> None: - pass diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0015_more_generics.py b/crates/basilisk-cli/tests/fixtures/errors/e0015_more_generics.py deleted file mode 100644 index c7b3ee505..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0015_more_generics.py +++ /dev/null @@ -1,10 +0,0 @@ -def f_set(items: set[int, str]) -> None: - pass - - -def f_frozenset(items: frozenset[int, str]) -> None: - pass - - -def f_dict_short(data: dict[str]) -> None: - pass diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0016_incompatible_override.py b/crates/basilisk-cli/tests/fixtures/errors/e0016_incompatible_override.py deleted file mode 100644 index 8d3e99907..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0016_incompatible_override.py +++ /dev/null @@ -1,13 +0,0 @@ -from __future__ import annotations -from typing import override - - -class Base: - def process(self, data: str) -> str: - return data - - -class Child(Base): - @override - def process(self, data: int) -> int: - return data diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0017_variable_override.py b/crates/basilisk-cli/tests/fixtures/errors/e0017_variable_override.py deleted file mode 100644 index ea8cf34b6..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0017_variable_override.py +++ /dev/null @@ -1,9 +0,0 @@ -from __future__ import annotations - - -class Base: - count: int = 0 - - -class Child(Base): - count: str = "zero" diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0018_undefined_variable.py b/crates/basilisk-cli/tests/fixtures/errors/e0018_undefined_variable.py deleted file mode 100644 index 64747faf8..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0018_undefined_variable.py +++ /dev/null @@ -1,2 +0,0 @@ -def compute() -> int: - return undefined_name diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0019_unbound_variable.py b/crates/basilisk-cli/tests/fixtures/errors/e0019_unbound_variable.py deleted file mode 100644 index 92c06410c..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0019_unbound_variable.py +++ /dev/null @@ -1,4 +0,0 @@ -def maybe_assign(flag: bool) -> int: - if flag: - result = 42 - return result diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0020_missing_overload_impl.py b/crates/basilisk-cli/tests/fixtures/errors/e0020_missing_overload_impl.py deleted file mode 100644 index 2000662cc..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0020_missing_overload_impl.py +++ /dev/null @@ -1,13 +0,0 @@ -from typing import overload - - -@overload -def double(x: int) -> int: ... - - -@overload -def double(x: str) -> str: ... - - -def helper() -> None: - pass diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0021_overlapping_overloads.py b/crates/basilisk-cli/tests/fixtures/errors/e0021_overlapping_overloads.py deleted file mode 100644 index e67ae3504..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0021_overlapping_overloads.py +++ /dev/null @@ -1,13 +0,0 @@ -from typing import overload - - -@overload -def process(x) -> int: ... - - -@overload -def process(x) -> str: ... - - -def process(x: int) -> int: - return x diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0022_unhashable_type.py b/crates/basilisk-cli/tests/fixtures/errors/e0022_unhashable_type.py deleted file mode 100644 index 984e98626..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0022_unhashable_type.py +++ /dev/null @@ -1,2 +0,0 @@ -def bad_key() -> None: - mapping = {[1, 2]: "value"} diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0023_nonexhaustive_match.py b/crates/basilisk-cli/tests/fixtures/errors/e0023_nonexhaustive_match.py deleted file mode 100644 index d5bde37a6..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0023_nonexhaustive_match.py +++ /dev/null @@ -1,7 +0,0 @@ -def classify(status: int) -> str: - match status: - case 200: - return "ok" - case 404: - return "not found" - return "unknown" diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0024_invalid_type_form.py b/crates/basilisk-cli/tests/fixtures/errors/e0024_invalid_type_form.py deleted file mode 100644 index cca39898b..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0024_invalid_type_form.py +++ /dev/null @@ -1,2 +0,0 @@ -def bad(x: 42) -> None: - pass diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0024_vararg_kwarg_return_literal.py b/crates/basilisk-cli/tests/fixtures/errors/e0024_vararg_kwarg_return_literal.py deleted file mode 100644 index 54b59863e..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0024_vararg_kwarg_return_literal.py +++ /dev/null @@ -1,2 +0,0 @@ -def process(*args: 42, **kwargs: True) -> 0: - pass diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0025_missing_override.py b/crates/basilisk-cli/tests/fixtures/errors/e0025_missing_override.py deleted file mode 100644 index fa0c2e6b9..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0025_missing_override.py +++ /dev/null @@ -1,11 +0,0 @@ -from __future__ import annotations - - -class Animal: - def speak(self) -> str: - return "" - - -class Dog(Animal): - def speak(self) -> str: - return "woof" diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0026_typevar_single_constraint.py b/crates/basilisk-cli/tests/fixtures/errors/e0026_typevar_single_constraint.py deleted file mode 100644 index 6fb30c1f1..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0026_typevar_single_constraint.py +++ /dev/null @@ -1,2 +0,0 @@ -from typing import TypeVar -T = TypeVar("T", int) diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0027_duplicate_typevar_generic.py b/crates/basilisk-cli/tests/fixtures/errors/e0027_duplicate_typevar_generic.py deleted file mode 100644 index f6bcb26fe..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0027_duplicate_typevar_generic.py +++ /dev/null @@ -1,4 +0,0 @@ -from typing import TypeVar, Generic -T = TypeVar('T') -class Box(Generic[T, T]): - pass diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0029_typeddict_method.py b/crates/basilisk-cli/tests/fixtures/errors/e0029_typeddict_method.py deleted file mode 100644 index 23a1a6edc..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0029_typeddict_method.py +++ /dev/null @@ -1,4 +0,0 @@ -from typing import TypedDict -class Config(TypedDict): - x: int - def helper(self) -> None: pass diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0030_non_default_after_default.py b/crates/basilisk-cli/tests/fixtures/errors/e0030_non_default_after_default.py deleted file mode 100644 index e6070fd2c..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0030_non_default_after_default.py +++ /dev/null @@ -1,5 +0,0 @@ -from typing import TypeVar, Generic -T = TypeVar('T', default=int) -S = TypeVar('S') -class Box(Generic[T, S]): - pass diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0031_invalid_cast.py b/crates/basilisk-cli/tests/fixtures/errors/e0031_invalid_cast.py deleted file mode 100644 index 77abc58b1..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0031_invalid_cast.py +++ /dev/null @@ -1,2 +0,0 @@ -from typing import cast -x = cast(int) diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0032_typeddict_invalid_keyword.py b/crates/basilisk-cli/tests/fixtures/errors/e0032_typeddict_invalid_keyword.py deleted file mode 100644 index c7e3e6a70..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0032_typeddict_invalid_keyword.py +++ /dev/null @@ -1,3 +0,0 @@ -from typing import TypedDict -class Config(TypedDict, metaclass=type): - x: int diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0033_invalid_reveal_type.py b/crates/basilisk-cli/tests/fixtures/errors/e0033_invalid_reveal_type.py deleted file mode 100644 index a3f2c7f72..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0033_invalid_reveal_type.py +++ /dev/null @@ -1,2 +0,0 @@ -reveal_type() -reveal_type(1, 2) diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0034_final_class_inherit.py b/crates/basilisk-cli/tests/fixtures/errors/e0034_final_class_inherit.py deleted file mode 100644 index a022b954c..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0034_final_class_inherit.py +++ /dev/null @@ -1,8 +0,0 @@ -from typing import final - -@final -class Base: - pass - -class Sub(Base): - pass diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0035_required_outside_typeddict.py b/crates/basilisk-cli/tests/fixtures/errors/e0035_required_outside_typeddict.py deleted file mode 100644 index 75a30f7ee..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0035_required_outside_typeddict.py +++ /dev/null @@ -1,3 +0,0 @@ -from typing import Required -class NotADict: - x: Required[int] = 0 diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0036_classvar_invalid.py b/crates/basilisk-cli/tests/fixtures/errors/e0036_classvar_invalid.py deleted file mode 100644 index 0f15e8848..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0036_classvar_invalid.py +++ /dev/null @@ -1,3 +0,0 @@ -from typing import ClassVar -def func(x: ClassVar[int]) -> None: - pass diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0037_typeddict_functional_invalid.py b/crates/basilisk-cli/tests/fixtures/errors/e0037_typeddict_functional_invalid.py deleted file mode 100644 index 745d68671..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0037_typeddict_functional_invalid.py +++ /dev/null @@ -1,2 +0,0 @@ -from typing import TypedDict -Wrong = TypedDict("Right", {"x": int}) diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0038_typeddict_inheritance_invalid.py b/crates/basilisk-cli/tests/fixtures/errors/e0038_typeddict_inheritance_invalid.py deleted file mode 100644 index c792c4393..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0038_typeddict_inheritance_invalid.py +++ /dev/null @@ -1,7 +0,0 @@ -from typing import TypedDict - -class Base(TypedDict): - x: int - -class Child(Base): - x: str diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0039_invalid_assert_type.py b/crates/basilisk-cli/tests/fixtures/errors/e0039_invalid_assert_type.py deleted file mode 100644 index cf7dc2ab6..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0039_invalid_assert_type.py +++ /dev/null @@ -1,4 +0,0 @@ -from typing import assert_type -assert_type() -assert_type(1) -assert_type(1, int, str) diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0040_enum_subclass.py b/crates/basilisk-cli/tests/fixtures/errors/e0040_enum_subclass.py deleted file mode 100644 index 01dbee08e..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0040_enum_subclass.py +++ /dev/null @@ -1,8 +0,0 @@ -from enum import Enum - -class Color(Enum): - RED = 1 - GREEN = 2 - -class ExtendedColor(Color): - BLUE = 3 diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0041_too_few_args.py b/crates/basilisk-cli/tests/fixtures/errors/e0041_too_few_args.py deleted file mode 100644 index 15cdc3a66..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0041_too_few_args.py +++ /dev/null @@ -1,4 +0,0 @@ -def func1(a: int, b: str) -> None: - pass - -func1() diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0042_pep695_mixed_typevar.py b/crates/basilisk-cli/tests/fixtures/errors/e0042_pep695_mixed_typevar.py deleted file mode 100644 index 011ebc178..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0042_pep695_mixed_typevar.py +++ /dev/null @@ -1,6 +0,0 @@ -from typing import TypeVar - -K = TypeVar("K") - -class ClassA[V](dict[K, V]): - ... diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0043_non_typevar_in_generic.py b/crates/basilisk-cli/tests/fixtures/errors/e0043_non_typevar_in_generic.py deleted file mode 100644 index d60916629..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0043_non_typevar_in_generic.py +++ /dev/null @@ -1,3 +0,0 @@ -from typing import Generic -class Bad(Generic[int]): - pass diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0044_final_invalid_position.py b/crates/basilisk-cli/tests/fixtures/errors/e0044_final_invalid_position.py deleted file mode 100644 index 9c5031ffd..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0044_final_invalid_position.py +++ /dev/null @@ -1,3 +0,0 @@ -from typing import Final -def f(x: Final[int]) -> None: - pass diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0045_annotated_invalid.py b/crates/basilisk-cli/tests/fixtures/errors/e0045_annotated_invalid.py deleted file mode 100644 index 60f7d79a2..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0045_annotated_invalid.py +++ /dev/null @@ -1,2 +0,0 @@ -from typing import Annotated -bad: Annotated[[int, str], "meta"] diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0046_enum_member_annotated.py b/crates/basilisk-cli/tests/fixtures/errors/e0046_enum_member_annotated.py deleted file mode 100644 index 8e34fcbd7..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0046_enum_member_annotated.py +++ /dev/null @@ -1,4 +0,0 @@ -from enum import Enum - -class Pet(Enum): - DOG: int = 2 diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0047_invalid_type_expr.py b/crates/basilisk-cli/tests/fixtures/errors/e0047_invalid_type_expr.py deleted file mode 100644 index 7bd876671..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0047_invalid_type_expr.py +++ /dev/null @@ -1,2 +0,0 @@ -def f(x: [int, str]) -> None: - pass diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0048_typealias_invalid_rhs.py b/crates/basilisk-cli/tests/fixtures/errors/e0048_typealias_invalid_rhs.py deleted file mode 100644 index f12e93b7c..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0048_typealias_invalid_rhs.py +++ /dev/null @@ -1,2 +0,0 @@ -from typing import TypeAlias -BadAlias: TypeAlias = [int, str] diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0049_multiple_unbounded_tuple.py b/crates/basilisk-cli/tests/fixtures/errors/e0049_multiple_unbounded_tuple.py deleted file mode 100644 index df8905aef..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0049_multiple_unbounded_tuple.py +++ /dev/null @@ -1 +0,0 @@ -t: tuple[*tuple[str, ...], *tuple[int, ...]] diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0050_invalid_newtype.py b/crates/basilisk-cli/tests/fixtures/errors/e0050_invalid_newtype.py deleted file mode 100644 index 83ab9c54f..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0050_invalid_newtype.py +++ /dev/null @@ -1,2 +0,0 @@ -from typing import NewType -GoodName = NewType("BadName", int) diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0051_invalid_literal.py b/crates/basilisk-cli/tests/fixtures/errors/e0051_invalid_literal.py deleted file mode 100644 index 6b7c637f6..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0051_invalid_literal.py +++ /dev/null @@ -1,2 +0,0 @@ -from typing import Literal -x: Literal[3.14] diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0052_frozen_dataclass.py b/crates/basilisk-cli/tests/fixtures/errors/e0052_frozen_dataclass.py deleted file mode 100644 index 3d88bbc31..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0052_frozen_dataclass.py +++ /dev/null @@ -1,8 +0,0 @@ -from dataclasses import dataclass - -@dataclass(frozen=True) -class Point: - x: float - -p = Point(1.0) -p.x = 2.0 diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0053_assert_type_mismatch.py b/crates/basilisk-cli/tests/fixtures/errors/e0053_assert_type_mismatch.py deleted file mode 100644 index ebb156ad9..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0053_assert_type_mismatch.py +++ /dev/null @@ -1,4 +0,0 @@ -from typing import assert_type - -def f(a: int | str) -> None: - assert_type(a, int) diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0054_final_reassignment.py b/crates/basilisk-cli/tests/fixtures/errors/e0054_final_reassignment.py deleted file mode 100644 index e97c4e4d2..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0054_final_reassignment.py +++ /dev/null @@ -1,4 +0,0 @@ -from typing import Final - -RATE: Final = 3000 -RATE = 300 diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0055_typevar_invalid_kwargs.py b/crates/basilisk-cli/tests/fixtures/errors/e0055_typevar_invalid_kwargs.py deleted file mode 100644 index 2b81c18ce..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0055_typevar_invalid_kwargs.py +++ /dev/null @@ -1,2 +0,0 @@ -from typing import TypeVar -T = TypeVar("T", covariant=True, contravariant=True) diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0056_readonly_typeddict.py b/crates/basilisk-cli/tests/fixtures/errors/e0056_readonly_typeddict.py deleted file mode 100644 index 5e5a2835e..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0056_readonly_typeddict.py +++ /dev/null @@ -1,9 +0,0 @@ -from typing import TypedDict -from typing_extensions import ReadOnly - -class Config(TypedDict): - name: str - version: ReadOnly[str] - -cfg: Config = {"name": "test", "version": "1.0"} -cfg["version"] = "2.0" diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0057_pep695_type_alias_invalid.py b/crates/basilisk-cli/tests/fixtures/errors/e0057_pep695_type_alias_invalid.py deleted file mode 100644 index d7c1967bf..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0057_pep695_type_alias_invalid.py +++ /dev/null @@ -1,2 +0,0 @@ -type BadAlias1 = [int, str] -type BadAlias2 = True diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0058_annotated_too_few_args.py b/crates/basilisk-cli/tests/fixtures/errors/e0058_annotated_too_few_args.py deleted file mode 100644 index 30822305f..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0058_annotated_too_few_args.py +++ /dev/null @@ -1,2 +0,0 @@ -from typing import Annotated -bad: Annotated[int] diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0059_dataclass_match_args_false.py b/crates/basilisk-cli/tests/fixtures/errors/e0059_dataclass_match_args_false.py deleted file mode 100644 index facaf71c6..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0059_dataclass_match_args_false.py +++ /dev/null @@ -1,7 +0,0 @@ -from dataclasses import dataclass - -@dataclass(match_args=False) -class DC4: - x: int - -DC4.__match_args__ diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0060_dataclass_ordering_invalid.py b/crates/basilisk-cli/tests/fixtures/errors/e0060_dataclass_ordering_invalid.py deleted file mode 100644 index 265f323c7..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0060_dataclass_ordering_invalid.py +++ /dev/null @@ -1,15 +0,0 @@ -from dataclasses import dataclass - -@dataclass(order=True) -class DC1: - a: str - -@dataclass(order=True) -class DC2: - a: str - -dc1 = DC1("x") -dc2 = DC2("y") - -if dc1 < dc2: - pass diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0061_assert_type_enum_literal.py b/crates/basilisk-cli/tests/fixtures/errors/e0061_assert_type_enum_literal.py deleted file mode 100644 index e675567cf..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0061_assert_type_enum_literal.py +++ /dev/null @@ -1,10 +0,0 @@ -from enum import Enum -from typing import assert_type, Literal - -class Status(Enum): - ACTIVE = 1 - INACTIVE = 2 - -def process(status: Status) -> None: - assert_type(status, Literal[Status.ACTIVE]) # E0061 — redundant narrowing - assert_type(status, Status) # OK — correct usage diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0062_noreturn_fallthrough.py b/crates/basilisk-cli/tests/fixtures/errors/e0062_noreturn_fallthrough.py deleted file mode 100644 index 5071690c5..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0062_noreturn_fallthrough.py +++ /dev/null @@ -1,9 +0,0 @@ -import sys -from typing import NoReturn - -def stop() -> NoReturn: - raise RuntimeError("no way") - -def bad(x: int) -> NoReturn: - if x != 0: - sys.exit(1) diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0063_non_hashable_dataclass.py b/crates/basilisk-cli/tests/fixtures/errors/e0063_non_hashable_dataclass.py deleted file mode 100644 index 99a7328cd..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0063_non_hashable_dataclass.py +++ /dev/null @@ -1,14 +0,0 @@ -from dataclasses import dataclass -from typing import Hashable - -@dataclass -class DC1: - a: int - -v: Hashable = DC1(0) # E0063 — DC1.__hash__ is None - -@dataclass(eq=True, frozen=True) -class DC2: - a: int - -v2: Hashable = DC2(0) # OK — frozen dataclasses are hashable diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0064_namedtuple_invalid_arg.py b/crates/basilisk-cli/tests/fixtures/errors/e0064_namedtuple_invalid_arg.py deleted file mode 100644 index 430ff134e..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0064_namedtuple_invalid_arg.py +++ /dev/null @@ -1,9 +0,0 @@ -from typing import Final, NamedTuple - -X: Final = "x" -Y: Final = "y" -N = NamedTuple("N", [(X, int), (Y, int)]) - -N(x=3, y=4) # OK -N(a=1) # E0064: unknown field `a` -N(x="", y="") # E0064: field `x` expects `int` but got `str` diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0065_float_param_int_attr.py b/crates/basilisk-cli/tests/fixtures/errors/e0065_float_param_int_attr.py deleted file mode 100644 index 4c8f08185..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0065_float_param_int_attr.py +++ /dev/null @@ -1,2 +0,0 @@ -def func1(f: float) -> None: - f.numerator # E0065 — float does not have .numerator diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0066_enum_value_type_mismatch.py b/crates/basilisk-cli/tests/fixtures/errors/e0066_enum_value_type_mismatch.py deleted file mode 100644 index 46c6748a4..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0066_enum_value_type_mismatch.py +++ /dev/null @@ -1,6 +0,0 @@ -from enum import Enum - -class Color(Enum): - _value_: int - RED = 1 # OK — int matches int - GREEN = "green" # E0066 — str is not compatible with int diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0067_enum_non_member_literal.py b/crates/basilisk-cli/tests/fixtures/errors/e0067_enum_non_member_literal.py deleted file mode 100644 index 5fb019629..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0067_enum_non_member_literal.py +++ /dev/null @@ -1,11 +0,0 @@ -from enum import Enum -from typing import Literal - -class Pet4(Enum): - CAT = 1 - converter = lambda x: str(x) # Non-member (lambda) - - def speak(self) -> None: ... # Non-member (method) - -converter_var: Literal[Pet4.converter] # E0067 — converter is not an enum member -speak_var: Literal[Pet4.speak] # E0067 — speak is not an enum member diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0068_literal_string_enum.py b/crates/basilisk-cli/tests/fixtures/errors/e0068_literal_string_enum.py deleted file mode 100644 index e1fc33c8a..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0068_literal_string_enum.py +++ /dev/null @@ -1,8 +0,0 @@ -from enum import Enum -from typing import Literal - -class Color(Enum): - RED = 1 - -def func2(a: Literal[Color.RED]) -> None: - x1: Literal["Color.RED"] = a # E0068 — string literal != enum member diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0069_dataclass_kwonly.py b/crates/basilisk-cli/tests/fixtures/errors/e0069_dataclass_kwonly.py deleted file mode 100644 index 6511394d4..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0069_dataclass_kwonly.py +++ /dev/null @@ -1,10 +0,0 @@ -from dataclasses import dataclass, KW_ONLY - -@dataclass -class Point: - x: float - _: KW_ONLY - y: float = 0.0 - -Point(1.0) # OK — x positional, y uses default -Point(1.0, 2.0) # E0069 — y is keyword-only, cannot be passed positionally diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0070_never_type_compat.py b/crates/basilisk-cli/tests/fixtures/errors/e0070_never_type_compat.py deleted file mode 100644 index 342cbbe89..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0070_never_type_compat.py +++ /dev/null @@ -1,13 +0,0 @@ -from typing import Never, Any, Generic, TypeVar - -T = TypeVar("T") -U = TypeVar("U") - -def func(c: list[Never]) -> None: - v: list[int] = c # E0070 — list is invariant, list[Never] != list[int] - -class ClassC(Generic[T]): - pass - -def func2(x: U) -> ClassC[U]: - return ClassC[Never]() # E0070 — ClassC is invariant diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0071_historical_positional.py b/crates/basilisk-cli/tests/fixtures/errors/e0071_historical_positional.py deleted file mode 100644 index 0922e9829..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0071_historical_positional.py +++ /dev/null @@ -1,5 +0,0 @@ -def f1(__x: int) -> None: ... - -f1(__x=3) # E0071 — __x is positional-only - -def f2(x: int, __y: int) -> None: ... # E0071 — __y after positional-or-keyword x diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0072_no_matching_overload.py b/crates/basilisk-cli/tests/fixtures/errors/e0072_no_matching_overload.py deleted file mode 100644 index 99a2f8d1f..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0072_no_matching_overload.py +++ /dev/null @@ -1,11 +0,0 @@ -from typing import overload - -class Bytes: - @overload - def __getitem__(self, __i: int) -> int: ... - @overload - def __getitem__(self, __s: slice) -> bytes: ... - def __getitem__(self, __i_or_s: int | slice) -> int | bytes: ... - -b = Bytes() -b[""] # E0072 — no overload of __getitem__ accepts str diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0073_namedtuple_tuple_compat.py b/crates/basilisk-cli/tests/fixtures/errors/e0073_namedtuple_tuple_compat.py deleted file mode 100644 index c9cd1d45a..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0073_namedtuple_tuple_compat.py +++ /dev/null @@ -1,11 +0,0 @@ -from typing import NamedTuple - -class Point(NamedTuple): - x: int - y: int - units: str = "meters" - -p = Point(x=1, y=2, units="inches") -v1: tuple[int, int, str] = p # OK -v2: tuple[int, int] = p # E0073 — too few elements (2 vs 3 fields) -v3: tuple[int, str, str] = p # E0073 — incompatible element type diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0074_constructor_new_mismatch.py b/crates/basilisk-cli/tests/fixtures/errors/e0074_constructor_new_mismatch.py deleted file mode 100644 index ece44a3f8..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0074_constructor_new_mismatch.py +++ /dev/null @@ -1,9 +0,0 @@ -from typing import Generic, TypeVar, Self - -T = TypeVar("T") - -class Class1(Generic[T]): - def __new__(cls, x: T) -> Self: - return super().__new__(cls) - -Class1[int](1.0) # E0074 — float is not compatible with int diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0075_self_type_attr_incompat.py b/crates/basilisk-cli/tests/fixtures/errors/e0075_self_type_attr_incompat.py deleted file mode 100644 index c53732b5f..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0075_self_type_attr_incompat.py +++ /dev/null @@ -1,16 +0,0 @@ -from typing import Self, TypeVar, Generic -from dataclasses import dataclass - -T = TypeVar("T") - -@dataclass -class LinkedList(Generic[T]): - value: T - next: Self | None = None - -@dataclass -class OrdinalLinkedList(LinkedList[int]): - def ordinal_value(self) -> str: - return str(self.value) - -xs = OrdinalLinkedList(value=1, next=LinkedList[int](value=2)) # E0075 diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0076_overload_union_expansion.py b/crates/basilisk-cli/tests/fixtures/errors/e0076_overload_union_expansion.py deleted file mode 100644 index 50f9799d8..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0076_overload_union_expansion.py +++ /dev/null @@ -1,11 +0,0 @@ -from typing import overload - -@overload -def example(x: int, y: str, z: int) -> str: ... -@overload -def example(x: int, y: int, z: int) -> int: ... -def example(x: int, y: int | str, z: int) -> int | str: - return 1 - -def check(v: int | str) -> None: - example(v, v, 1) # E0076 — str not assignable to int in any overload diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0077_protocol_self_return.py b/crates/basilisk-cli/tests/fixtures/errors/e0077_protocol_self_return.py deleted file mode 100644 index 18187600b..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0077_protocol_self_return.py +++ /dev/null @@ -1,13 +0,0 @@ -from typing import Protocol, Self - -class ShapeProtocol(Protocol): - def set_scale(self, scale: float) -> Self: ... - -class BadReturn: - def set_scale(self, scale: float) -> int: - return 42 - -def accepts(s: ShapeProtocol) -> None: ... - -def main(bad: BadReturn) -> None: - accepts(bad) # E0077 — BadReturn.set_scale returns int, not Self diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0078_self_type_violation.py b/crates/basilisk-cli/tests/fixtures/errors/e0078_self_type_violation.py deleted file mode 100644 index 92aa74594..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0078_self_type_violation.py +++ /dev/null @@ -1,15 +0,0 @@ -from typing import Self, Generic, TypeVar - -T = TypeVar("T") - -class Shape: - def method2(self) -> Self: - return Shape() # E0078 — should return self, not Shape() - - @classmethod - def cls_method2(cls) -> Self: - return Shape() # E0078 — should return cls(), not Shape() - -class Container(Generic[T]): - def foo(self, other: Self[int]) -> None: # E0078 — Self is not subscriptable - pass diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0079_module_protocol_incompat.py b/crates/basilisk-cli/tests/fixtures/errors/e0079_module_protocol_incompat.py deleted file mode 100644 index 3698fa91a..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0079_module_protocol_incompat.py +++ /dev/null @@ -1,9 +0,0 @@ -from typing import Protocol - -class MyProtocol(Protocol): - timeout: str - def get_value(self) -> int: ... - -import sys - -x: MyProtocol = sys # E0079 — sys may not satisfy MyProtocol diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0080_typevar_bound_violation.py b/crates/basilisk-cli/tests/fixtures/errors/e0080_typevar_bound_violation.py deleted file mode 100644 index 572d093e5..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0080_typevar_bound_violation.py +++ /dev/null @@ -1,10 +0,0 @@ -from typing import Sized, TypeVar - -ST = TypeVar("ST", bound=Sized) - -def longer(x: ST, y: ST) -> ST: - if len(x) > len(y): - return x - return y - -longer(3, 3) # E0080 — int does not implement Sized (__len__) diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0081_typevartuple_unpack_min.py b/crates/basilisk-cli/tests/fixtures/errors/e0081_typevartuple_unpack_min.py deleted file mode 100644 index e0b9d3053..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0081_typevartuple_unpack_min.py +++ /dev/null @@ -1,13 +0,0 @@ -from typing import TypeVarTuple, Generic, Any - -Ts = TypeVarTuple("Ts") - -class Array(Generic[*Ts]): ... - -class Batch: ... -class Channels: ... - -def process(x: Array[Batch, *tuple[Any, ...], Channels]) -> None: ... - -def func(z: Array[Batch]) -> None: - process(z) # E0081 — Array[Batch] has 1 type arg, need at least 2 diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0082_typevartuple_callable_mismatch.py b/crates/basilisk-cli/tests/fixtures/errors/e0082_typevartuple_callable_mismatch.py deleted file mode 100644 index 64d23e435..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0082_typevartuple_callable_mismatch.py +++ /dev/null @@ -1,11 +0,0 @@ -from typing import TypeVarTuple, Callable, Generic - -Ts = TypeVarTuple("Ts") - -class Process: - def __init__(self, target: Callable[[*Ts], None], args: tuple[*Ts]) -> None: ... - -def func1(arg1: int, arg2: str) -> None: ... - -Process(target=func1, args=(0, "")) # OK -Process(target=func1, args=("", 0)) # E0082 — str, int does not match int, str diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0083_typevartuple_unpack_required.py b/crates/basilisk-cli/tests/fixtures/errors/e0083_typevartuple_unpack_required.py deleted file mode 100644 index 46482ea03..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0083_typevartuple_unpack_required.py +++ /dev/null @@ -1,9 +0,0 @@ -from typing import Generic, TypeVarTuple - -Ts = TypeVarTuple("Ts") - -class Cls(Generic[Ts]): # E0083 — TypeVarTuple must be unpacked with * - ... - -def f(*args: Ts) -> None: # E0083 — TypeVarTuple must be unpacked with * - ... diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0084_typevartuple_invalid_params.py b/crates/basilisk-cli/tests/fixtures/errors/e0084_typevartuple_invalid_params.py deleted file mode 100644 index 0be60c48e..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0084_typevartuple_invalid_params.py +++ /dev/null @@ -1,3 +0,0 @@ -from typing import TypeVarTuple - -Ts1 = TypeVarTuple("Ts1", covariant=True) # E0084 — TypeVarTuple does not support variance diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0085_typevartuple_arg_count.py b/crates/basilisk-cli/tests/fixtures/errors/e0085_typevartuple_arg_count.py deleted file mode 100644 index dd2b1dc6d..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0085_typevartuple_arg_count.py +++ /dev/null @@ -1,14 +0,0 @@ -from typing import Generic, TypeVarTuple - -Ts = TypeVarTuple("Ts") - -class Height: - def __init__(self, v: int) -> None: ... - -class Width: - def __init__(self, v: int) -> None: ... - -class Array(Generic[*Ts]): - def __init__(self, shape: tuple[*Ts]) -> None: ... - -a = Array[Height, Width](Height(1)) # E0085 — expected 2 arguments, got 1 diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0086_multiple_typevartuple.py b/crates/basilisk-cli/tests/fixtures/errors/e0086_multiple_typevartuple.py deleted file mode 100644 index c1e09864b..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0086_multiple_typevartuple.py +++ /dev/null @@ -1,7 +0,0 @@ -from typing import TypeVarTuple, Generic - -Ts1 = TypeVarTuple("Ts1") -Ts2 = TypeVarTuple("Ts2") - -class Array3(Generic[*Ts1, *Ts2]): # E0086 — multiple TypeVarTuples not allowed - ... diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0088_typeddict_isinstance.py b/crates/basilisk-cli/tests/fixtures/errors/e0088_typeddict_isinstance.py deleted file mode 100644 index e1a0406c3..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0088_typeddict_isinstance.py +++ /dev/null @@ -1,10 +0,0 @@ -from typing import TypedDict - -class Movie(TypedDict): - name: str - year: int - -movie: Movie = {"name": "Blade Runner", "year": 1982} - -if isinstance(movie, Movie): # E0088 — TypedDict cannot be used in isinstance - pass diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0089_pep695_invalid_bound.py b/crates/basilisk-cli/tests/fixtures/errors/e0089_pep695_invalid_bound.py deleted file mode 100644 index fafc68b15..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0089_pep695_invalid_bound.py +++ /dev/null @@ -1,8 +0,0 @@ -class Foo[T: [str, int]]: # E0089 — list literal is not a valid bound - ... - -class Bar[T: ()]: # E0089 — constraint tuple must have two or more types - ... - -class Baz[T: (str,)]: # E0089 — single-element constraint tuple - ... diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0090_invalid_tuple_syntax.py b/crates/basilisk-cli/tests/fixtures/errors/e0090_invalid_tuple_syntax.py deleted file mode 100644 index 0d833076a..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0090_invalid_tuple_syntax.py +++ /dev/null @@ -1,3 +0,0 @@ -t1: tuple[int, ...] # OK -t2: tuple[int, int, ...] # E0090 — multiple fixed types before ... -t3: tuple[...] # E0090 — missing type before ... diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0091_typevar_default_incompat.py b/crates/basilisk-cli/tests/fixtures/errors/e0091_typevar_default_incompat.py deleted file mode 100644 index e6f93ed4f..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0091_typevar_default_incompat.py +++ /dev/null @@ -1,7 +0,0 @@ -from typing import TypeVar - -Ok1 = TypeVar("Ok1", bound=float, default=int) # OK — int <: float -Invalid1 = TypeVar("Invalid1", bound=str, default=int) # E0091 — int is not <: str - -Ok2 = TypeVar("Ok2", float, str, default=float) # OK -Invalid2 = TypeVar("Invalid2", float, str, default=int) # E0091 — int not in {float, str} diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0092_too_few_type_args.py b/crates/basilisk-cli/tests/fixtures/errors/e0092_too_few_type_args.py deleted file mode 100644 index 0d4ba53b1..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0092_too_few_type_args.py +++ /dev/null @@ -1,9 +0,0 @@ -from typing import Generic, TypeVar - -T1 = TypeVar("T1") -T2 = TypeVar("T2") - -class MyGeneric(Generic[T1, T2]): ... - -MyGeneric[int] # E0092 — 1 arg but at least 2 required -MyGeneric[int, str] # OK diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0093_typeddict_key_validation.py b/crates/basilisk-cli/tests/fixtures/errors/e0093_typeddict_key_validation.py deleted file mode 100644 index 90e605c2c..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0093_typeddict_key_validation.py +++ /dev/null @@ -1,10 +0,0 @@ -from typing import TypedDict - -class Movie(TypedDict): - name: str - year: int - -movie: Movie = {"name": "Blade Runner", "year": 1982} - -movie["director"] = "Ridley Scott" # E0093: invalid key -movie["year"] = "1982" # E0093: wrong value type diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0094_self_type_invalid_location.py b/crates/basilisk-cli/tests/fixtures/errors/e0094_self_type_invalid_location.py deleted file mode 100644 index 455046d56..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0094_self_type_invalid_location.py +++ /dev/null @@ -1,4 +0,0 @@ -from typing import Self - -def foo(bar: Self) -> Self: ... # E0094 — not within a class -bar: Self # E0094 — module-level Self diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0095_initvar_field.py b/crates/basilisk-cli/tests/fixtures/errors/e0095_initvar_field.py deleted file mode 100644 index b07548c32..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0095_initvar_field.py +++ /dev/null @@ -1,12 +0,0 @@ -from dataclasses import InitVar, dataclass - -@dataclass -class DC1: - x: InitVar[int] - y: InitVar[str] - - def __post_init__(self, x: int, y: int) -> None: # E0095: y should be str - pass - -dc1 = DC1(1, "") -dc1.x # E0095: cannot access InitVar field as attribute diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0096_dataclass_default_factory.py b/crates/basilisk-cli/tests/fixtures/errors/e0096_dataclass_default_factory.py deleted file mode 100644 index 7416752bd..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0096_dataclass_default_factory.py +++ /dev/null @@ -1,5 +0,0 @@ -from dataclasses import dataclass, field - -@dataclass -class DC: - a: int = field(default_factory=str) # E0096: str() -> str, not int diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0097_protocol_self_attr.py b/crates/basilisk-cli/tests/fixtures/errors/e0097_protocol_self_attr.py deleted file mode 100644 index 934438d7d..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0097_protocol_self_attr.py +++ /dev/null @@ -1,6 +0,0 @@ -from typing import Protocol - -class MyProto(Protocol): - x: int - def __init__(self) -> None: - self.y = 0 # E0097 — `y` is not declared in the Protocol diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0098_non_protocol_base.py b/crates/basilisk-cli/tests/fixtures/errors/e0098_non_protocol_base.py deleted file mode 100644 index 1d7434e14..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0098_non_protocol_base.py +++ /dev/null @@ -1,7 +0,0 @@ -from typing import Protocol - -class Base: - x: int = 0 - -class BadProto(Base, Protocol): # E0098 — Base is not a Protocol - def method(self) -> int: ... diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0099_protocol_instantiation.py b/crates/basilisk-cli/tests/fixtures/errors/e0099_protocol_instantiation.py deleted file mode 100644 index 50094712c..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0099_protocol_instantiation.py +++ /dev/null @@ -1,6 +0,0 @@ -from typing import Protocol - -class MyProto(Protocol): - def method(self) -> int: ... - -obj = MyProto() # E0099 — cannot instantiate a Protocol diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0100_literal_augmented_assign.py b/crates/basilisk-cli/tests/fixtures/errors/e0100_literal_augmented_assign.py deleted file mode 100644 index afa555bad..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0100_literal_augmented_assign.py +++ /dev/null @@ -1,4 +0,0 @@ -from typing import Literal - -def func(a: Literal[3, 4, 5]) -> None: - a += 3 # E0100 — augmented assign widens Literal type diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0101_typeguard_no_narrowing_param.py b/crates/basilisk-cli/tests/fixtures/errors/e0101_typeguard_no_narrowing_param.py deleted file mode 100644 index 9921bb288..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0101_typeguard_no_narrowing_param.py +++ /dev/null @@ -1,9 +0,0 @@ -from typing import TypeGuard, TypeIs - - -class Checker: - def is_int(self) -> TypeGuard[int]: # E: no narrowing parameter - return True - - def is_str(cls) -> TypeIs[str]: # E: no narrowing parameter - return True diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0102_typevar_default_violation.py b/crates/basilisk-cli/tests/fixtures/errors/e0102_typevar_default_violation.py deleted file mode 100644 index 2f9b4797b..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0102_typevar_default_violation.py +++ /dev/null @@ -1,4 +0,0 @@ -from typing import TypeVar - -T2 = TypeVar("T2", default=T1) # E: T1 not defined yet -T1 = TypeVar("T1") diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0103_tuple_index_out_of_bounds.py b/crates/basilisk-cli/tests/fixtures/errors/e0103_tuple_index_out_of_bounds.py deleted file mode 100644 index 59d7ac340..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0103_tuple_index_out_of_bounds.py +++ /dev/null @@ -1,3 +0,0 @@ -v: tuple[int, str, list[bool]] = (3, "hi", [True]) -v[4] # E: index 4 out of range for 3-element tuple -v[-4] # E: index -4 out of range for 3-element tuple diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0104_cyclical_type_alias.py b/crates/basilisk-cli/tests/fixtures/errors/e0104_cyclical_type_alias.py deleted file mode 100644 index 55f7500ca..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0104_cyclical_type_alias.py +++ /dev/null @@ -1,3 +0,0 @@ -from typing import TypeAlias, Union - -RecursiveUnion: TypeAlias = Union["RecursiveUnion", int] # E: cyclical reference diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0105_bounded_typevar_attr_access.py b/crates/basilisk-cli/tests/fixtures/errors/e0105_bounded_typevar_attr_access.py deleted file mode 100644 index 041f8a99d..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0105_bounded_typevar_attr_access.py +++ /dev/null @@ -1,3 +0,0 @@ -class C[T: str]: - def method(self, x: T) -> None: - x.is_integer() # E: str does not have is_integer diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0106_protocol_as_type.py b/crates/basilisk-cli/tests/fixtures/errors/e0106_protocol_as_type.py deleted file mode 100644 index 734488cab..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0106_protocol_as_type.py +++ /dev/null @@ -1,17 +0,0 @@ -from typing import Protocol - - -class Proto(Protocol): - def meth(self) -> int: ... - - -class Concrete: - def meth(self) -> int: - return 42 - - -def fun(cls: type[Proto]) -> int: - return cls().meth() - - -fun(Proto) # E: Protocol class passed to type[Proto] diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0107_variance_incompatibility.py b/crates/basilisk-cli/tests/fixtures/errors/e0107_variance_incompatibility.py deleted file mode 100644 index df8325246..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0107_variance_incompatibility.py +++ /dev/null @@ -1,12 +0,0 @@ -from typing import Generic, TypeVar - -T = TypeVar("T") -T_co = TypeVar("T_co", covariant=True) - - -class Base(Generic[T]): - pass - - -class Bad(Base[T_co]): # E: invariant param gets covariant arg - pass diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0108_dataclass_slots.py b/crates/basilisk-cli/tests/fixtures/errors/e0108_dataclass_slots.py deleted file mode 100644 index c29cb5780..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0108_dataclass_slots.py +++ /dev/null @@ -1,17 +0,0 @@ -from dataclasses import dataclass - - -@dataclass(slots=True) -class DC: - x: int - - def set_y(self) -> None: - self.y = 3 # E: "y" is not in __slots__ - - -@dataclass -class DC2: - a: int - - -DC2.__slots__ # E: __slots__ not defined diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0109_typevar_bound_violation.py b/crates/basilisk-cli/tests/fixtures/errors/e0109_typevar_bound_violation.py deleted file mode 100644 index e6839d09c..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0109_typevar_bound_violation.py +++ /dev/null @@ -1,11 +0,0 @@ -from typing import TypeVar - -TNum = TypeVar("TNum", bound=int) - - -def identity(s: TNum) -> TNum: - return s - - -def caller(s: str) -> None: - identity(s) # E: str is not a subtype of int diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0110_protocol_variance.py b/crates/basilisk-cli/tests/fixtures/errors/e0110_protocol_variance.py deleted file mode 100644 index f01a4bfce..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0110_protocol_variance.py +++ /dev/null @@ -1,7 +0,0 @@ -from typing import Protocol, TypeVar - -T = TypeVar("T") - - -class MyProto(Protocol[T]): # E: T should be covariant - def method(self) -> T: ... diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0111_constructor_call_errors.py b/crates/basilisk-cli/tests/fixtures/errors/e0111_constructor_call_errors.py deleted file mode 100644 index ec5b52df2..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0111_constructor_call_errors.py +++ /dev/null @@ -1,18 +0,0 @@ -from typing import Generic, TypeVar - -T = TypeVar("T") - - -class MyClass(Generic[T]): - def __init__(self, x: T) -> None: - self.x = x - - -MyClass[int](1.0) # E: float is not int - - -class NoInit: - pass - - -NoInit(42) # E: no custom __init__ with arguments diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0112_typeguard_callable_return.py b/crates/basilisk-cli/tests/fixtures/errors/e0112_typeguard_callable_return.py deleted file mode 100644 index c717da41f..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0112_typeguard_callable_return.py +++ /dev/null @@ -1,12 +0,0 @@ -from typing import Callable, TypeGuard - - -def takes_callable_str(f: Callable[[object], str]) -> None: - pass - - -def simple_typeguard(val: object) -> TypeGuard[int]: - return isinstance(val, int) - - -takes_callable_str(simple_typeguard) # E: TypeGuard is bool, not str diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0113_typeis_inconsistent_narrowing.py b/crates/basilisk-cli/tests/fixtures/errors/e0113_typeis_inconsistent_narrowing.py deleted file mode 100644 index 713a5ea2b..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0113_typeis_inconsistent_narrowing.py +++ /dev/null @@ -1,5 +0,0 @@ -from typing import TypeIs - - -def bad_narrowing(val: int) -> TypeIs[str]: # E: str is not consistent with int - return isinstance(val, str) diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0114_protocol_isinstance.py b/crates/basilisk-cli/tests/fixtures/errors/e0114_protocol_isinstance.py deleted file mode 100644 index 4bd6a9e78..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0114_protocol_isinstance.py +++ /dev/null @@ -1,17 +0,0 @@ -from typing import Protocol, runtime_checkable - - -class Proto1(Protocol): - name: str - - -@runtime_checkable -class Proto2(Protocol): - name: str - - def method(self) -> int: ... - - -x: object = object() -isinstance(x, Proto1) # E: not @runtime_checkable -issubclass(type(x), Proto2) # E: data protocol in issubclass diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0115_deprecated_usage.py b/crates/basilisk-cli/tests/fixtures/errors/e0115_deprecated_usage.py deleted file mode 100644 index 8aec25b18..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0115_deprecated_usage.py +++ /dev/null @@ -1,9 +0,0 @@ -from typing_extensions import deprecated - - -@deprecated("Use new_func instead") -def old_func() -> None: - pass - - -old_func() # E: use of deprecated function diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0116_namedtuple_definition.py b/crates/basilisk-cli/tests/fixtures/errors/e0116_namedtuple_definition.py deleted file mode 100644 index 660a8e9bf..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0116_namedtuple_definition.py +++ /dev/null @@ -1,6 +0,0 @@ -from typing import NamedTuple - - -class BadTuple(NamedTuple): - _hidden: int # E: field name starts with underscore - normal: str diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0117_unbound_typevar.py b/crates/basilisk-cli/tests/fixtures/errors/e0117_unbound_typevar.py deleted file mode 100644 index 14a8c8bff..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0117_unbound_typevar.py +++ /dev/null @@ -1,9 +0,0 @@ -from typing import TypeVar, Generic - -T = TypeVar("T") -S = TypeVar("S") - - -def fun(x: T) -> list[T]: - z: list[S] = [] # E: S is not bound in this function - return [x] diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0118_super_abstract_no_impl.py b/crates/basilisk-cli/tests/fixtures/errors/e0118_super_abstract_no_impl.py deleted file mode 100644 index aaef2c04d..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0118_super_abstract_no_impl.py +++ /dev/null @@ -1,13 +0,0 @@ -from typing import Protocol -from abc import abstractmethod - - -class PColor(Protocol): - @abstractmethod - def draw(self) -> str: - ... - - -class BadColor(PColor): - def draw(self) -> str: - return super().draw() # E: no default implementation diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0119_protocol_isinstance_overlap.py b/crates/basilisk-cli/tests/fixtures/errors/e0119_protocol_isinstance_overlap.py deleted file mode 100644 index 4bd6a9e78..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0119_protocol_isinstance_overlap.py +++ /dev/null @@ -1,17 +0,0 @@ -from typing import Protocol, runtime_checkable - - -class Proto1(Protocol): - name: str - - -@runtime_checkable -class Proto2(Protocol): - name: str - - def method(self) -> int: ... - - -x: object = object() -isinstance(x, Proto1) # E: not @runtime_checkable -issubclass(type(x), Proto2) # E: data protocol in issubclass diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0120_generator_return_type.py b/crates/basilisk-cli/tests/fixtures/errors/e0120_generator_return_type.py deleted file mode 100644 index 59db515c9..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0120_generator_return_type.py +++ /dev/null @@ -1,2 +0,0 @@ -def bad() -> int: - yield 1 # E: generator with non-generator return type diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0121_protocol_conformance.py b/crates/basilisk-cli/tests/fixtures/errors/e0121_protocol_conformance.py deleted file mode 100644 index 411796905..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0121_protocol_conformance.py +++ /dev/null @@ -1,12 +0,0 @@ -from typing import Protocol - - -class P(Protocol): - def method(self) -> None: ... - - -class C: - pass - - -x: P = C() # E: C does not implement method diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0122_callable_arity.py b/crates/basilisk-cli/tests/fixtures/errors/e0122_callable_arity.py deleted file mode 100644 index 5fe2c2a55..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0122_callable_arity.py +++ /dev/null @@ -1,7 +0,0 @@ -from typing import Callable - - -def takes_cb(cb: Callable[[int, str], bool]) -> None: - cb(1) # E: expected 2 arguments, got 1 - cb(1, "a", 3.0) # E: expected 2 arguments, got 3 - cb(x=1, y="a") # E: keyword arguments not allowed diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0123_super_abstract_protocol.py b/crates/basilisk-cli/tests/fixtures/errors/e0123_super_abstract_protocol.py deleted file mode 100644 index aaef2c04d..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0123_super_abstract_protocol.py +++ /dev/null @@ -1,13 +0,0 @@ -from typing import Protocol -from abc import abstractmethod - - -class PColor(Protocol): - @abstractmethod - def draw(self) -> str: - ... - - -class BadColor(PColor): - def draw(self) -> str: - return super().draw() # E: no default implementation diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0124_protocol_tuple_mismatch.py b/crates/basilisk-cli/tests/fixtures/errors/e0124_protocol_tuple_mismatch.py deleted file mode 100644 index 8c9c04bc8..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0124_protocol_tuple_mismatch.py +++ /dev/null @@ -1,10 +0,0 @@ -from typing import Protocol - - -class RGB(Protocol): - rgb: tuple[int, int, int] - - -class Point(RGB): - def __init__(self, red: int, green: int, blue: str) -> None: - self.rgb = red, green, blue # E: blue must be int not str diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0125_instance_attr_on_class.py b/crates/basilisk-cli/tests/fixtures/errors/e0125_instance_attr_on_class.py deleted file mode 100644 index 1505be313..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0125_instance_attr_on_class.py +++ /dev/null @@ -1,11 +0,0 @@ -from typing import Generic, TypeVar - -T = TypeVar("T") - - -class Node(Generic[T]): - label: T - - -Node[int].label = 1 # E: instance attribute on class -Node.label # E: instance attribute on class diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0126_literal_string_assignment.py b/crates/basilisk-cli/tests/fixtures/errors/e0126_literal_string_assignment.py deleted file mode 100644 index b7f1c5ca9..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0126_literal_string_assignment.py +++ /dev/null @@ -1,6 +0,0 @@ -from typing import Literal, LiteralString - - -def func(b: Literal["two"], non_literal: str) -> None: - x1: Literal[""] = b # E: different literal values - x2: LiteralString = f"{non_literal}" # E: non-literal in f-string diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0127_tuple_index_out_of_range.py b/crates/basilisk-cli/tests/fixtures/errors/e0127_tuple_index_out_of_range.py deleted file mode 100644 index 19457a87c..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0127_tuple_index_out_of_range.py +++ /dev/null @@ -1,7 +0,0 @@ -from typing import Literal - - -def f(v: tuple[int, str, list[bool]], b: Literal[5]) -> None: - v[b] # E: index 5 out of range for 3-element tuple - v[4] # E: index 4 out of range - v[-4] # E: index -4 out of range diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0128_typevar_default_referential.py b/crates/basilisk-cli/tests/fixtures/errors/e0128_typevar_default_referential.py deleted file mode 100644 index b9ce7d5fc..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0128_typevar_default_referential.py +++ /dev/null @@ -1,11 +0,0 @@ -from typing import TypeVar, Generic - -S1 = TypeVar("S1") -S2 = TypeVar("S2", default=S1) - -Start2T = TypeVar("Start2T", default="StopT") -Stop2T = TypeVar("Stop2T", default=int) - - -class slice2(Generic[Start2T, Stop2T]): # E: bad ordering - pass diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0129_literal_value_assignment.py b/crates/basilisk-cli/tests/fixtures/errors/e0129_literal_value_assignment.py deleted file mode 100644 index 3ea5d10ba..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0129_literal_value_assignment.py +++ /dev/null @@ -1,6 +0,0 @@ -from typing import Literal - - -def func(a: Literal[0], b: Literal[False]) -> None: - x1: Literal[False] = a # E: int 0 != bool False - x2: Literal[0] = b # E: bool False != int 0 diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0130_typevar_scoping.py b/crates/basilisk-cli/tests/fixtures/errors/e0130_typevar_scoping.py deleted file mode 100644 index f72ba14d1..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0130_typevar_scoping.py +++ /dev/null @@ -1,8 +0,0 @@ -from typing import TypeVar, Generic - -T = TypeVar("T") - - -class Outer(Generic[T]): - class Inner(Generic[T]): # E: reuses outer class TypeVar - pass diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0131_generator_yield_type.py b/crates/basilisk-cli/tests/fixtures/errors/e0131_generator_yield_type.py deleted file mode 100644 index ef3b2319f..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0131_generator_yield_type.py +++ /dev/null @@ -1,9 +0,0 @@ -from typing import Generator - - -class A: - pass - - -def bad() -> Generator[A, None, None]: - yield 3 # E: incompatible yield type (int vs A) diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0132_inconsistent_typevar_ordering.py b/crates/basilisk-cli/tests/fixtures/errors/e0132_inconsistent_typevar_ordering.py deleted file mode 100644 index 4dc17c9ce..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0132_inconsistent_typevar_ordering.py +++ /dev/null @@ -1,16 +0,0 @@ -from typing import TypeVar, Generic - -T1 = TypeVar("T1") -T2 = TypeVar("T2") - - -class Grandparent(Generic[T1, T2]): - pass - - -class Parent(Grandparent[T1, T2]): - pass - - -class BadChild(Parent[T1, T2], Grandparent[T2, T1]): # E: inconsistent ordering - pass diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0133_protocol_typevar_variance.py b/crates/basilisk-cli/tests/fixtures/errors/e0133_protocol_typevar_variance.py deleted file mode 100644 index f01a4bfce..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0133_protocol_typevar_variance.py +++ /dev/null @@ -1,7 +0,0 @@ -from typing import Protocol, TypeVar - -T = TypeVar("T") - - -class MyProto(Protocol[T]): # E: T should be covariant - def method(self) -> T: ... diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0134_invariant_generic_mismatch.py b/crates/basilisk-cli/tests/fixtures/errors/e0134_invariant_generic_mismatch.py deleted file mode 100644 index 358edb643..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0134_invariant_generic_mismatch.py +++ /dev/null @@ -1,19 +0,0 @@ -from typing import Generic, TypeVar - -T = TypeVar("T") - - -class Node: - pass - - -class SymbolTable(dict[str, list[Node]]): - pass - - -def takes(x: dict[str, list[object]]) -> None: - pass - - -def test(s: SymbolTable) -> None: - takes(s) # E: list is invariant, list[Node] != list[object] diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0136_callable_subtyping.py b/crates/basilisk-cli/tests/fixtures/errors/e0136_callable_subtyping.py deleted file mode 100644 index b478d4b8b..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0136_callable_subtyping.py +++ /dev/null @@ -1,8 +0,0 @@ -from typing import Callable - - -def func( - cb1: Callable[[float], int], - cb3: Callable[[int], int], -) -> None: - f6: Callable[[float], float] = cb3 # E: int param is not supertype of float diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0137_generic_protocol.py b/crates/basilisk-cli/tests/fixtures/errors/e0137_generic_protocol.py deleted file mode 100644 index 7c5085dcd..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0137_generic_protocol.py +++ /dev/null @@ -1,7 +0,0 @@ -from typing import Generic, Protocol, TypeVar - -T_co = TypeVar("T_co", covariant=True) - - -class Proto2(Protocol[T_co], Generic[T_co]): # E: Protocol[T] with Generic[T] - pass diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0138_dataclass_transform_metaclass.py b/crates/basilisk-cli/tests/fixtures/errors/e0138_dataclass_transform_metaclass.py deleted file mode 100644 index a1d42079e..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0138_dataclass_transform_metaclass.py +++ /dev/null @@ -1,18 +0,0 @@ -from typing import dataclass_transform - - -@dataclass_transform(kw_only_default=True) -class ModelMeta(type): - pass - - -class ModelBase(metaclass=ModelMeta): - pass - - -class Customer(ModelBase, frozen=True): - id: int - - -c = Customer(id=1) -c.id = 2 # E: frozen instance diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0139_typevartuple_specialization.py b/crates/basilisk-cli/tests/fixtures/errors/e0139_typevartuple_specialization.py deleted file mode 100644 index 35e352fc7..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0139_typevartuple_specialization.py +++ /dev/null @@ -1,8 +0,0 @@ -from typing import TypeVar, TypeVarTuple - -T = TypeVar("T") -Ts = TypeVarTuple("Ts") - -IntTupleGeneric = tuple[int, T] - -IntTupleGeneric[*Ts] # E: Ts is a TypeVarTuple, not a TypeVar diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0140_callable_assignment.py b/crates/basilisk-cli/tests/fixtures/errors/e0140_callable_assignment.py deleted file mode 100644 index 7262c1656..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0140_callable_assignment.py +++ /dev/null @@ -1,12 +0,0 @@ -from typing import Callable - - -def takes_two(a: int, b: str) -> bool: - return True - - -def takes_one(a: int) -> bool: - return True - - -x: Callable[[int, str], bool] = takes_one # E: signature mismatch diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0141_unpack_kwargs.py b/crates/basilisk-cli/tests/fixtures/errors/e0141_unpack_kwargs.py deleted file mode 100644 index dfd84cf38..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0141_unpack_kwargs.py +++ /dev/null @@ -1,10 +0,0 @@ -from typing import TypedDict, Unpack - - -class Options(TypedDict): - name: str - age: int - - -def func(name: str, **kwargs: Unpack[Options]) -> None: # E: name overlaps with TypedDict key - pass diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0142_dataclass_transform_base.py b/crates/basilisk-cli/tests/fixtures/errors/e0142_dataclass_transform_base.py deleted file mode 100644 index 58b3d5e34..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0142_dataclass_transform_base.py +++ /dev/null @@ -1,14 +0,0 @@ -from typing import dataclass_transform - - -@dataclass_transform(kw_only_default=True) -class ModelBase: - pass - - -class Customer(ModelBase, frozen=True): - id: int - - -c = Customer(3) # E: kw_only requires keyword args -c.id = 4 # E: frozen instance is immutable diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0143_namedtuple_usage.py b/crates/basilisk-cli/tests/fixtures/errors/e0143_namedtuple_usage.py deleted file mode 100644 index b932d7846..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0143_namedtuple_usage.py +++ /dev/null @@ -1,12 +0,0 @@ -from typing import NamedTuple - - -class Point(NamedTuple): - x: int - y: int - units: str = "meters" - - -p = Point(1, 2) -p[3] # E: out-of-bounds index -p.x = 3 # E: NamedTuple fields are read-only diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0144_type_call_constructor.py b/crates/basilisk-cli/tests/fixtures/errors/e0144_type_call_constructor.py deleted file mode 100644 index 9cffa49fd..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0144_type_call_constructor.py +++ /dev/null @@ -1,8 +0,0 @@ -class MyClass: - def __init__(self, x: int, y: str) -> None: - self.x = x - self.y = y - - -def factory(cls: type[MyClass]) -> MyClass: - return cls() # E: missing required arguments x, y diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0145_invalid_type_bracket.py b/crates/basilisk-cli/tests/fixtures/errors/e0145_invalid_type_bracket.py deleted file mode 100644 index 6c39c987b..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0145_invalid_type_bracket.py +++ /dev/null @@ -1,29 +0,0 @@ -from typing import Callable, TypeVar - -T = TypeVar("T") - - -def func5(x: type[T]) -> None: - pass - - -func5(Callable) # E: Callable is not a class - - -class A: - pass - - -class B: - pass - - -class C: - pass - - -def func4(x: type[A | B]) -> None: - pass - - -func4(C) # E: C is not A or B diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0146_protocol_class_object.py b/crates/basilisk-cli/tests/fixtures/errors/e0146_protocol_class_object.py deleted file mode 100644 index c926a50fa..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0146_protocol_class_object.py +++ /dev/null @@ -1,21 +0,0 @@ -from typing import Protocol - - -class Proto(Protocol): - def meth(self) -> int: ... - - -class Concrete: - def meth(self) -> int: - return 42 - - -def fun(cls: type[Proto]) -> int: - return cls().meth() - - -fun(Proto) # E: Protocol class itself passed to type[Proto] - -var: type[Proto] -var = Proto # E: Protocol class assigned to type[Proto] -var = Concrete # OK diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0147_tuple_starred_unpack.py b/crates/basilisk-cli/tests/fixtures/errors/e0147_tuple_starred_unpack.py deleted file mode 100644 index e4cb57295..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0147_tuple_starred_unpack.py +++ /dev/null @@ -1,2 +0,0 @@ -t1: tuple[int, *tuple[str]] = (1, "") -t1 = (1, "", "") # E: too many elements for *tuple[str] diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0148_generic_type_arg.py b/crates/basilisk-cli/tests/fixtures/errors/e0148_generic_type_arg.py deleted file mode 100644 index e7c2542b4..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0148_generic_type_arg.py +++ /dev/null @@ -1,11 +0,0 @@ -from typing import TypeVar - -AnyStr = TypeVar("AnyStr", str, bytes) - - -def concat(x: AnyStr, y: AnyStr) -> AnyStr: - return x + y - - -def bad(s: str, b: bytes) -> None: - concat(s, b) # E: constraint groups do not match diff --git a/crates/basilisk-cli/tests/fixtures/errors/e0149_pep695_type_param_scoping.py b/crates/basilisk-cli/tests/fixtures/errors/e0149_pep695_type_param_scoping.py deleted file mode 100644 index 3b6b240cf..000000000 --- a/crates/basilisk-cli/tests/fixtures/errors/e0149_pep695_type_param_scoping.py +++ /dev/null @@ -1,7 +0,0 @@ -class ClassB[S: Sequence[T], T]: # E: S's bound references T (later param) - pass - - -class ClassE[T]: - def method1[T](self) -> None: # E: method re-defines class type param T - pass diff --git a/crates/basilisk-cli/tests/fixtures/missing_both.py b/crates/basilisk-cli/tests/fixtures/missing_both.py deleted file mode 100644 index f0bb37f81..000000000 --- a/crates/basilisk-cli/tests/fixtures/missing_both.py +++ /dev/null @@ -1,6 +0,0 @@ -def broken(x, y): - return x + y - - -def also_broken(name): - return name diff --git a/crates/basilisk-cli/tests/fixtures/missing_param_annotation.py b/crates/basilisk-cli/tests/fixtures/missing_param_annotation.py deleted file mode 100644 index 33c6d1bed..000000000 --- a/crates/basilisk-cli/tests/fixtures/missing_param_annotation.py +++ /dev/null @@ -1,6 +0,0 @@ -def process(data) -> None: - pass - - -def transform(value, factor: float) -> float: - return value * factor diff --git a/crates/basilisk-cli/tests/fixtures/missing_return_annotation.py b/crates/basilisk-cli/tests/fixtures/missing_return_annotation.py deleted file mode 100644 index 5cae30fec..000000000 --- a/crates/basilisk-cli/tests/fixtures/missing_return_annotation.py +++ /dev/null @@ -1,6 +0,0 @@ -def fetch(url: str): - return url.encode() - - -def compute(x: int, y: int): - return x + y diff --git a/crates/basilisk-cli/tests/inert_cli.rs b/crates/basilisk-cli/tests/inert_cli.rs new file mode 100644 index 000000000..6d56db49c --- /dev/null +++ b/crates/basilisk-cli/tests/inert_cli.rs @@ -0,0 +1,189 @@ +//! The inert-CLI contract, exercised on the real binary. +//! +//! Implements [WITHDRAWAL-INERT]. See +//! docs/specs/DOCS-WITHDRAWAL-MESSAGING-SPEC.md#WITHDRAWAL-INERT +//! +//! These assertions are the whole product surface now: whatever a user or a CI +//! pipeline types, Basilisk must print the approved statement and fail. Unit +//! tests inside `main.rs` cannot prove that — the exit status, the emptiness of +//! stdout, and the fact that no file is touched are properties of the process. + +#![expect( + clippy::expect_used, + reason = "a test that cannot spawn the binary under test has nothing to assert" +)] + +use std::path::Path; +use std::process::{Command, Output}; + +/// The bytes the binary must print, from the same generated file it compiles in. +const NOTICE: &str = include_str!("../src/withdrawal_notice.txt"); + +/// `4` — unlisted ([CHKARCH-CLI-EXITCODES]). +const EXIT_UNLISTED: i32 = 4; + +/// Every argument shape a user or a stale pipeline could still send: the old +/// subcommands, the flags clap used to own, and nothing at all. +const INVOCATIONS: &[&[&str]] = &[ + &[], + &["check"], + &["check", "."], + &["check", "app.py", "--output", "json"], + &["analyze", "src/"], + &["format", "."], + &["format", "--check"], + &["fix", ".", "--unsafe"], + &["adopt"], + &["unadopt"], + &["lsp"], + &["lsp", "--transport", "ws", "--port", "8765"], + &["mcp"], + &["typeshed", "download"], + &["stubs", "status"], + &["createstub", "widget"], + &["--help"], + &["-h"], + &["help"], + &["--not-a-real-flag"], + &["--output", "json"], +]; + +fn run(args: &[&str], cwd: &Path) -> Output { + Command::new(env!("CARGO_BIN_EXE_basilisk")) + .args(args) + .current_dir(cwd) + .output() + .expect("the basilisk binary must be runnable") +} + +/// A project directory holding one file Basilisk would once have rewritten. +fn project() -> Result { + let dir = tempfile::tempdir()?; + std::fs::write( + dir.path().join("pyproject.toml"), + b"[tool.basilisk.rules]\n\"BSK-0001\" = \"error\"\n", + )?; + std::fs::write( + dir.path().join("app.py"), + b"def f(x) ->None :\n return x\n", + )?; + Ok(dir) +} + +/// Every invocation prints the approved notice to stderr, byte for byte. +#[test] +fn every_invocation_prints_the_notice_to_stderr() -> Result<(), Box> { + let dir = project()?; + for args in INVOCATIONS { + let output = run(args, dir.path()); + assert_eq!( + String::from_utf8_lossy(&output.stderr), + NOTICE, + "stderr must be the approved notice for `basilisk {}`", + args.join(" ") + ); + } + Ok(()) +} + +/// Stdout stays empty, always — `--output json > report.json` must yield an +/// empty file, never prose a consumer could parse as findings. +#[test] +fn every_invocation_writes_nothing_to_stdout() -> Result<(), Box> { + let dir = project()?; + for args in INVOCATIONS { + let output = run(args, dir.path()); + assert!( + output.stdout.is_empty(), + "stdout must stay empty for `basilisk {}`, got {:?}", + args.join(" "), + String::from_utf8_lossy(&output.stdout) + ); + } + Ok(()) +} + +/// Exit `4`. Never `0` (a pipeline must break), never `1` ("errors found" +/// would be one more incorrect result), never `2`/`3`. +#[test] +fn every_invocation_exits_four() -> Result<(), Box> { + let dir = project()?; + for args in INVOCATIONS { + let output = run(args, dir.path()); + assert_eq!( + output.status.code(), + Some(EXIT_UNLISTED), + "`basilisk {}` must exit {EXIT_UNLISTED}", + args.join(" ") + ); + } + Ok(()) +} + +/// No file is created, deleted, or rewritten — `fix`, `format` and `adopt` +/// used to edit source in place, and an inert binary must not. +#[test] +fn no_invocation_touches_the_workspace() -> Result<(), Box> { + let dir = project()?; + let source = std::fs::read(dir.path().join("app.py"))?; + let before = listing(dir.path())?; + for args in INVOCATIONS { + let _ = run(args, dir.path()); + } + assert_eq!( + std::fs::read(dir.path().join("app.py"))?, + source, + "source must be untouched" + ); + assert_eq!( + listing(dir.path())?, + before, + "no file may be added or removed" + ); + Ok(()) +} + +/// The directory's entries, sorted — a cache dir or a rewritten config would +/// show up here. +fn listing(dir: &Path) -> Result, std::io::Error> { + let mut names: Vec = std::fs::read_dir(dir)? + .filter_map(Result::ok) + .map(|entry| entry.file_name().to_string_lossy().into_owned()) + .collect(); + names.sort(); + Ok(names) +} + +/// `--version` is the one surface that still answers, and it answers on stdout +/// with exit 0: package managers and installed extensions probe it, and a +/// failure there hides the notice behind a broken install instead of showing it. +#[test] +fn version_still_answers() -> Result<(), Box> { + let dir = project()?; + for args in [&["--version"][..], &["--version", "--json"][..]] { + let output = run(args, dir.path()); + assert_eq!(output.status.code(), Some(0), "`{args:?}` must exit 0"); + assert!( + String::from_utf8_lossy(&output.stdout).contains("basilisk"), + "`{args:?}` must name the product on stdout" + ); + } + Ok(()) +} + +/// The version contract claims no capabilities. Advertising `lsp`/`mcp`/`dap` +/// to a tool that reads the contract would be a false claim about a binary +/// that starts no server. +#[test] +fn version_json_claims_no_capabilities() -> Result<(), Box> { + let dir = project()?; + let output = run(&["--version", "--json"], dir.path()); + let stdout = String::from_utf8_lossy(&output.stdout); + for capability in ["\"lsp\"", "\"mcp\"", "\"dap\"", "\"profiler\""] { + assert!( + !stdout.contains(capability), + "the inert binary must not advertise {capability}: {stdout}" + ); + } + Ok(()) +} diff --git a/crates/basilisk-cli/tests/lsp_stdio/mod.rs b/crates/basilisk-cli/tests/lsp_stdio/mod.rs deleted file mode 100644 index 2703c2d70..000000000 --- a/crates/basilisk-cli/tests/lsp_stdio/mod.rs +++ /dev/null @@ -1,238 +0,0 @@ -//! Tests for [LSPARCH-TESTING]. See docs/specs/LSP-ARCHITECTURE-SPEC.md#LSPARCH-TESTING -#![allow( - clippy::allow_attributes, - clippy::indexing_slicing, - clippy::expect_used, - clippy::unwrap_used, - clippy::panic, - // Shared harness methods are each used by SOME but not every test binary; - // `mod lsp_stdio` compiles into each binary independently, so a helper - // unused by one is not dead across the suite. - dead_code -)] -//! Shared harness for end-to-end tests that drive the **real compiled -//! `basilisk` binary** as an LSP server over stdio. -//! -//! Unlike the in-process WebSocket fixture (`basilisk-lsp/tests/lsp/ -//! ws_test_common.rs`), this harness exercises the production entry point -//! (`basilisk lsp` → `run_server()`), including its runtime construction — -//! required for bugs that only manifest in the real process (thread stack -//! sizes, PATH hermeticity, process exit behaviour). -//! -//! The server is spawned with `PATH` pointing at an empty directory, so the -//! binary must never need an external tool ([LSPFMT-DECISION]). - -use std::io::{BufRead, BufReader, Read, Write}; -use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio}; -use std::sync::atomic::{AtomicU64, Ordering}; -use std::time::{Duration, Instant}; - -use serde_json::{json, Value}; - -static DIR_COUNTER: AtomicU64 = AtomicU64::new(0); - -/// A unique, process-scoped temp directory path (not created). -pub fn unique_temp_dir(prefix: &str) -> std::path::PathBuf { - std::env::temp_dir().join(format!( - "{prefix}_{}_{}", - std::process::id(), - DIR_COUNTER.fetch_add(1, Ordering::Relaxed) - )) -} - -/// An LSP server subprocess speaking stdio JSON-RPC, spawned with a PATH on -/// which no external binary exists. -pub struct LspProcess { - child: Child, - stdin: ChildStdin, - reader: BufReader, - next_id: i64, - pub last_capabilities: Value, -} - -impl Drop for LspProcess { - fn drop(&mut self) { - // Shut the server down via the LSP protocol and wait for a clean - // exit: the binary may be coverage-instrumented, and a hard kill - // truncates its .profraw into corrupt, unmergeable profile data. - // Everything here is best-effort — never panic in Drop. - for body in [ - r#"{"jsonrpc":"2.0","id":999999,"method":"shutdown","params":null}"#, - r#"{"jsonrpc":"2.0","method":"exit","params":null}"#, - ] { - let framed = format!("Content-Length: {}\r\n\r\n{body}", body.len()); - let _ = self.stdin.write_all(framed.as_bytes()); - } - let _ = self.stdin.flush(); - for _ in 0..500 { - match self.child.try_wait() { - Ok(Some(_)) => return, - Ok(None) => std::thread::sleep(Duration::from_millis(10)), - Err(_) => break, - } - } - // Unresponsive after 5s — kill as a last resort. - let _ = self.child.kill(); - let _ = self.child.wait(); - } -} - -impl LspProcess { - /// Spawn `basilisk lsp` with PATH set to an empty directory, then run the - /// initialize handshake. - pub fn start() -> Self { - Self::start_with(None, &json!(null)) - } - - /// Like [`Self::start`], with a workspace root and/or initializationOptions. - pub fn start_with(root: Option<&std::path::Path>, initialization_options: &Value) -> Self { - let empty_path_dir = unique_temp_dir("bsk_lsp_stdio_path"); - std::fs::create_dir_all(&empty_path_dir).expect("create empty PATH dir"); - - let mut child = Command::new(env!("CARGO_BIN_EXE_basilisk")) - .arg("lsp") - .env("PATH", &empty_path_dir) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::null()) - .spawn() - .expect("spawn basilisk lsp"); - - let stdin = child.stdin.take().expect("child stdin"); - let stdout = child.stdout.take().expect("child stdout"); - let mut lsp = Self { - child, - stdin, - reader: BufReader::new(stdout), - next_id: 1, - last_capabilities: Value::Null, - }; - - let root_uri = root.map(|p| format!("file://{}", p.to_string_lossy())); - let init_result = lsp.request( - "initialize", - &json!({ - "processId": null, - "rootUri": root_uri, - "capabilities": {}, - "initializationOptions": initialization_options, - "trace": "off" - }), - ); - assert!( - init_result.get("capabilities").is_some(), - "initialize must return capabilities: {init_result}" - ); - lsp.last_capabilities = init_result["capabilities"].clone(); - lsp.notify("initialized", &json!({})); - lsp - } - - /// Send one framed JSON-RPC message. - fn send(&mut self, message: &Value) { - let body = message.to_string(); - let framed = format!("Content-Length: {}\r\n\r\n{body}", body.len()); - self.stdin - .write_all(framed.as_bytes()) - .expect("write to server stdin"); - self.stdin.flush().expect("flush server stdin"); - } - - pub fn notify(&mut self, method: &str, params: &Value) { - self.send(&json!({ "jsonrpc": "2.0", "method": method, "params": params })); - } - - /// Send a request and block until its response arrives, skipping - /// server-initiated notifications. Panics after 60s without a response. - pub fn request(&mut self, method: &str, params: &Value) -> Value { - let id = self.next_id; - self.next_id += 1; - self.send(&json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params })); - - let deadline = Instant::now() + Duration::from_mins(1); - while Instant::now() < deadline { - let message = self.read_message(); - if message.get("id").and_then(Value::as_i64) == Some(id) { - assert!( - message.get("error").is_none(), - "request {method} returned an error: {message}" - ); - return message["result"].clone(); - } - } - panic!("no response to {method} within 60s"); - } - - /// Block until the server sends the named notification, skipping every - /// other message. Panics if the deadline passes or the server dies first. - pub fn wait_for_notification(&mut self, method: &str, timeout: Duration) -> Value { - let deadline = Instant::now() + timeout; - while Instant::now() < deadline { - let message = self.read_message(); - if message.get("method").and_then(Value::as_str) == Some(method) { - return message; - } - } - panic!("no {method} notification within {timeout:?}"); - } - - /// Read a single Content-Length framed message from the server. - fn read_message(&mut self) -> Value { - let mut content_length: usize = 0; - loop { - let mut line = String::new(); - let read = self - .reader - .read_line(&mut line) - .expect("read header line from server"); - if read == 0 { - // Give the OS a moment to reap the child so the panic can - // report HOW the server died (e.g. SIGABRT after a stack - // overflow), not just that stdout closed. - std::thread::sleep(Duration::from_millis(200)); - let status = self.child.try_wait().ok().flatten(); - panic!("server closed stdout before responding (process status: {status:?})"); - } - let trimmed = line.trim(); - if trimmed.is_empty() { - break; - } - if let Some(value) = trimmed.strip_prefix("Content-Length:") { - content_length = value.trim().parse().expect("Content-Length value"); - } - } - let mut body = vec![0_u8; content_length]; - self.reader - .read_exact(&mut body) - .expect("read message body from server"); - serde_json::from_slice(&body).expect("server sent valid JSON") - } - - pub fn did_open(&mut self, uri: &str, text: &str) { - self.notify( - "textDocument/didOpen", - &json!({ - "textDocument": { - "uri": uri, - "languageId": "python", - "version": 1, - "text": text - } - }), - ); - } - - pub fn code_actions(&mut self, uri: &str) -> Value { - self.request( - "textDocument/codeAction", - &json!({ - "textDocument": { "uri": uri }, - "range": { - "start": { "line": 0, "character": 0 }, - "end": { "line": 0, "character": 0 } - }, - "context": { "diagnostics": [] } - }), - ) - } -} diff --git a/crates/basilisk-cli/tests/mcp_stdio_tests.rs b/crates/basilisk-cli/tests/mcp_stdio_tests.rs deleted file mode 100644 index 1ce57e85e..000000000 --- a/crates/basilisk-cli/tests/mcp_stdio_tests.rs +++ /dev/null @@ -1,222 +0,0 @@ -//! End-to-end tests for [MCP-STDIO] / [MCP-TYPESHED-STATUS]. -//! See docs/specs/CHECKER-MCP-SPEC.md. - -use std::io::Write as _; -use std::process::{Command, Stdio}; - -use serde_json::{json, Value}; - -const PROTOCOL_VERSION: &str = "2025-11-25"; - -fn custom_typeshed_workspace() -> Result> { - let workspace = tempfile::tempdir()?; - let stdlib = workspace.path().join("typeshed").join("stdlib"); - std::fs::create_dir_all(&stdlib)?; - std::fs::write(stdlib.join("VERSIONS"), "os: 3.8-\n")?; - std::fs::write(stdlib.join("os.pyi"), "def getcwd() -> str: ...\n")?; - std::fs::write( - workspace.path().join("pyproject.toml"), - "[tool.basilisk]\ntypeshed-path = \"typeshed\"\n", - )?; - Ok(workspace) -} - -fn run_session(workspace: &std::path::Path) -> 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") - .arg(workspace) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn()?; - let mut stdin = child.stdin.take().ok_or("MCP stdin was not piped")?; - stdin.write_all(payload)?; - drop(stdin); - let output = child.wait_with_output()?; - if !output.status.success() { - return Err(format!( - "MCP exited {:?}: {}", - output.status.code(), - String::from_utf8_lossy(&output.stderr) - ) - .into()); - } - String::from_utf8(output.stdout)? - .lines() - .map(|line| serde_json::from_str(line).map_err(Into::into)) - .collect() -} - -#[test] -fn stdio_server_lists_and_returns_ordered_structured_status( -) -> Result<(), Box> { - let workspace = custom_typeshed_workspace()?; - let responses = run_session(workspace.path())?; - assert_eq!( - responses.len(), - 3, - "notifications must not receive responses" - ); - let initialize = responses.first().ok_or("initialize response missing")?; - let list = responses.get(1).ok_or("tools/list response missing")?; - let call = responses.get(2).ok_or("tools/call response missing")?; - assert_eq!( - initialize - .pointer("/result/protocolVersion") - .and_then(Value::as_str), - Some(PROTOCOL_VERSION) - ); - assert_eq!( - list.pointer("/result/tools/0/name").and_then(Value::as_str), - Some("basilisk_typeshed_status") - ); - let status = call - .pointer("/result/structuredContent") - .ok_or("structuredContent missing")?; - assert_eq!( - status.get("active_source").and_then(Value::as_str), - Some("custom") - ); - assert_eq!( - status.get("license_status").and_then(Value::as_str), - Some("not supplied") - ); - assert!( - status.get("provenance").is_none(), - "active_source IS the trust story — no provenance field may reappear" - ); - assert!( - status.get("signed_release").is_none(), - "active_source IS the trust story — no signed_release field may reappear" - ); - let warnings = status - .get("warnings") - .and_then(Value::as_array) - .ok_or("ordered warnings missing")?; - assert_eq!( - warnings - .first() - .and_then(|warning| warning.get("code")) - .and_then(Value::as_str), - Some("typeshed_source_unpinned") - ); - assert!( - warnings.iter().any(|warning| { - warning.get("code").and_then(Value::as_str) == Some("typeshed_source_user_managed") - }), - "custom status must disclose user-managed contents and terms: {warnings:?}" - ); - assert_eq!( - call.pointer("/result/isError").and_then(Value::as_bool), - Some(false) - ); - Ok(()) -} - -#[test] -fn stdio_server_emits_only_json_rpc_on_stdout() -> Result<(), Box> { - let workspace = custom_typeshed_workspace()?; - for response in run_session(workspace.path())? { - assert_eq!(response.get("jsonrpc").and_then(Value::as_str), Some("2.0")); - assert!(response.get("id").is_some()); - } - Ok(()) -} - -#[test] -fn packaged_binary_advertises_mcp_capability() -> Result<(), Box> { - let output = Command::new(env!("CARGO_BIN_EXE_basilisk")) - .args(["--version", "--json"]) - .output()?; - if !output.status.success() { - return Err(format!( - "version command exited {:?}: {}", - output.status.code(), - String::from_utf8_lossy(&output.stderr) - ) - .into()); - } - let version: Value = serde_json::from_slice(&output.stdout)?; - let capabilities = version - .get("capabilities") - .and_then(Value::as_array) - .ok_or("Shipwright capabilities missing")?; - assert!( - capabilities - .iter() - .any(|entry| entry.as_str() == Some("mcp")), - "packaged basilisk binary must advertise MCP: {version}" - ); - Ok(()) -} - -/// [MCP-STDIO]: the spawned binary answers every malformed request shape with -/// the prescribed JSON-RPC error and keeps the session alive throughout — -/// invalid UTF-8, non-object requests, bad ids, missing methods, wrong -/// protocol versions, re-initialization, and unknown tools. -#[test] -fn protocol_guard_paths_respond_and_the_session_survives() -> 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 deleted file mode 100644 index e8d64035a..000000000 --- a/crates/basilisk-cli/tests/stub_cli_tests.rs +++ /dev/null @@ -1,230 +0,0 @@ -//! Binary coverage for [STUBRES-AUTOGEN]. -#![allow( - clippy::allow_attributes, - clippy::expect_used, - clippy::panic, - clippy::unwrap_used -)] - -use std::path::PathBuf; -use std::process::{Command, Output}; -use std::sync::atomic::{AtomicU64, Ordering}; - -static TEST_COUNTER: AtomicU64 = AtomicU64::new(0); - -struct TestProject { - root: PathBuf, -} - -impl TestProject { - fn new(name: &str) -> Result> { - let sequence = TEST_COUNTER.fetch_add(1, Ordering::Relaxed); - let root = std::env::temp_dir().join(format!( - "basilisk_stub_cli_{name}_{}_{sequence}", - std::process::id() - )); - let _ = std::fs::remove_dir_all(&root); - std::fs::create_dir_all(&root)?; - std::fs::write( - root.join("pyproject.toml"), - "[project]\nname = \"stub-cli-test\"\nversion = \"0.0.0\"\n", - )?; - Ok(Self { root }) - } - - fn write(&self, relative: &str, source: &str) -> Result { - let path = self.root.join(relative); - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent)?; - } - std::fs::write(&path, source)?; - Ok(path) - } - - fn command(&self) -> Command { - let mut command = Command::new(env!("CARGO_BIN_EXE_basilisk")); - let _ = command.current_dir(&self.root); - command - } - - fn generate_all(&self) -> Result { - self.command() - .args(["stubs", "generate", "--all", "--mode", "ast"]) - .output() - } -} - -impl Drop for TestProject { - fn drop(&mut self) { - let _ = std::fs::remove_dir_all(&self.root); - } -} - -fn output_text(output: &Output) -> String { - format!( - "stdout:\n{}\nstderr:\n{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ) -} - -fn site_packages(project: &TestProject) -> PathBuf { - project.root.join(".venv/lib/python3.12/site-packages") -} - -// Tests [STUBRES-AUTOGEN]: `stubs generate --all` discovers every untyped -// third-party import, deduplicates it, and ignores local or PEP 561-typed code. -#[test] -fn generate_all_discovers_only_untyped_project_imports() -> Result<(), Box> { - let project = TestProject::new("all")?; - let packages = site_packages(&project); - let _ = project.write( - ".venv/lib/python3.12/site-packages/alpha.py", - "def alpha_value(value: int) -> str:\n return str(value)\n", - )?; - let _ = project.write( - ".venv/lib/python3.12/site-packages/beta/__init__.py", - "def beta_value() -> int:\n return 2\n", - )?; - let _ = project.write( - ".venv/lib/python3.12/site-packages/typedpkg/__init__.py", - "def typed_value() -> int:\n return 3\n", - )?; - let _ = project.write(".venv/lib/python3.12/site-packages/typedpkg/py.typed", "")?; - let _ = project.write("localmod.py", "def local_value() -> int:\n return 4\n")?; - let _ = project.write( - "app.py", - "import alpha\nfrom beta import beta_value\nimport typedpkg\nimport localmod\n", - )?; - let _ = project.write("worker.py", "import alpha\n")?; - - assert!(packages.is_dir(), "test venv must expose site-packages"); - let output = project.generate_all()?; - let details = output_text(&output); - - assert!(output.status.success(), "{details}"); - let alpha = project.root.join(".basilisk/stubs/alpha.pyi"); - let beta = project.root.join(".basilisk/stubs/beta.pyi"); - assert!(alpha.is_file(), "alpha stub missing; {details}"); - assert!(beta.is_file(), "beta stub missing; {details}"); - assert!( - std::fs::read_to_string(alpha)?.contains("def alpha_value(value: int) -> str: ..."), - "alpha stub must come from its source" - ); - assert!( - std::fs::read_to_string(beta)?.contains("def beta_value() -> int: ..."), - "beta stub must come from its package source" - ); - assert!( - !project.root.join(".basilisk/stubs/typedpkg.pyi").exists(), - "a py.typed package must not be regenerated" - ); - assert!( - !project.root.join(".basilisk/stubs/localmod.pyi").exists(), - "first-party source must not be treated as an untyped dependency" - ); - assert_eq!( - String::from_utf8_lossy(&output.stdout) - .matches("Generated stub for `alpha`") - .count(), - 1, - "duplicate imports must generate one stub; {details}" - ); - Ok(()) -} - -// Tests [STUBRES-AUTOGEN]: Pyright's top-level `--createstub` spelling is an -// alias of the named-package `stubs generate` workflow, including mode flags. -#[test] -fn createstub_alias_generates_the_named_package() -> Result<(), Box> { - let project = TestProject::new("createstub")?; - let modules = project.root.join("python-modules"); - let _ = project.write( - "python-modules/aliaspkg.py", - "def aliased(value: str) -> int:\n return len(value)\n", - )?; - - let output = project - .command() - .env("PYTHONPATH", &modules) - .args(["--createstub", "aliaspkg", "--mode", "ast"]) - .output()?; - let details = output_text(&output); - - assert!(output.status.success(), "{details}"); - let stub = project.root.join(".basilisk/stubs/aliaspkg.pyi"); - assert!( - stub.is_file(), - "compatibility alias must write the stub; {details}" - ); - assert!( - std::fs::read_to_string(stub)?.contains("def aliased(value: str) -> int: ..."), - "compatibility alias must use the normal generation backend" - ); - Ok(()) -} - -/// GitHub #336: a module whose only names are private exposes no public API, so -/// the generated stub is declaration-free. The CLI must NOT report a false -/// "✓ Generated" success or write an empty `.pyi` (which would then satisfy -/// BSK-0152 as though the module were typed) — it warns and writes nothing. -#[test] -fn generate_writes_nothing_and_warns_for_a_module_with_no_public_api( -) -> Result<(), Box> { - let project = TestProject::new("no_public_api")?; - let _ = project.write( - ".venv/lib/python3.12/site-packages/hollow.py", - "_private = 1\ndef _helper() -> int:\n return 2\n", - )?; - let _ = project.write("app.py", "import hollow\n")?; - - let output = project.generate_all()?; - let details = output_text(&output); - let stdout = String::from_utf8_lossy(&output.stdout); - - assert!(output.status.success(), "{details}"); - assert!( - !project.root.join(".basilisk/stubs/hollow.pyi").exists(), - "a declaration-free stub must not be written; {details}" - ); - assert!( - !stdout.contains("Generated stub for `hollow`"), - "an empty stub must not be reported as a generation success; {details}" - ); - assert!( - stdout.contains("no introspectable public API"), - "the CLI must warn that `hollow` exposed nothing to stub; {details}" - ); - Ok(()) -} - -#[test] -fn generate_all_with_no_untyped_imports_succeeds() -> Result<(), Box> { - let project = TestProject::new("all_empty")?; - let _ = project.write("app.py", "import pathlib\n")?; - - let output = project.generate_all()?; - let details = output_text(&output); - - assert!(output.status.success(), "{details}"); - assert!( - !project.root.join(".basilisk/stubs").exists(), - "an empty discovery should not create a cache directory" - ); - Ok(()) -} - -/// [STUBRES-AUTOGEN]: `stubs status` on a project that never generated stubs -/// reports cleanly instead of erroring. -#[test] -fn status_reports_cleanly_when_nothing_was_generated() -> 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 deleted file mode 100644 index 32f50f781..000000000 --- a/crates/basilisk-cli/tests/typeshed_cli_status_tests.rs +++ /dev/null @@ -1,124 +0,0 @@ -//! Ordinary CLI acceptance for [STUBRES-TYPESHED-WARN]. -//! See docs/specs/CHECKER-STUB-RESOLUTION-SPEC.md#STUBRES-TYPESHED-WARN. - -use std::process::Command; - -#[test] -fn check_uses_custom_typeshed_and_routes_status_only_to_stderr( -) -> Result<(), Box> { - let workspace = tempfile::tempdir()?; - let stdlib = workspace.path().join("typeshed").join("stdlib"); - std::fs::create_dir_all(&stdlib)?; - std::fs::write(stdlib.join("VERSIONS"), "os: 3.8-\n")?; - std::fs::write(stdlib.join("os.pyi"), "def getcwd() -> str: ...\n")?; - 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\"\n", - )?; - - let output = Command::new(env!("CARGO_BIN_EXE_basilisk")) - .arg("check") - .arg(workspace.path().join("app.py")) - .args(["--output", "json", "--color", "never"]) - .output()?; - let stdout = String::from_utf8(output.stdout)?; - let stderr = String::from_utf8(output.stderr)?; - assert!( - output.status.success(), - "custom Typeshed check failed: stdout={stdout}; stderr={stderr}" - ); - let diagnostics: serde_json::Value = serde_json::from_str(&stdout)?; - assert_eq!(diagnostics, serde_json::json!([])); - // [STUBRES-TYPESHED-WARN] conformance invariant: source-status advisories - // NEVER enter the scored stdout JSON stream. - assert!( - !stdout.contains("typeshed_source"), - "advisories must stay off stdout: {stdout}" - ); - // The human banner renders on stderr like any other Basilisk diagnostic: - // `warning[]: ` plus a `= see:` deep link, in - // canonical status-table order, NOT key="VALUE" telemetry. - assert!( - stderr.contains("warning[typeshed_source_unpinned]:"), - "{stderr}" - ); - assert!( - stderr.contains("= see: https://www.basilisk-python.dev/errors/typeshed_source_unpinned"), - "each advisory must deep-link to its docs page: {stderr}" - ); - assert!( - !stderr.contains("warning_code="), - "the banner must not read like CLI-arg telemetry: {stderr}" - ); - let unpinned = stderr.find("warning[typeshed_source_unpinned]"); - let user_managed = stderr.find("warning[typeshed_source_user_managed]"); - assert!( - unpinned - .zip(user_managed) - .is_some_and(|(first, second)| first < second), - "status warnings must preserve canonical order: {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 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( - workspace.path().join("pyproject.toml"), - concat!( - "[tool.basilisk]\n", - "typeshed-commit = \"0000000000000000000000000000000000000000\"\n", - "typeshed-store-path = \"store\"\n", - ), - )?; - - let output = Command::new(env!("CARGO_BIN_EXE_basilisk")) - .arg("check") - .arg(workspace.path().join("app.py")) - .args(["--output", "json", "--color", "never"]) - .output()?; - let stdout = String::from_utf8(output.stdout)?; - let stderr = String::from_utf8(output.stderr)?; - assert_eq!( - output.status.code(), - Some(3), - "stdout={stdout}; stderr={stderr}" - ); - assert!( - stderr.contains("NO SOURCE") && stderr.contains("0000000000000000000000000000000000000000"), - "the failure must carry the spec's NO SOURCE line naming the pin: {stderr}" - ); - assert!( - !stderr.contains("typeshed source status"), - "a missing pin must not activate or report a fallback: {stderr}" - ); - Ok(()) -} diff --git a/crates/basilisk-common/README.md b/crates/basilisk-common/README.md index 360927776..62800fdb1 100644 --- a/crates/basilisk-common/README.md +++ b/crates/basilisk-common/README.md @@ -1,5 +1,12 @@ # basilisk-common +> **A record, not a product claim.** Basilisk is unlisted and its type checker is +> inert ([WITHDRAWAL](../../docs/specs/DOCS-WITHDRAWAL-MESSAGING-SPEC.md#WITHDRAWAL)). +> Nothing described below ships in anything a user can install: the `basilisk` +> binary analyses nothing, and the editor extensions carry no checker. This file +> is kept as an account of what was built, and nothing in it authorises +> rebuilding what it describes. + Shared constants and types for Basilisk — compiles to both native and `wasm32-wasip1`. ## Role in Basilisk @@ -14,4 +21,6 @@ This is the **shared foundation crate** with zero dependencies. It defines const ## Status -Complete — stable API consumed across the workspace. +Nothing user-facing consumes this crate. The Zed extension used to link it +for its shared command and diagnostic constants and no longer does; what +remains is consumed only by the language server, which ships in nothing. diff --git a/crates/basilisk-config/README.md b/crates/basilisk-config/README.md index 761276e2b..33bdf2591 100644 --- a/crates/basilisk-config/README.md +++ b/crates/basilisk-config/README.md @@ -1,5 +1,12 @@ # basilisk-config +> **A record, not a product claim.** Basilisk is unlisted and its type checker is +> inert ([WITHDRAWAL](../../docs/specs/DOCS-WITHDRAWAL-MESSAGING-SPEC.md#WITHDRAWAL)). +> Nothing described below ships in anything a user can install: the `basilisk` +> binary analyses nothing, and the editor extensions carry no checker. This file +> is kept as an account of what was built, and nothing in it authorises +> rebuilding what it describes. + Configuration parsing for Basilisk — reads `[tool.basilisk]` from `pyproject.toml`, the only configuration source. @@ -69,10 +76,6 @@ analysis modes. Those are separate planned/consumer concerns. ## Status -Parsing is consumed by `basilisk-checker`, `basilisk-cli`, and `basilisk-lsp`. -Validated mutation, ancestor-walk nearest-first discovery, content revisions, and -the editor API are implemented; the editor targets `pyproject.toml` only and -never reports a stray `basilisk.json` at all. Remaining -provenance, document-version safety, and domain consolidation work is tracked -in -[`LSP-CONFIGURATION-EDITOR-PLAN.md`](../../docs/plans/LSP-CONFIGURATION-EDITOR-PLAN.md). +Consumed only by crates that ship in nothing. No installed artefact reads +`[tool.basilisk]` any more — the inert CLI parses no arguments and opens no +files. diff --git a/crates/basilisk-db/README.md b/crates/basilisk-db/README.md index 1c11d71f8..df2c41184 100644 --- a/crates/basilisk-db/README.md +++ b/crates/basilisk-db/README.md @@ -1,10 +1,17 @@ # basilisk-db +> **A record, not a product claim.** Basilisk is unlisted and its type checker is +> inert ([WITHDRAWAL](../../docs/specs/DOCS-WITHDRAWAL-MESSAGING-SPEC.md#WITHDRAWAL)). +> Nothing described below ships in anything a user can install: the `basilisk` +> binary analyses nothing, and the editor extensions carry no checker. This file +> is kept as an account of what was built, and nothing in it authorises +> rebuilding what it describes. + Incremental computation database for Basilisk, built on the Salsa framework. ## Role in Basilisk -This crate provides the **caching and incremental recomputation layer** that makes the LSP fast. Instead of re-analyzing an entire project on every keystroke, Salsa tracks which inputs changed and only recomputes the affected outputs — delivering sub-10ms incremental checks. +This crate provides the **caching and incremental recomputation layer** for the language server. Instead of re-analyzing an entire project on every keystroke, Salsa tracks which inputs changed and only recomputes the affected outputs. ``` file edit ➜ [basilisk-db] ➜ only recompute what changed ➜ updated diagnostics @@ -25,4 +32,6 @@ file edit ➜ [basilisk-db] ➜ only recompute what changed ➜ updated diagnost ## Status -Working — powers the LSP's incremental analysis. +Consumed only by the language server, which ships in nothing. The cross-session +result cache this crate used to hold was deleted with the checking it cached +([CHECKER-CACHE-SPEC](../../docs/specs/CHECKER-CACHE-SPEC.md)). diff --git a/crates/basilisk-db/src/cache.rs b/crates/basilisk-db/src/cache.rs deleted file mode 100644 index a1e712bdf..000000000 --- a/crates/basilisk-db/src/cache.rs +++ /dev/null @@ -1,140 +0,0 @@ -//! Implements [CHKCACHE-ENTRY] / [CHKCACHE-FINGERPRINT]. -//! See docs/specs/CHECKER-CACHE-SPEC.md#CHKCACHE-ENTRY -//! -//! A generic, content-addressed, on-disk result cache. -//! -//! [`CheckCache`] is agnostic to *what* it caches: the consumer (the CLI) -//! supplies a serialisable payload (the diagnostics) and the fingerprint of the -//! non-file inputs (version, config, environment). The cache stores the exact -//! read-set captured during the check and, on lookup, re-verifies every recorded -//! file against its stored hash. A hit is returned only when the fingerprint and -//! every file match — the [`CHKCACHE-CONTRACT`] guarantee. - -use std::path::{Path, PathBuf}; - -use basilisk_common::fs::{canonical_key, content_hash, ReadSet}; -use serde::{de::DeserializeOwned, Deserialize, Serialize}; - -/// The non-file inputs that affect a check result. -/// -/// These are fingerprinted as a unit; any change forces a miss. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct Fingerprint { - /// Checker version (`CARGO_PKG_VERSION`). - pub version: String, - /// Hash of the effective configuration. - pub config_hash: u64, - /// Hash of the resolution environment (search paths, `uv.lock`). - pub env_hash: u64, - /// Identity of the active standard-library typeshed snapshot - /// ([STUBRES-TYPESHED], [CHKCACHE-FINGERPRINT]): the resolved commit/tree - /// SHA, a custom-tree digest, or the bundled-snapshot SHA. - /// - /// This is a *runtime* identity that [`Self::config_hash`] cannot - /// represent: unpinned "Latest" can resolve to different commits between - /// runs, and a bundled fallback substitutes different `.pyi` bodies, all - /// under byte-identical configuration. A change forces a miss so cached - /// diagnostics never outlive the stubs that produced them. - pub typeshed_id: String, -} - -/// A persistent result cache rooted at a directory. -#[derive(Debug, Clone)] -pub struct CheckCache { - dir: PathBuf, -} - -#[derive(Serialize, Deserialize)] -struct Dep { - path: String, - hash: u64, -} - -#[derive(Serialize, Deserialize)] -struct Entry { - version: String, - config_hash: u64, - env_hash: u64, - typeshed_id: String, - deps: Vec, - payload: T, -} - -impl CheckCache { - /// Create a cache rooted at `dir`. The directory is created lazily on the - /// first [`CheckCache::store`]. - #[must_use] - pub fn new(dir: PathBuf) -> Self { - Self { dir } - } - - /// Return the cached payload for `target` iff the fingerprint matches and - /// every recorded dependency is byte-identical to when it was stored. - /// - /// Returns `None` on any mismatch, missing/unreadable dependency, or - /// unparseable entry — never a stale result. - // Implements [CHKCACHE-CONTRACT] - #[must_use] - pub fn lookup( - &self, - target: &Path, - fingerprint: &Fingerprint, - ) -> Option { - let raw = std::fs::read_to_string(self.entry_path(target)).ok()?; - let entry: Entry = serde_json::from_str(&raw).ok()?; - if entry.version != fingerprint.version - || entry.config_hash != fingerprint.config_hash - || entry.env_hash != fingerprint.env_hash - || entry.typeshed_id != fingerprint.typeshed_id - { - return None; - } - entry - .deps - .iter() - .all(Self::dep_unchanged) - .then_some(entry.payload) - } - - /// Store `payload` for `target`, recording the exact `read_set` so a future - /// lookup can re-verify it. - /// - /// # Errors - /// - /// Returns an [`std::io::Error`] if the cache directory or entry file cannot - /// be written, or if the payload cannot be serialised. - pub fn store( - &self, - target: &Path, - fingerprint: &Fingerprint, - read_set: ReadSet, - payload: &T, - ) -> std::io::Result<()> { - std::fs::create_dir_all(&self.dir)?; - let deps = read_set - .into_iter() - .map(|(path, hash)| Dep { path, hash }) - .collect(); - let entry = Entry { - version: fingerprint.version.clone(), - config_hash: fingerprint.config_hash, - env_hash: fingerprint.env_hash, - typeshed_id: fingerprint.typeshed_id.clone(), - deps, - payload, - }; - let raw = serde_json::to_string(&entry).map_err(std::io::Error::other)?; - std::fs::write(self.entry_path(target), raw) - } - - /// Re-read a recorded dependency and compare against its stored hash. - fn dep_unchanged(dep: &Dep) -> bool { - std::fs::read_to_string(&dep.path).is_ok_and(|current| content_hash(¤t) == dep.hash) - } - - /// On-disk path for a target's entry: `/.json`. - fn entry_path(&self, target: &Path) -> PathBuf { - let key = canonical_key(target); - self.dir.join(format!("{:016x}.json", content_hash(&key))) - } -} diff --git a/crates/basilisk-db/src/lib.rs b/crates/basilisk-db/src/lib.rs index 58eb67628..8654dde10 100644 --- a/crates/basilisk-db/src/lib.rs +++ b/crates/basilisk-db/src/lib.rs @@ -1,20 +1,20 @@ //! Implements [CHKARCH-INCREMENTAL-SALSA]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-INCREMENTAL-SALSA //! Incremental computation database for Basilisk. //! -//! Two complementary layers live here: +//! One layer lives here: the **in-session** Salsa engine +//! ([CHKARCH-INCREMENTAL-SALSA]). The [`db::SourceFile`] input feeds a +//! demand-driven query graph whose derived queries (parse → resolve → check, +//! defined in the upstream crates) re-run only when an input they actually read +//! changed. //! -//! - [`db`] — the **in-session** Salsa engine ([CHKARCH-INCREMENTAL-SALSA]). The -//! [`db::SourceFile`] input feeds a demand-driven query graph whose derived -//! queries (parse → resolve → check, defined in the upstream crates) re-run -//! only when an input they actually read changed. This is what makes an edit -//! recompute one file instead of the whole workspace. -//! - [`cache`] — the **cross-session** content-addressed result cache -//! ([CHKCACHE](../../../docs/specs/CHECKER-CACHE-SPEC.md), -//! [CHKARCH-INCREMENTAL-CACHE]). It persists diagnostics keyed by their exact -//! read-set so a fresh process skips re-checking files that did not change on -//! disk, eliminating cold-start cost. +//! The **cross-session** result cache used to live here too. It persisted +//! diagnostics keyed by their read-set so a fresh process could skip files that +//! had not changed on disk — a cold-start optimisation for `basilisk check +//! --cache`. That command is gone: the CLI is inert ([WITHDRAWAL-INERT]) and +//! checks nothing, so there are no results to cache and nothing that reads +//! them. The cache is deleted rather than kept warm for a rebuild that will not +//! reuse this code. -pub mod cache; pub mod db; pub use db::{BasiliskDatabase, Db, SourceFile}; diff --git a/crates/basilisk-db/tests/cache_tests.rs b/crates/basilisk-db/tests/cache_tests.rs deleted file mode 100644 index 311bc8ca1..000000000 --- a/crates/basilisk-db/tests/cache_tests.rs +++ /dev/null @@ -1,169 +0,0 @@ -//! Tests for [CHKCACHE-ENTRY] / [CHKCACHE-FINGERPRINT] / [CHKCACHE-CONTRACT]. -//! 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. - -use std::collections::{BTreeMap, HashMap}; -use std::path::{Path, PathBuf}; -use std::sync::atomic::{AtomicU64, Ordering}; - -use basilisk_common::fs::{canonical_key, content_hash}; -use basilisk_db::cache::{CheckCache, Fingerprint}; - -/// A fresh, unique temp directory for one test. -fn temp_dir(name: &str) -> PathBuf { - static CTR: AtomicU64 = AtomicU64::new(0); - let n = CTR.fetch_add(1, Ordering::Relaxed); - let dir = std::env::temp_dir().join(format!("bsk_db_{name}_{}_{n}", std::process::id())); - std::fs::create_dir_all(&dir).expect("create temp dir"); - dir -} - -fn fingerprint() -> Fingerprint { - Fingerprint { - version: "1.2.3".to_owned(), - config_hash: 42, - env_hash: 7, - typeshed_id: "commit:6ef9f7719ecfff09dad8724ef42b621fd994fb5e".to_owned(), - } -} - -/// Write a source file and return its `(canonical key, content hash)`. -fn write_dep(dir: &Path, name: &str, contents: &str) -> (PathBuf, String, u64) { - let path = dir.join(name); - std::fs::write(&path, contents).expect("write dep"); - let key = canonical_key(&path); - (path, key, content_hash(contents)) -} - -#[test] -fn roundtrip_hit_returns_payload() { - let dir = temp_dir("roundtrip"); - let cache = CheckCache::new(dir.join("cache")); - let (target, key, hash) = write_dep(&dir, "t.py", "x = 1\n"); - let read_set: BTreeMap = [(key, hash)].into_iter().collect(); - - cache - .store(&target, &fingerprint(), read_set, &vec!["diag".to_owned()]) - .expect("store"); - let hit: Option> = cache.lookup(&target, &fingerprint()); - assert_eq!(hit, Some(vec!["diag".to_owned()]), "unchanged inputs → hit"); -} - -#[test] -fn missing_entry_is_a_miss() { - let dir = temp_dir("noentry"); - let cache = CheckCache::new(dir.join("cache")); - let (target, ..) = write_dep(&dir, "t.py", "x = 1\n"); - let miss: Option> = cache.lookup(&target, &fingerprint()); - assert_eq!(miss, None, "no stored entry → miss"); -} - -#[test] -fn corrupt_entry_is_a_miss() { - let dir = temp_dir("corrupt"); - let cache_dir = dir.join("cache"); - let cache = CheckCache::new(cache_dir.clone()); - let (target, key, hash) = write_dep(&dir, "t.py", "x = 1\n"); - let read_set: BTreeMap = [(key, hash)].into_iter().collect(); - cache - .store(&target, &fingerprint(), read_set, &vec!["diag".to_owned()]) - .expect("store"); - - // Overwrite the single entry file with garbage. - for entry in std::fs::read_dir(&cache_dir).expect("read cache dir") { - std::fs::write(entry.expect("dir entry").path(), "not json").expect("corrupt"); - } - let miss: Option> = cache.lookup(&target, &fingerprint()); - assert_eq!(miss, None, "unparseable entry → miss, never a panic"); -} - -#[test] -fn fingerprint_mismatches_are_misses() { - let dir = temp_dir("fp"); - let cache = CheckCache::new(dir.join("cache")); - let (target, key, hash) = write_dep(&dir, "t.py", "x = 1\n"); - let read_set: BTreeMap = [(key, hash)].into_iter().collect(); - cache - .store(&target, &fingerprint(), read_set, &vec!["diag".to_owned()]) - .expect("store"); - - let bumped_version = Fingerprint { - version: "9.9.9".to_owned(), - ..fingerprint() - }; - let bumped_config = Fingerprint { - config_hash: 999, - ..fingerprint() - }; - let bumped_env = Fingerprint { - env_hash: 999, - ..fingerprint() - }; - // [STUBRES-TYPESHED]: the active typeshed snapshot is part of the identity. - // A moved `main`, a bundled fallback, or a re-pinned commit resolves to a - // different snapshot under otherwise-identical inputs and MUST miss so - // cached diagnostics never outlive the stubs that produced them. - let bumped_typeshed = Fingerprint { - typeshed_id: "bundled:83c2518a9e6abbda0c44592c3483de459198f887".to_owned(), - ..fingerprint() - }; - for (label, fp) in [ - ("version", bumped_version), - ("config", bumped_config), - ("env", bumped_env), - ("typeshed", bumped_typeshed), - ] { - let miss: Option> = cache.lookup(&target, &fp); - assert_eq!(miss, None, "{label} change must force a miss"); - } -} - -#[test] -fn changed_dependency_is_a_miss() { - let dir = temp_dir("changedep"); - let cache = CheckCache::new(dir.join("cache")); - let (target, tkey, thash) = write_dep(&dir, "a.py", "import b\n"); - let (dep, dkey, dhash) = write_dep(&dir, "b.py", "old\n"); - let read_set: BTreeMap = [(tkey, thash), (dkey, dhash)].into_iter().collect(); - cache - .store(&target, &fingerprint(), read_set, &vec!["diag".to_owned()]) - .expect("store"); - - std::fs::write(&dep, "new contents\n").expect("edit dep"); - let miss: Option> = cache.lookup(&target, &fingerprint()); - assert_eq!(miss, None, "a changed dependency must force a miss"); -} - -#[test] -fn missing_dependency_is_a_miss() { - let dir = temp_dir("missdep"); - let cache = CheckCache::new(dir.join("cache")); - let (target, tkey, thash) = write_dep(&dir, "a.py", "import b\n"); - let (dep, dkey, dhash) = write_dep(&dir, "b.py", "data\n"); - let read_set: BTreeMap = [(tkey, thash), (dkey, dhash)].into_iter().collect(); - cache - .store(&target, &fingerprint(), read_set, &vec!["diag".to_owned()]) - .expect("store"); - - std::fs::remove_file(&dep).expect("remove dep"); - let miss: Option> = cache.lookup(&target, &fingerprint()); - assert_eq!(miss, None, "a deleted dependency must force a miss"); -} - -#[test] -fn unserialisable_payload_errors() { - let dir = temp_dir("badpayload"); - let cache = CheckCache::new(dir.join("cache")); - let (target, key, hash) = write_dep(&dir, "t.py", "x = 1\n"); - let read_set: BTreeMap = [(key, hash)].into_iter().collect(); - - // A map with non-string keys cannot be serialised to JSON → store errors. - let payload: HashMap<(i32, i32), i32> = [((1, 2), 3)].into_iter().collect(); - let result = cache.store(&target, &fingerprint(), read_set, &payload); - assert!( - result.is_err(), - "an unserialisable payload must surface an error" - ); -} diff --git a/crates/basilisk-lsp/README.md b/crates/basilisk-lsp/README.md index f9e3ddcc8..f6aa5c325 100644 --- a/crates/basilisk-lsp/README.md +++ b/crates/basilisk-lsp/README.md @@ -1,5 +1,12 @@ # basilisk-lsp +> **A record, not a product claim.** Basilisk is unlisted and its type checker is +> inert ([WITHDRAWAL](../../docs/specs/DOCS-WITHDRAWAL-MESSAGING-SPEC.md#WITHDRAWAL)). +> Nothing described below ships in anything a user can install: the `basilisk` +> binary analyses nothing, and the editor extensions carry no checker. This file +> is kept as an account of what was built, and nothing in it authorises +> rebuilding what it describes. + Language Server Protocol implementation for Basilisk. ## Role in Basilisk @@ -12,7 +19,7 @@ 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. +- **Beyond diagnostics** — the server also implemented completions, hover, go-to-definition, find references, rename, code actions, and inlay hints. - **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. @@ -43,4 +50,6 @@ defines reaches this crate through `basilisk-checker`'s re-export. ## Status -Working — diagnostics, hover, go-to-definition, code actions, inlay hints, debugging, and refactoring are all shipping. +The language server ships in nothing. `basilisk lsp` no longer exists — the CLI +parses no arguments and starts no server — and no editor extension launches +one. Nothing described above runs for a user. diff --git a/crates/basilisk-lsp/tests/lsp/lsp_e2e_advanced.rs b/crates/basilisk-lsp/tests/lsp/lsp_e2e_advanced.rs deleted file mode 100644 index 73168f4fa..000000000 --- a/crates/basilisk-lsp/tests/lsp/lsp_e2e_advanced.rs +++ /dev/null @@ -1,305 +0,0 @@ -//! Tests for [LSPARCH-TESTING]. See docs/specs/LSP-ARCHITECTURE-SPEC.md#LSPARCH-TESTING -// Tests for LSP: `lsp_e2e_advanced`. - -// LSP E2E tests — Capabilities, Folding, Selection, Code Lens, Highlight, -// didSave, Workspace Symbols, Formatting, Execute Command. - -use super::lsp_e2e_common::{send_request, LspTestFixture, TestResult}; - -// ── Capability advertisement ───────────────────────────────────────────────── - -#[test] -fn test_lsp_initialize_advertises_new_capabilities() -> TestResult<()> { - let mut fixture = LspTestFixture::new()?; - let response = fixture.initialize()?; - - assert!( - response.contains("\"definitionProvider\""), - "should advertise definition: {response}" - ); - assert!( - response.contains("\"documentSymbolProvider\""), - "should advertise document symbols: {response}" - ); - assert!( - response.contains("\"signatureHelpProvider\""), - "should advertise signature help: {response}" - ); - assert!( - response.contains("\"referencesProvider\""), - "should advertise references: {response}" - ); - assert!( - response.contains("\"renameProvider\""), - "should advertise rename: {response}" - ); - assert!( - response.contains("\"inlayHintProvider\""), - "should advertise inlay hints: {response}" - ); - assert!( - response.contains("\"semanticTokensProvider\""), - "should advertise semantic tokens: {response}" - ); - assert!( - response.contains("\"declarationProvider\""), - "should advertise declaration: {response}" - ); - assert!( - response.contains("\"typeDefinitionProvider\""), - "should advertise type definition: {response}" - ); - Ok(()) -} - -// ── Folding Ranges ─────────────────────────────────────────────────────────── - -#[test] -fn test_lsp_folding_range() -> TestResult<()> { - let mut fixture = LspTestFixture::new()?; - let _ = fixture.initialize()?; - - let code = "\ -class Animal: - name: str - def speak(self) -> str: - return self.name - -def greet(name: str) -> str: - return f\"Hello, {name}!\" -"; - fixture.did_open("file:///fold.py", code)?; - let _ = fixture.wait_for_diagnostics(); - - let resp = send_request( - &mut fixture, - 300, - "textDocument/foldingRange", - serde_json::json!({ - "textDocument": { "uri": "file:///fold.py" } - }), - )? - .ok_or("no foldingRange response")?; - - assert!(resp.contains("\"result\""), "should have a result: {resp}"); - assert!( - resp.contains("startLine"), - "should contain folding ranges with startLine: {resp}" - ); - Ok(()) -} - -// ── Selection Ranges (Smart Select) ────────────────────────────────────────── - -#[test] -fn test_lsp_selection_range() -> TestResult<()> { - let mut fixture = LspTestFixture::new()?; - let _ = fixture.initialize()?; - - let code = "\ -def greet(name: str) -> str: - return f\"Hello, {name}!\" -"; - fixture.did_open("file:///sel.py", code)?; - let _ = fixture.wait_for_diagnostics(); - - let resp = send_request( - &mut fixture, - 301, - "textDocument/selectionRange", - serde_json::json!({ - "textDocument": { "uri": "file:///sel.py" }, - "positions": [{ "line": 0, "character": 4 }] - }), - )? - .ok_or("no selectionRange response")?; - - assert!(resp.contains("\"result\""), "should have a result: {resp}"); - Ok(()) -} - -// ── Code Lens ──────────────────────────────────────────────────────────────── - -#[test] -fn test_lsp_code_lens() -> TestResult<()> { - let mut fixture = LspTestFixture::new()?; - let _ = fixture.initialize()?; - - let code = "\ -def greet(name: str) -> str: - return f\"Hello, {name}!\" - -def caller() -> None: - greet(\"world\") - greet(\"test\") -"; - fixture.did_open("file:///lens.py", code)?; - let _ = fixture.wait_for_diagnostics(); - - let resp = send_request( - &mut fixture, - 302, - "textDocument/codeLens", - serde_json::json!({ - "textDocument": { "uri": "file:///lens.py" } - }), - )? - .ok_or("no codeLens response")?; - - assert!(resp.contains("\"result\""), "should have a result: {resp}"); - Ok(()) -} - -// ── Document Highlight ─────────────────────────────────────────────────────── - -#[test] -fn test_lsp_document_highlight() -> TestResult<()> { - let mut fixture = LspTestFixture::new()?; - let _ = fixture.initialize()?; - - let code = "\ -def greet(name: str) -> str: - return name -"; - fixture.did_open("file:///hl.py", code)?; - let _ = fixture.wait_for_diagnostics(); - - let resp = send_request( - &mut fixture, - 303, - "textDocument/documentHighlight", - serde_json::json!({ - "textDocument": { "uri": "file:///hl.py" }, - "position": { "line": 0, "character": 10 } - }), - )? - .ok_or("no documentHighlight response")?; - - assert!(resp.contains("\"result\""), "should have a result: {resp}"); - Ok(()) -} - -// ── didSave re-checks diagnostics ──────────────────────────────────────────── - -#[test] -fn test_lsp_did_save_rechecks() -> TestResult<()> { - let mut fixture = LspTestFixture::new()?; - let _ = fixture.initialize()?; - - let code = "def greet(name: str) -> str:\n return f\"Hello, {name}!\""; - fixture.did_open("file:///save.py", code)?; - let _ = fixture.wait_for_diagnostics(); - - fixture.send_json(&serde_json::json!({ - "jsonrpc": "2.0", - "method": "textDocument/didSave", - "params": { - "textDocument": { "uri": "file:///save.py" } - } - }))?; - - let diag = fixture - .wait_for_diagnostics() - .ok_or("no diagnostics after save")?; - - assert!( - diag.contains("\"diagnostics\":[]"), - "clean code should have empty diagnostics after save: {diag}" - ); - Ok(()) -} - -// ── Workspace Symbols ──────────────────────────────────────────────────────── - -#[test] -fn test_lsp_workspace_symbol() -> TestResult<()> { - let mut fixture = LspTestFixture::new()?; - let _ = fixture.initialize()?; - - let code = "\ -class Animal: - name: str - -def greet(name: str) -> str: - return name -"; - fixture.did_open("file:///wssym.py", code)?; - let _ = fixture.wait_for_diagnostics(); - - let resp = send_request( - &mut fixture, - 314, - "workspace/symbol", - serde_json::json!({ - "query": "greet" - }), - )? - .ok_or("no workspace/symbol response")?; - - assert!(resp.contains("\"result\""), "should have a result: {resp}"); - assert!( - resp.contains("greet"), - "workspace symbols should contain 'greet': {resp}" - ); - Ok(()) -} - -// ── Document Formatting (embedded Ruff formatter, [LSPFMT-ENGINE]) ─────────── - -#[test] -fn test_lsp_formatting() -> TestResult<()> { - let mut fixture = LspTestFixture::new()?; - let _ = fixture.initialize()?; - - let code = "def greet( name:str )->str:\n return name\n"; - fixture.did_open("file:///fmt.py", code)?; - let _ = fixture.wait_for_diagnostics(); - - let resp = send_request( - &mut fixture, - 315, - "textDocument/formatting", - serde_json::json!({ - "textDocument": { "uri": "file:///fmt.py" }, - "options": { - "tabSize": 4, - "insertSpaces": true - } - }), - )? - .ok_or("no formatting response")?; - - assert!(resp.contains("\"result\""), "should have a result: {resp}"); - // The embedded engine is always present — badly formatted code MUST - // produce the Ruff-formatted output, never a silent null (#254). - assert!( - resp.contains("def greet(name: str) -> str:"), - "formatting must produce ruff-format output: {resp}" - ); - Ok(()) -} - -// ── Execute Command ────────────────────────────────────────────────────────── - -#[test] -fn test_lsp_execute_command_unknown() -> TestResult<()> { - let mut fixture = LspTestFixture::new()?; - let _ = fixture.initialize()?; - - let resp = send_request( - &mut fixture, - 316, - "workspace/executeCommand", - serde_json::json!({ - "command": "basilisk.nonExistentCommand", - "arguments": [] - }), - )? - .ok_or("no executeCommand response")?; - - assert!( - resp.contains("\"result\""), - "should have a result (null) for unknown command: {resp}" - ); - Ok(()) -} diff --git a/crates/basilisk-lsp/tests/lsp/lsp_e2e_basics.rs b/crates/basilisk-lsp/tests/lsp/lsp_e2e_basics.rs deleted file mode 100644 index 911cc3f30..000000000 --- a/crates/basilisk-lsp/tests/lsp/lsp_e2e_basics.rs +++ /dev/null @@ -1,313 +0,0 @@ -//! Tests for [LSPARCH-TESTING]. See docs/specs/LSP-ARCHITECTURE-SPEC.md#LSPARCH-TESTING -// Tests for LSP: `lsp_e2e_basics`. - -// LSP E2E tests — Initialize, document lifecycle, error handling. - -use super::lsp_e2e_common::{LspTestFixture, TestResult}; - -// ── Initialize + basic document lifecycle ──────────────────────────────────── - -#[test] -fn test_lsp_initialize() -> TestResult<()> { - let mut fixture = LspTestFixture::new()?; - let response = fixture.initialize()?; - - assert!(response.contains("\"jsonrpc\":\"2.0\"")); - assert!(response.contains("\"id\":1")); - assert!(response.contains("\"result\"")); - assert!(response.contains("\"basilisk\"")); - assert!(response.contains("\"textDocumentSync\":2")); - assert!(response.contains("\"hoverProvider\":true")); - assert!( - response.contains("\"codeActionProvider\""), - "should advertise code actions: {response}" - ); - Ok(()) -} - -#[test] -fn test_lsp_did_open_with_type_errors() -> TestResult<()> { - let mut fixture = LspTestFixture::new()?; - let _ = fixture.initialize()?; - - // `name` has no default to infer from (BSK-0001) and the returned method - // call is not inferable (BSK-0002) — an f-string return would infer - // `-> str` and silence BSK-0002 ([TYPEINF-FUNC-RETURN]). - let python_code = "def greet(name):\n return name.upper()"; - fixture.did_open("file:///test.py", python_code)?; - - let diag = fixture - .wait_for_diagnostics() - .ok_or("no diagnostics published")?; - - assert!(diag.contains("BSK-0001")); - assert!(diag.contains("BSK-0002")); - assert!(diag.contains("Missing parameter type annotation")); - assert!(diag.contains("Missing return type annotation")); - Ok(()) -} - -#[test] -fn test_lsp_did_open_with_clean_code() -> TestResult<()> { - let mut fixture = LspTestFixture::new()?; - let _ = fixture.initialize()?; - - let python_code = "def greet(name: str) -> str:\n return f\"Hello, {name}!\""; - fixture.did_open("file:///test.py", python_code)?; - - // Clean code settles to an empty diagnostics publish; wait for that exact - // publish so an initial-then-settled double publish cannot race the assert. - let diag = fixture - .wait_for_diagnostics_matching(|msg| msg.contains("\"diagnostics\":[]")) - .ok_or("no empty diagnostics published")?; - - assert!(diag.contains("\"diagnostics\":[]")); - Ok(()) -} - -#[test] -fn test_lsp_did_open_with_syntax_error() -> TestResult<()> { - let mut fixture = LspTestFixture::new()?; - let _ = fixture.initialize()?; - - // Missing colon after return type. - let python_code = "def greet(name: str) -> str\n return f\"Hello, {name}!\""; - fixture.did_open("file:///test.py", python_code)?; - - let diag = fixture - .wait_for_diagnostics() - .ok_or("no diagnostics published")?; - - assert!(diag.contains("\"method\":\"textDocument/publishDiagnostics\"")); - assert!(diag.contains("BSK-PARSE")); - assert!(diag.contains("Parse error")); - Ok(()) -} - -#[test] -fn test_lsp_did_change_updates_diagnostics() -> TestResult<()> { - let mut fixture = LspTestFixture::new()?; - let _ = fixture.initialize()?; - - let initial_code = "def greet(name):\n return f\"Hello, {name}!\""; - fixture.did_open("file:///test.py", initial_code)?; - let _ = fixture.wait_for_diagnostics(); - - // Change the document to fully annotated code. - fixture.send_json(&serde_json::json!({ - "jsonrpc": "2.0", - "method": "textDocument/didChange", - "params": { - "textDocument": { - "uri": "file:///test.py", - "version": 2 - }, - "contentChanges": [{ - "text": "def greet(name: str) -> str:\n return f\"Hello, {name}!\"" - }] - } - }))?; - - // The server may still have a stale populated publish in flight from the - // did_open; wait for the settled empty publish rather than the first one. - let diag = fixture - .wait_for_diagnostics_matching(|msg| msg.contains("\"diagnostics\":[]")) - .ok_or("no empty diagnostics after change")?; - - assert!(diag.contains("\"diagnostics\":[]")); - Ok(()) -} - -#[test] -fn test_lsp_did_close_clears_diagnostics() -> TestResult<()> { - let mut fixture = LspTestFixture::new()?; - let _ = fixture.initialize()?; - - let python_code = "def greet(name):\n return f\"Hello, {name}!\""; - fixture.did_open("file:///test.py", python_code)?; - let _ = fixture.wait_for_diagnostics(); - - fixture.send_json(&serde_json::json!({ - "jsonrpc": "2.0", - "method": "textDocument/didClose", - "params": { - "textDocument": { - "uri": "file:///test.py" - } - } - }))?; - - // A populated publish from the did_open may still be in flight; wait for - // the clearing publish that didClose triggers rather than the first one. - let diag = fixture - .wait_for_diagnostics_matching(|msg| msg.contains("\"diagnostics\":[]")) - .ok_or("no clearing diagnostics after close")?; - - assert!(diag.contains("\"diagnostics\":[]")); - Ok(()) -} - -#[test] -fn test_lsp_hover_on_error_location() -> TestResult<()> { - let mut fixture = LspTestFixture::new()?; - let _ = fixture.initialize()?; - - let python_code = "def greet(name):\n return f\"Hello, {name}!\""; - fixture.did_open("file:///test.py", python_code)?; - let _ = fixture.wait_for_diagnostics(); - - fixture.send_json(&serde_json::json!({ - "jsonrpc": "2.0", - "id": 2, - "method": "textDocument/hover", - "params": { - "textDocument": { "uri": "file:///test.py" }, - "position": { "line": 0, "character": 11 } - } - }))?; - - // The hover response is a request reply (has "id"), not a notification. - // Read messages until we find one with "id":2. - let mut hover_response = None; - for _ in 0..10 { - let Some(msg) = fixture.recv() else { break }; - if msg.contains("\"id\":2") { - hover_response = Some(msg); - break; - } - } - let hover = hover_response.ok_or("no hover response")?; - - assert!(hover.contains("\"jsonrpc\":\"2.0\"")); - assert!(hover.contains("BSK-0001")); - assert!(hover.contains("Missing parameter type annotation")); - Ok(()) -} - -// ── Error handling ─────────────────────────────────────────────────────────── - -#[test] -fn test_lsp_malformed_json_handling() -> TestResult<()> { - use std::io::Write as _; - let mut fixture = LspTestFixture::new()?; - let _ = fixture.initialize()?; - - // Send raw malformed JSON (not via send_json which would serialize properly). - let bad = "{ invalid json }"; - let frame = format!("Content-Length: {}\r\n\r\n{}", bad.len(), bad); - fixture.stdin.write_all(frame.as_bytes())?; - fixture.stdin.flush()?; - - // The server may send logMessage notifications (e.g. workspace scan) - // before the error response; skip notifications and find the error. - let mut error_response = None; - for _ in 0..10 { - let Some(msg) = fixture.recv() else { - break; - }; - if msg.contains("\"error\"") { - error_response = Some(msg); - break; - } - } - let error_response = error_response.ok_or("no error response")?; - - assert!(error_response.contains("\"error\"")); - assert!(error_response.contains("-32700")); - assert!(error_response.contains("Parse error")); - Ok(()) -} - -#[test] -fn test_lsp_unknown_method_handling() -> TestResult<()> { - let mut fixture = LspTestFixture::new()?; - let _ = fixture.initialize()?; - - fixture.send_json(&serde_json::json!({ - "jsonrpc": "2.0", - "id": 99, - "method": "textDocument/unknownMethod", - "params": {} - }))?; - - // Read messages until we find the error response for id 99. - let mut error_response = None; - for _ in 0..10 { - let Some(msg) = fixture.recv() else { break }; - if msg.contains("\"id\":99") { - error_response = Some(msg); - break; - } - } - let resp = error_response.ok_or("no error response")?; - - assert!(resp.contains("\"error\"")); - assert!(resp.contains("-32601")); - Ok(()) -} - -// ── Concurrent + large file handling ───────────────────────────────────────── - -#[test] -fn test_lsp_concurrent_document_handling() -> TestResult<()> { - let mut fixture = LspTestFixture::new()?; - let _ = fixture.initialize()?; - - fixture.did_open("file:///doc1.py", "def func1(x): pass")?; - fixture.did_open("file:///doc2.py", "def func2(y): return y")?; - - // Drain diagnostic notifications until BOTH documents have been reported. - // The server may publish for one document more than once (e.g. an initial - // empty publish followed by the populated one) or in either order, so a - // fixed two-notification read can miss doc2 under parallel load. Keep - // reading until both URIs are seen (bounded) — this only strengthens the - // gate: both documents must still receive diagnostics. - let mut combined = String::new(); - for _ in 0..8 { - let Some(msg) = fixture.wait_for_diagnostics() else { - break; - }; - combined.push('\n'); - combined.push_str(&msg); - if combined.contains("file:///doc1.py") && combined.contains("file:///doc2.py") { - break; - } - } - - assert!( - combined.contains("file:///doc1.py"), - "no diagnostics published for doc1: {combined}" - ); - assert!( - combined.contains("file:///doc2.py"), - "no diagnostics published for doc2: {combined}" - ); - assert!( - combined.contains("BSK-0001"), - "expected BSK-0001 in diagnostics: {combined}" - ); - Ok(()) -} - -#[test] -fn test_lsp_large_file_handling() -> TestResult<()> { - let mut fixture = LspTestFixture::new()?; - let _ = fixture.initialize()?; - - let mut large_code = String::new(); - for i in 0..50 { - use std::fmt::Write as _; - let _ = writeln!(large_code, "def func{i}(x): return x"); - } - - fixture.did_open("file:///large.py", &large_code)?; - - let diag = fixture - .wait_for_diagnostics() - .ok_or("no diagnostics published")?; - - assert!(diag.contains("\"method\":\"textDocument/publishDiagnostics\"")); - assert!(diag.matches("BSK-0001").count() >= 50); - assert!(diag.matches("BSK-0002").count() >= 50); - Ok(()) -} diff --git a/crates/basilisk-lsp/tests/lsp/lsp_e2e_change_signature.rs b/crates/basilisk-lsp/tests/lsp/lsp_e2e_change_signature.rs deleted file mode 100644 index 72e11157b..000000000 --- a/crates/basilisk-lsp/tests/lsp/lsp_e2e_change_signature.rs +++ /dev/null @@ -1,198 +0,0 @@ -//! Tests for [LSPARCH-FEATURES-SIGHELP]. See docs/specs/LSP-ARCHITECTURE-SPEC.md#LSPARCH-FEATURES-SIGHELP -// E2E tests for change signature and edit correctness verification. -// -// Tests change signature (remove/add/reorder parameters) and verifies -// that abstract method implementation produces correct workspace edits. - -use super::lsp_e2e_common::{send_request, LspTestFixture, TestResult}; - -/// Request code actions for a given file, range, and no diagnostics. -fn request_code_actions( - fixture: &mut LspTestFixture, - uri: &str, - start_line: u32, - start_char: u32, - end_line: u32, - end_char: u32, - request_id: u64, -) -> TestResult { - send_request( - fixture, - request_id, - "textDocument/codeAction", - serde_json::json!({ - "textDocument": { "uri": uri }, - "range": { - "start": { "line": start_line, "character": start_char }, - "end": { "line": end_line, "character": end_char } - }, - "context": { "diagnostics": [] } - }), - )? - .ok_or_else(|| "no code action response".into()) -} - -// ── Change Signature: Remove Parameter ─────────────────────────────────────── - -#[test] -fn test_refactor_change_signature_remove_param_offered() -> TestResult<()> { - let mut fixture = LspTestFixture::new()?; - let _ = fixture.initialize()?; - - let code = "def greet(name: str, greeting: str) -> str:\n return f\"{greeting}, {name}\"\n\nresult: str = greet(\"world\", \"Hello\")\n"; - fixture.did_open("file:///change_sig_remove.py", code)?; - let _ = fixture.wait_for_diagnostics(); - - let resp = request_code_actions( - &mut fixture, - "file:///change_sig_remove.py", - 0, - 21, - 0, - 29, // cursor on `greeting` parameter - 319, - )?; - - assert!( - resp.contains("Remove parameter"), - "should offer remove parameter: {resp}" - ); - Ok(()) -} - -// ── Change Signature: Add Parameter ───────────────────────────────────────── - -#[test] -fn test_refactor_change_signature_add_param_offered() -> TestResult<()> { - let mut fixture = LspTestFixture::new()?; - let _ = fixture.initialize()?; - - let code = "def greet(name: str) -> str:\n return f\"Hello, {name}\"\n"; - fixture.did_open("file:///change_sig_add.py", code)?; - let _ = fixture.wait_for_diagnostics(); - - let resp = request_code_actions( - &mut fixture, - "file:///change_sig_add.py", - 0, - 4, - 0, - 4, // cursor on function name - 320, - )?; - - assert!( - resp.contains("Add parameter"), - "should offer add parameter: {resp}" - ); - Ok(()) -} - -// ── Change Signature: Reorder Parameters ──────────────────────────────────── - -#[test] -fn test_refactor_change_signature_reorder_offered() -> TestResult<()> { - let mut fixture = LspTestFixture::new()?; - let _ = fixture.initialize()?; - - let code = "def process(zebra: int, apple: int, mango: int) -> int:\n return zebra + apple + mango\n"; - fixture.did_open("file:///change_sig_reorder.py", code)?; - let _ = fixture.wait_for_diagnostics(); - - let resp = request_code_actions( - &mut fixture, - "file:///change_sig_reorder.py", - 0, - 4, - 0, - 4, // cursor on function name - 321, - )?; - - assert!( - resp.contains("Sort parameters"), - "should offer sort parameters: {resp}" - ); - Ok(()) -} - -// ── Change Signature: Remove Parameter Edit Correctness ───────────────────── - -#[test] -fn test_refactor_change_signature_remove_param_edit_correctness() -> TestResult<()> { - let mut fixture = LspTestFixture::new()?; - let _ = fixture.initialize()?; - - let code = "def greet(name: str, greeting: str) -> str:\n return f\"{greeting}, {name}\"\n\nresult: str = greet(\"world\", \"Hello\")\n"; - fixture.did_open("file:///change_sig_rm_edit.py", code)?; - let _ = fixture.wait_for_diagnostics(); - - let resp = request_code_actions( - &mut fixture, - "file:///change_sig_rm_edit.py", - 0, - 21, - 0, - 29, // cursor on `greeting` parameter - 322, - )?; - - let parsed: serde_json::Value = serde_json::from_str(&resp)?; - let actions = parsed["result"].as_array().ok_or("expected result array")?; - - let action = actions - .iter() - .find(|a| { - a["title"] - .as_str() - .is_some_and(|t| t.contains("Remove parameter")) - }) - .ok_or("no remove parameter action found")?; - - assert!( - action["edit"]["changes"].is_object(), - "remove parameter should produce workspace edit with changes" - ); - Ok(()) -} - -// ── Implement Abstract Methods Edit Correctness ───────────────────────────── - -// Exercises [REFACTOR-ABSTRACT-ALGO] — verifies the generated stub edit. -#[test] -fn test_refactor_implement_abstract_methods_edit_correctness() -> TestResult<()> { - let mut fixture = LspTestFixture::new()?; - let _ = fixture.initialize()?; - - let code = "from abc import ABC, abstractmethod\n\nclass Base(ABC):\n @abstractmethod\n def do_thing(self) -> None:\n ...\n\nclass Child(Base):\n pass\n"; - fixture.did_open("file:///abstract_edit.py", code)?; - let _ = fixture.wait_for_diagnostics(); - - let resp = request_code_actions( - &mut fixture, - "file:///abstract_edit.py", - 7, - 6, - 7, - 6, // cursor inside Child class - 323, - )?; - - let parsed: serde_json::Value = serde_json::from_str(&resp)?; - let actions = parsed["result"].as_array().ok_or("expected result array")?; - - let action = actions - .iter() - .find(|a| { - a["title"] - .as_str() - .is_some_and(|t| t.contains("abstract") || t.contains("Implement")) - }) - .ok_or("no implement abstract methods action found")?; - - assert!( - action["edit"]["changes"].is_object(), - "implement abstract methods should produce workspace edit with changes" - ); - Ok(()) -} diff --git a/crates/basilisk-lsp/tests/lsp/lsp_e2e_code_actions.rs b/crates/basilisk-lsp/tests/lsp/lsp_e2e_code_actions.rs deleted file mode 100644 index 84ab26c57..000000000 --- a/crates/basilisk-lsp/tests/lsp/lsp_e2e_code_actions.rs +++ /dev/null @@ -1,920 +0,0 @@ -//! Tests for [LSPARCH-FEATURES-CODEACTIONS]. See docs/specs/LSP-ARCHITECTURE-SPEC.md#LSPARCH-FEATURES-CODEACTIONS -// Tests for LSP: `lsp_e2e_code_actions`. - -// LSP E2E tests — Signature Help, Find References, Rename, Inlay Hints, -// and Code Actions. - -use super::lsp_e2e_common::{send_request, LspTestFixture, TestResult}; - -// ── Document Symbols ───────────────────────────────────────────────────────── - -#[test] -fn test_lsp_document_symbols() -> TestResult<()> { - let mut fixture = LspTestFixture::new()?; - let _ = fixture.initialize()?; - - let code = "\ -class Animal: - name: str - def speak(self) -> str: - return self.name - -def greet(animal: Animal) -> str: - return animal.name - -x: int = 42 -"; - fixture.did_open("file:///symbols.py", code)?; - let _ = fixture.wait_for_diagnostics(); - - let resp = send_request( - &mut fixture, - 40, - "textDocument/documentSymbol", - serde_json::json!({ - "textDocument": { "uri": "file:///symbols.py" } - }), - )? - .ok_or("no document symbols response")?; - - assert!( - resp.contains("Animal"), - "symbols should include class 'Animal': {resp}" - ); - assert!( - resp.contains("greet"), - "symbols should include function 'greet': {resp}" - ); - assert!( - resp.contains("\"x\""), - "symbols should include variable 'x': {resp}" - ); - Ok(()) -} - -#[test] -fn test_lsp_document_symbols_nested_methods() -> TestResult<()> { - let mut fixture = LspTestFixture::new()?; - let _ = fixture.initialize()?; - - let code = "\ -class Calculator: - value: int - def add(self, x: int) -> int: - return self.value + x - def multiply(self, x: int) -> int: - return self.value * x -"; - fixture.did_open("file:///nested.py", code)?; - let _ = fixture.wait_for_diagnostics(); - - let resp = send_request( - &mut fixture, - 41, - "textDocument/documentSymbol", - serde_json::json!({ - "textDocument": { "uri": "file:///nested.py" } - }), - )? - .ok_or("no document symbols response")?; - - assert!(resp.contains("Calculator"), "should contain class: {resp}"); - assert!(resp.contains("add"), "should contain method 'add': {resp}"); - assert!( - resp.contains("multiply"), - "should contain method 'multiply': {resp}" - ); - assert!( - resp.contains("value"), - "should contain attribute 'value': {resp}" - ); - Ok(()) -} - -// ── Signature Help ─────────────────────────────────────────────────────────── - -#[test] -fn test_lsp_signature_help() -> TestResult<()> { - let mut fixture = LspTestFixture::new()?; - let _ = fixture.initialize()?; - - let code = "\ -def greet(name: str, greeting: str) -> str: - return f\"{greeting}, {name}!\" - -result: str = greet(\"world\", \"Hi\") -"; - fixture.did_open("file:///sighel.py", code)?; - let _ = fixture.wait_for_diagnostics(); - - let resp = send_request( - &mut fixture, - 50, - "textDocument/signatureHelp", - serde_json::json!({ - "textDocument": { "uri": "file:///sighel.py" }, - "position": { "line": 3, "character": 21 } - }), - )? - .ok_or("no signature help response")?; - - assert!( - resp.contains("greet"), - "signature should show function name: {resp}" - ); - assert!( - resp.contains("name"), - "signature should show parameter 'name': {resp}" - ); - assert!( - resp.contains("greeting"), - "signature should show parameter 'greeting': {resp}" - ); - Ok(()) -} - -// ── Find All References ────────────────────────────────────────────────────── - -#[test] -fn test_lsp_find_references() -> TestResult<()> { - let mut fixture = LspTestFixture::new()?; - let _ = fixture.initialize()?; - - let code = "\ -def greet(name: str) -> str: - return f\"Hello, {name}!\" - -result: str = greet(\"world\") -"; - fixture.did_open("file:///refs.py", code)?; - let _ = fixture.wait_for_diagnostics(); - - let resp = send_request( - &mut fixture, - 60, - "textDocument/references", - serde_json::json!({ - "textDocument": { "uri": "file:///refs.py" }, - "position": { "line": 0, "character": 4 }, - "context": { "includeDeclaration": true } - }), - )? - .ok_or("no references response")?; - - let count = resp.matches("refs.py").count(); - assert!( - count >= 2, - "should find at least 2 references for 'greet' (found {count}): {resp}" - ); - Ok(()) -} - -// ── Rename ─────────────────────────────────────────────────────────────────── - -#[test] -fn test_lsp_prepare_rename() -> TestResult<()> { - let mut fixture = LspTestFixture::new()?; - let _ = fixture.initialize()?; - - let code = "def greet(name: str) -> str:\n return f\"Hello, {name}!\"\n"; - fixture.did_open("file:///rename.py", code)?; - let _ = fixture.wait_for_diagnostics(); - - let resp = send_request( - &mut fixture, - 70, - "textDocument/prepareRename", - serde_json::json!({ - "textDocument": { "uri": "file:///rename.py" }, - "position": { "line": 0, "character": 4 } - }), - )? - .ok_or("no prepare rename response")?; - - assert!( - resp.contains("result"), - "prepare rename should return a result: {resp}" - ); - Ok(()) -} - -#[test] -fn test_lsp_rename_symbol() -> TestResult<()> { - let mut fixture = LspTestFixture::new()?; - let _ = fixture.initialize()?; - - let code = "\ -def greet(name: str) -> str: - return f\"Hello, {name}!\" - -result: str = greet(\"world\") -"; - fixture.did_open("file:///ren.py", code)?; - let _ = fixture.wait_for_diagnostics(); - - let resp = send_request( - &mut fixture, - 71, - "textDocument/rename", - serde_json::json!({ - "textDocument": { "uri": "file:///ren.py" }, - "position": { "line": 0, "character": 4 }, - "newName": "say_hello" - }), - )? - .ok_or("no rename response")?; - - assert!( - resp.contains("say_hello"), - "rename should include new name: {resp}" - ); - assert!( - resp.contains("changes"), - "rename should include workspace changes: {resp}" - ); - Ok(()) -} - -// ── Inlay Hints ────────────────────────────────────────────────────────────── - -#[test] -fn test_lsp_inlay_hints_variable_types() -> TestResult<()> { - let mut fixture = LspTestFixture::new()?; - let _ = fixture.initialize()?; - - let code = "x = 42\ny = \"hello\"\nz = True\n"; - fixture.did_open("file:///inlay.py", code)?; - let _ = fixture.wait_for_diagnostics(); - - let resp = send_request( - &mut fixture, - 80, - "textDocument/inlayHint", - serde_json::json!({ - "textDocument": { "uri": "file:///inlay.py" }, - "range": { - "start": { "line": 0, "character": 0 }, - "end": { "line": 3, "character": 0 } - } - }), - )? - .ok_or("no inlay hint response")?; - - assert!( - resp.contains("int"), - "inlay hints should show 'int' for x=42: {resp}" - ); - assert!( - resp.contains(": str"), - "inlay hints should display 'str' for y=\"hello\": {resp}" - ); - assert!( - resp.contains("bool"), - "inlay hints should show 'bool' for z=True: {resp}" - ); - Ok(()) -} - -// ── Semantic Tokens ────────────────────────────────────────────────────────── - -#[test] -fn test_lsp_semantic_tokens_full() -> TestResult<()> { - let mut fixture = LspTestFixture::new()?; - let _ = fixture.initialize()?; - - let code = "\ -class Animal: - name: str - def speak(self) -> str: - return self.name - -def greet(animal: Animal) -> str: - return animal.name - -x: int = 42 -"; - fixture.did_open("file:///semtok.py", code)?; - let _ = fixture.wait_for_diagnostics(); - - let resp = send_request( - &mut fixture, - 90, - "textDocument/semanticTokens/full", - serde_json::json!({ - "textDocument": { "uri": "file:///semtok.py" } - }), - )? - .ok_or("no semantic tokens response")?; - - assert!( - resp.contains("\"data\""), - "semantic tokens should contain 'data' array: {resp}" - ); - assert!( - resp.contains("result"), - "semantic tokens should have result: {resp}" - ); - - let parsed: serde_json::Value = serde_json::from_str(&resp)?; - let data = parsed["result"]["data"] - .as_array() - .ok_or("data should be an array")?; - - assert_eq!( - data.len() % 5, - 0, - "token data length should be multiple of 5" - ); - assert!(data.len() >= 5, "should have at least 1 token: {resp}"); - Ok(()) -} - -// ── Code Actions ───────────────────────────────────────────────────────────── - -#[test] -fn test_lsp_code_action_missing_param_annotation() -> TestResult<()> { - let mut fixture = LspTestFixture::new()?; - let _ = fixture.initialize()?; - - let code = "def greet(name):\n return f\"Hello, {name}!\""; - fixture.did_open("file:///actions.py", code)?; - - let diag_msg = fixture - .wait_for_diagnostics() - .ok_or("no diagnostics published")?; - - let diag_json: serde_json::Value = serde_json::from_str(&diag_msg)?; - let diagnostics = diag_json["params"]["diagnostics"] - .as_array() - .ok_or("expected diagnostics array")?; - - let e0001 = diagnostics - .iter() - .find(|d| d["code"].as_str() == Some("BSK-0001")) - .ok_or("no BSK-0001 diagnostic")?; - - let resp = send_request( - &mut fixture, - 100, - "textDocument/codeAction", - serde_json::json!({ - "textDocument": { "uri": "file:///actions.py" }, - "range": e0001["range"], - "context": { - "diagnostics": [e0001] - } - }), - )? - .ok_or("no code action response")?; - - assert!( - resp.contains(": Any"), - "code action should insert ': Any': {resp}" - ); - assert!( - resp.contains("quickfix"), - "code action should be a quickfix: {resp}" - ); - Ok(()) -} - -#[test] -fn test_lsp_code_action_missing_return_annotation() -> TestResult<()> { - let mut fixture = LspTestFixture::new()?; - let _ = fixture.initialize()?; - - // The returned method call is not inferable, so BSK-0002 fires — an - // f-string return would infer `-> str` and stay silent - // ([TYPEINF-FUNC-RETURN]). - let code = "def greet(name: str):\n return name.upper()"; - fixture.did_open("file:///retact.py", code)?; - - let diag_msg = fixture - .wait_for_diagnostics() - .ok_or("no diagnostics published")?; - - let diag_json: serde_json::Value = serde_json::from_str(&diag_msg)?; - let diagnostics = diag_json["params"]["diagnostics"] - .as_array() - .ok_or("expected diagnostics array")?; - - let e0002 = diagnostics - .iter() - .find(|d| d["code"].as_str() == Some("BSK-0002")) - .ok_or("no BSK-0002 diagnostic")?; - - let resp = send_request( - &mut fixture, - 101, - "textDocument/codeAction", - serde_json::json!({ - "textDocument": { "uri": "file:///retact.py" }, - "range": e0002["range"], - "context": { - "diagnostics": [e0002] - } - }), - )? - .ok_or("no code action response")?; - - assert!( - resp.contains("-> Any"), - "code action should insert '-> Any': {resp}" - ); - assert!( - resp.contains("quickfix"), - "code action should be a quickfix: {resp}" - ); - - // Verify the edit inserts AFTER the closing `)`, not at the function name. - // Input: `def greet(name: str):` — `)` is at column 19. - let resp_json: serde_json::Value = serde_json::from_str(&resp)?; - let actions = resp_json["result"] - .as_array() - .ok_or("expected result array")?; - let return_fix = actions - .iter() - .find(|a| a["title"].as_str().is_some_and(|t| t.contains("-> Any"))) - .ok_or("no return type fix action")?; - let edit = &return_fix["edit"]["changes"]["file:///retact.py"][0]; - let start_line = edit["range"]["start"]["line"].as_u64().unwrap_or(u64::MAX); - let start_char = edit["range"]["start"]["character"] - .as_u64() - .unwrap_or(u64::MAX); - let new_text = edit["newText"].as_str().unwrap_or(""); - assert_eq!(start_line, 0, "edit must be on the function def line"); - assert_eq!( - start_char, 20, - "edit must insert at column 20 (after closing paren), not at function name" - ); - assert_eq!( - new_text, " -> Any", - "inserted text must be ' -> Any' (space before arrow)" - ); - Ok(()) -} - -#[test] -fn test_lsp_code_action_redundant_annotation_w0050() -> TestResult<()> { - let mut fixture = LspTestFixture::new()?; - let _ = fixture.initialize()?; - - let code = "x: int = 42\n"; - fixture.did_open("file:///redundant.py", code)?; - - let diag_msg = fixture - .wait_for_diagnostics() - .ok_or("no diagnostics published")?; - - let diag_json: serde_json::Value = serde_json::from_str(&diag_msg)?; - let diagnostics = diag_json["params"]["diagnostics"] - .as_array() - .ok_or("expected diagnostics array")?; - - let w0050 = diagnostics - .iter() - .find(|d| d["code"].as_str() == Some("BSK-0050")) - .ok_or("no BSK-0050 diagnostic")?; - - let resp = send_request( - &mut fixture, - 102, - "textDocument/codeAction", - serde_json::json!({ - "textDocument": { "uri": "file:///redundant.py" }, - "range": w0050["range"], - "context": { - "diagnostics": [w0050] - } - }), - )? - .ok_or("no code action response")?; - - assert!( - resp.contains("Remove redundant type annotation"), - "code action should offer to remove redundant annotation: {resp}" - ); - assert!( - resp.contains("quickfix"), - "code action should be a quickfix: {resp}" - ); - Ok(()) -} - -// ── Mass Autofix (Fix All in File) ────────────────────────────────────────── -// Exercises [AUTOFIX-MASS] (File scope) and [AUTOFIX-MASS-VSCODE] (the -// `source.fixAll.basilisk` code action + `basilisk.fixFile` command). - -#[test] -fn test_lsp_fix_all_in_file_returns_combined_edit() -> TestResult<()> { - let mut fixture = LspTestFixture::new()?; - let _ = fixture.initialize()?; - - // Two redundant annotations on separate lines — both fixable. - let code = "x: int = 42\ny: str = \"hello\"\n"; - fixture.did_open("file:///fixall.py", code)?; - - let _ = fixture - .wait_for_diagnostics() - .ok_or("no diagnostics published")?; - - // Request source.fixAll code actions — the server should return a single - // combined action with edits for both BSK-0050 diagnostics. - let resp = send_request( - &mut fixture, - 200, - "textDocument/codeAction", - serde_json::json!({ - "textDocument": { "uri": "file:///fixall.py" }, - "range": { - "start": { "line": 0, "character": 0 }, - "end": { "line": 2, "character": 0 } - }, - "context": { - "diagnostics": [], - "only": ["source.fixAll"] - } - }), - )? - .ok_or("no fix-all code action response")?; - - assert!( - resp.contains("Fix all auto-fixable issues"), - "should return a fix-all action: {resp}" - ); - assert!( - resp.contains("source.fixAll.basilisk"), - "action kind should be source.fixAll.basilisk: {resp}" - ); - Ok(()) -} - -#[test] -fn test_lsp_fix_all_no_fixable_returns_empty() -> TestResult<()> { - let mut fixture = LspTestFixture::new()?; - let _ = fixture.initialize()?; - - // All annotations are necessary — nothing to fix. - let code = "x: list[int] = [1, 2, 3]\n"; - fixture.did_open("file:///nofixall.py", code)?; - - let _ = fixture - .wait_for_diagnostics() - .ok_or("no diagnostics published")?; - - let resp = send_request( - &mut fixture, - 201, - "textDocument/codeAction", - serde_json::json!({ - "textDocument": { "uri": "file:///nofixall.py" }, - "range": { - "start": { "line": 0, "character": 0 }, - "end": { "line": 1, "character": 0 } - }, - "context": { - "diagnostics": [], - "only": ["source.fixAll"] - } - }), - )? - .ok_or("no fix-all code action response")?; - - // Should return null result or empty array — no fixable diagnostics. - let parsed: serde_json::Value = serde_json::from_str(&resp)?; - let result = &parsed["result"]; - let is_empty = result.is_null() || result.as_array().is_some_and(Vec::is_empty); - assert!( - is_empty, - "fix-all should return null/empty when nothing is fixable: {resp}" - ); - Ok(()) -} - -#[test] -fn test_lsp_fix_file_command() -> TestResult<()> { - let mut fixture = LspTestFixture::new()?; - let _ = fixture.initialize()?; - - let code = "x: int = 42\n"; - fixture.did_open("file:///fixcmd.py", code)?; - - let _ = fixture - .wait_for_diagnostics() - .ok_or("no diagnostics published")?; - - let resp = send_request( - &mut fixture, - 202, - "workspace/executeCommand", - serde_json::json!({ - "command": "basilisk.fixFile", - "arguments": ["file:///fixcmd.py"] - }), - )? - .ok_or("no fixFile command response")?; - - assert!( - resp.contains("fixed"), - "fixFile should return a result with 'fixed' count: {resp}" - ); - Ok(()) -} - -// Regression for issue #245 [AUTOFIX-CLASSIFY] / [AUTOFIX-MASS-VSCODE]: every -// LSP fix-all surface must apply Safe fixes only by default. The Unsafe -// BSK-0003 fix (insert `: Any` on an unannotated variable) may only be -// applied by the explicit all-tier command variants (`basilisk.fixFileAll` / -// `basilisk.fixWorkspaceAll`), mirroring the CLI's safe-only default. -#[test] -fn test_lsp_fix_all_defaults_to_safe_fixes_only() -> TestResult<()> { - let mut fixture = LspTestFixture::new()?; - std::fs::write( - fixture.workspace_root.join("pyproject.toml"), - "[tool.basilisk.rules]\n\"BSK-0003\" = \"error\"\n\"BSK-0050\" = \"warning\"\n", - )?; - let _ = fixture.initialize()?; - - // Line 0: redundant annotation → BSK-0050 (Safe fix: remove `: int`). - // Line 1: unannotated `None` variable → BSK-0003 (Unsafe fix: insert `: Any`). - let uri = - tower_lsp::lsp_types::Url::from_file_path(fixture.workspace_root.join("safe_default.py")) - .map_err(|()| "fixture path cannot be represented as a URI")? - .to_string(); - let code = "x: int = 42\ny = None\n"; - fixture.did_open(&uri, code)?; - let _ = fixture - .wait_for_diagnostics_matching(|message| { - message.contains(&uri) && message.contains("BSK-0003") && message.contains("BSK-0050") - }) - .ok_or("no settled Safe and Unsafe diagnostics published")?; - - // Surface 1: the `source.fixAll` code action must include only Safe fixes. - let resp = send_request( - &mut fixture, - 300, - "textDocument/codeAction", - serde_json::json!({ - "textDocument": { "uri": &uri }, - "range": { - "start": { "line": 0, "character": 0 }, - "end": { "line": 2, "character": 0 } - }, - "context": { - "diagnostics": [], - "only": ["source.fixAll"] - } - }), - )? - .ok_or("no fix-all code action response")?; - let parsed: serde_json::Value = serde_json::from_str(&resp)?; - let edits = parsed["result"][0]["edit"]["changes"][&uri] - .as_array() - .ok_or("fix-all action should carry edits")?; - assert!( - edits.iter().all(|e| e["newText"].as_str() != Some(": Any")), - "source.fixAll must not apply the Unsafe BSK-0003 `: Any` insertion: {resp}" - ); - assert_eq!( - edits.len(), - 1, - "source.fixAll should include exactly the Safe BSK-0050 fix: {resp}" - ); - - // Surfaces 2–3: the plain commands (keybinding / context menu / toolbar) - // are Safe-only by default; the spec-promised all-tier variants - // ([AUTOFIX-MASS-VSCODE]) exist and widen to the Unsafe BSK-0003 fix. - let second_uri = - tower_lsp::lsp_types::Url::from_file_path(fixture.workspace_root.join("safe_workspace.py")) - .map_err(|()| "fixture path cannot be represented as a URI")? - .to_string(); - let third_uri = - tower_lsp::lsp_types::Url::from_file_path(fixture.workspace_root.join("all_file.py")) - .map_err(|()| "fixture path cannot be represented as a URI")? - .to_string(); - let expectations: [(&str, &str, bool, u64); 4] = [ - ("basilisk.fixFile", &uri, false, 1), - ("basilisk.fixWorkspace", &second_uri, true, 1), - ("basilisk.fixFileAll", &third_uri, true, 2), - ("basilisk.fixWorkspaceAll", &uri, false, 2), - ]; - for ((command, command_uri, open_first, expected_fixed), request_id) in - expectations.into_iter().zip(301_u64..) - { - if open_first { - fixture.did_open(command_uri, code)?; - let _ = fixture - .wait_for_diagnostics() - .ok_or("no diagnostics published for command fixture")?; - } - let resp = send_request( - &mut fixture, - request_id, - "workspace/executeCommand", - serde_json::json!({ "command": command, "arguments": [command_uri] }), - )? - .ok_or("no executeCommand response")?; - let parsed: serde_json::Value = serde_json::from_str(&resp)?; - assert_eq!( - parsed["result"]["fixed"].as_u64(), - Some(expected_fixed), - "{command} must fix exactly {expected_fixed} issue(s) — Safe fixes \ - only by default, every fixable rule for the `All` variants: {resp}" - ); - } - Ok(()) -} - -// Implements [AUTOFIX-MASS-OVERVIEW] / [CONFIGEDITOR-OPERATIONS]: command -// arguments are workspace authority boundaries, and accepted edits converge -// the index before the execute-command response is returned. -#[test] -fn test_fix_commands_enforce_workspace_authority_and_converge() -> TestResult<()> { - let mut fixture = LspTestFixture::new()?; - std::fs::write( - fixture.workspace_root.join("pyproject.toml"), - "[tool.basilisk.rules]\n\"BSK-0050\" = \"warning\"\n", - )?; - let _ = fixture.initialize()?; - - let root_uri = - tower_lsp::lsp_types::Url::from_file_path(fixture.workspace_root.join("inside.py")) - .map_err(|()| "fixture path cannot be represented as a URI")? - .to_string(); - let external_uri = "file:///external_fix_scope.py"; - let code = "x: int = 42\n"; - fixture.did_open(&root_uri, code)?; - let _ = fixture - .wait_for_diagnostics_matching(|message| { - message.contains(&root_uri) && message.contains("BSK-0050") - }) - .ok_or("no settled diagnostics for in-root document")?; - fixture.did_open(external_uri, code)?; - let _ = fixture - .wait_for_diagnostics_matching(|message| { - message.contains(external_uri) && message.contains("BSK-0050") - }) - .ok_or("no settled diagnostics for external document")?; - - let workspace = send_request( - &mut fixture, - 320, - "workspace/executeCommand", - serde_json::json!({ - "command": "basilisk.fixWorkspace", - "arguments": [] - }), - )? - .ok_or("no fixWorkspace response")?; - let parsed: serde_json::Value = serde_json::from_str(&workspace)?; - assert_eq!(parsed["result"]["fixed"].as_u64(), Some(1)); - assert_eq!(parsed["result"]["files"].as_u64(), Some(1)); - - let external = send_request( - &mut fixture, - 321, - "workspace/executeCommand", - serde_json::json!({ - "command": "basilisk.fixFile", - "arguments": [external_uri] - }), - )? - .ok_or("no external fixFile response")?; - let parsed: serde_json::Value = serde_json::from_str(&external)?; - assert_eq!( - parsed["result"]["fixed"].as_u64(), - Some(1), - "workspace fix must leave the external open document unchanged: {external}" - ); - - let malformed = send_request( - &mut fixture, - 322, - "workspace/executeCommand", - serde_json::json!({ - "command": "basilisk.fixWorkspace", - "arguments": [{}] - }), - )? - .ok_or("no malformed-root response")?; - let parsed: serde_json::Value = serde_json::from_str(&malformed)?; - assert_eq!(parsed["error"]["code"].as_i64(), Some(-32602)); - - let outside_disable = send_request( - &mut fixture, - 323, - "workspace/executeCommand", - serde_json::json!({ - "command": "basilisk.disableRule", - "arguments": [{ - "rule": "BSK-0050", - "severity": "off", - "uri": external_uri - }] - }), - )? - .ok_or("no outside disableRule response")?; - let parsed: serde_json::Value = serde_json::from_str(&outside_disable)?; - assert_eq!(parsed["error"]["code"].as_i64(), Some(-32602)); - Ok(()) -} - -// ── Fix All by Rule ───────────────────────────────────────────────────────── - -#[test] -fn test_lsp_fix_all_by_rule_in_quickfix_menu() -> TestResult<()> { - let mut fixture = LspTestFixture::new()?; - let _ = fixture.initialize()?; - - // Three redundant annotations — requesting code actions for the first - // diagnostic should include a "Fix all `BSK-0050`" quickfix action. - let code = "x: int = 42\ny: str = \"hello\"\nz: bool = True\n"; - fixture.did_open("file:///fixrule.py", code)?; - - let diag_msg = fixture - .wait_for_diagnostics() - .ok_or("no diagnostics published")?; - - let diag_json: serde_json::Value = serde_json::from_str(&diag_msg)?; - let diagnostics = diag_json["params"]["diagnostics"] - .as_array() - .ok_or("expected diagnostics array")?; - - let w0050 = diagnostics - .iter() - .find(|d| d["code"].as_str() == Some("BSK-0050")) - .ok_or("no BSK-0050 diagnostic")?; - - let resp = send_request( - &mut fixture, - 210, - "textDocument/codeAction", - serde_json::json!({ - "textDocument": { "uri": "file:///fixrule.py" }, - "range": w0050["range"], - "context": { - "diagnostics": [w0050] - } - }), - )? - .ok_or("no code action response")?; - - assert!( - resp.contains("Fix all `BSK-0050` in this file"), - "should contain per-rule fix-all action: {resp}" - ); - assert!( - resp.contains("3 fixes"), - "should fix all 3 BSK-0050 instances: {resp}" - ); - // Also verify the global fix-all is present. - assert!( - resp.contains("Fix all auto-fixable issues"), - "should also contain global fix-all action: {resp}" - ); - Ok(()) -} - -#[test] -fn test_lsp_fix_all_by_rule_not_shown_for_single_instance() -> TestResult<()> { - let mut fixture = LspTestFixture::new()?; - let _ = fixture.initialize()?; - - // Only one BSK-0050 — per-rule fix-all should not appear. - let code = "x: int = 42\n"; - fixture.did_open("file:///fixrule1.py", code)?; - - let diag_msg = fixture - .wait_for_diagnostics() - .ok_or("no diagnostics published")?; - - let diag_json: serde_json::Value = serde_json::from_str(&diag_msg)?; - let diagnostics = diag_json["params"]["diagnostics"] - .as_array() - .ok_or("expected diagnostics array")?; - - let w0050 = diagnostics - .iter() - .find(|d| d["code"].as_str() == Some("BSK-0050")) - .ok_or("no BSK-0050 diagnostic")?; - - let resp = send_request( - &mut fixture, - 211, - "textDocument/codeAction", - serde_json::json!({ - "textDocument": { "uri": "file:///fixrule1.py" }, - "range": w0050["range"], - "context": { - "diagnostics": [w0050] - } - }), - )? - .ok_or("no code action response")?; - - assert!( - !resp.contains("Fix all `BSK-0050` in this file"), - "per-rule fix-all should NOT appear for single instance: {resp}" - ); - Ok(()) -} diff --git a/crates/basilisk-lsp/tests/lsp/lsp_e2e_common.rs b/crates/basilisk-lsp/tests/lsp/lsp_e2e_common.rs deleted file mode 100644 index ca8f3e672..000000000 --- a/crates/basilisk-lsp/tests/lsp/lsp_e2e_common.rs +++ /dev/null @@ -1,42 +0,0 @@ -//! Tests for [LSPARCH-TESTING]. See docs/specs/LSP-ARCHITECTURE-SPEC.md#LSPARCH-TESTING -// Shared test infrastructure for stdio-based LSP E2E tests. -// -// Each test file imports this module via `mod lsp_e2e_common;` to get -// the fixture, type alias, and helper functions. -// -// The actual fixture implementation lives in `basilisk_test_utils::lsp_stdio`. - -pub use basilisk_test_utils::TestResult; - -/// Type alias preserving the original name used by LSP E2E tests. -pub type LspTestFixture = basilisk_test_utils::LspStdioFixture; - -/// Send an LSP request and wait for the response matching the given id. -/// -/// Thin wrapper around [`LspStdioFixture::send_request`] preserving -/// the standalone-function call style used by existing tests. -/// -/// # Errors -/// Returns an error if writing the request or reading the response fails. -pub fn send_request( - fixture: &mut LspTestFixture, - id: u64, - method: &str, - params: serde_json::Value, -) -> TestResult> { - fixture.send_request(id, method, params) -} - -/// Helper: send a `textDocument/completion` request and wait for the response. -/// -/// # Errors -/// Returns an error if writing the request or reading the response fails. -pub fn request_completion( - fixture: &mut LspTestFixture, - uri: &str, - line: u32, - character: u32, - request_id: u64, -) -> TestResult> { - fixture.request_completion(uri, line, character, request_id) -} diff --git a/crates/basilisk-lsp/tests/lsp/lsp_e2e_completion.rs b/crates/basilisk-lsp/tests/lsp/lsp_e2e_completion.rs deleted file mode 100644 index 94cb9c456..000000000 --- a/crates/basilisk-lsp/tests/lsp/lsp_e2e_completion.rs +++ /dev/null @@ -1,309 +0,0 @@ -//! Tests for [LSPARCH-FEATURES-COMPLETION]. See docs/specs/LSP-ARCHITECTURE-SPEC.md#LSPARCH-FEATURES-COMPLETION -// Tests for LSP: `lsp_e2e_completion`. - -// LSP E2E tests — Completion (`IntelliSense`). - -use super::lsp_e2e_common::{request_completion, LspTestFixture, TestResult}; - -#[test] -fn test_lsp_initialize_advertises_completion() -> TestResult<()> { - let mut fixture = LspTestFixture::new()?; - let response = fixture.initialize()?; - - assert!(response.contains("\"completionProvider\"")); - assert!(response.contains("\".\"")); - Ok(()) -} - -#[test] -fn test_lsp_completion_returns_functions_and_classes() -> TestResult<()> { - let mut fixture = LspTestFixture::new()?; - let _ = fixture.initialize()?; - - let code = "\ -class Animal: - name: str - def speak(self) -> str: - return self.name - -def greet(animal: Animal) -> str: - return animal.name - -x: int = 42 -"; - fixture.did_open("file:///comp.py", code)?; - let _ = fixture.wait_for_diagnostics(); - - // Request completion at end of file (empty prefix → all symbols) - let resp = request_completion(&mut fixture, "file:///comp.py", 9, 0, 10)? - .ok_or("no completion response")?; - - // Should contain our function, class, and variable - assert!( - resp.contains("\"label\":\"greet\""), - "should complete function 'greet': {resp}" - ); - assert!( - resp.contains("\"label\":\"Animal\""), - "should complete class 'Animal': {resp}" - ); - assert!( - resp.contains("\"label\":\"x\""), - "should complete variable 'x': {resp}" - ); - - // Should also contain builtins - assert!( - resp.contains("\"label\":\"print\""), - "should complete builtin 'print': {resp}" - ); - assert!( - resp.contains("\"label\":\"len\""), - "should complete builtin 'len': {resp}" - ); - Ok(()) -} - -#[test] -fn test_lsp_completion_prefix_filtering() -> TestResult<()> { - let mut fixture = LspTestFixture::new()?; - let _ = fixture.initialize()?; - - let code = "\ -def greet(name: str) -> str: - return name - -def goodbye(name: str) -> str: - return name - -def helper(x: int) -> int: - return x - -gr"; - fixture.did_open("file:///prefix.py", code)?; - let _ = fixture.wait_for_diagnostics(); - - // Cursor at the end of "gr" on the last line (line 9, character 2) - let resp = request_completion(&mut fixture, "file:///prefix.py", 9, 2, 11)? - .ok_or("no completion response")?; - - assert!( - resp.contains("\"label\":\"greet\""), - "should match 'greet' for prefix 'gr': {resp}" - ); - assert!( - !resp.contains("\"label\":\"helper\""), - "should NOT match 'helper' for prefix 'gr': {resp}" - ); - assert!( - !resp.contains("\"label\":\"goodbye\""), - "should NOT match 'goodbye' for prefix 'gr': {resp}" - ); - Ok(()) -} - -#[test] -fn test_lsp_completion_imports() -> TestResult<()> { - let mut fixture = LspTestFixture::new()?; - let _ = fixture.initialize()?; - - let code = "\ -from typing import Optional, List -import os - -"; - fixture.did_open("file:///imports.py", code)?; - let _ = fixture.wait_for_diagnostics(); - - // Completion at empty position (line 3) - let resp = request_completion(&mut fixture, "file:///imports.py", 3, 0, 12)? - .ok_or("no completion response")?; - - assert!( - resp.contains("\"label\":\"Optional\""), - "should complete imported 'Optional': {resp}" - ); - assert!( - resp.contains("\"label\":\"List\""), - "should complete imported 'List': {resp}" - ); - assert!( - resp.contains("\"label\":\"os\""), - "should complete imported module 'os': {resp}" - ); - Ok(()) -} - -#[test] -fn test_lsp_completion_dot_on_class() -> TestResult<()> { - let mut fixture = LspTestFixture::new()?; - let _ = fixture.initialize()?; - - let code = "\ -class Dog: - name: str - breed: str - def bark(self) -> str: - return \"woof\" - def fetch(self, item: str) -> str: - return item - -Dog."; - fixture.did_open("file:///dot.py", code)?; - let _ = fixture.wait_for_diagnostics(); - - // Cursor after "Dog." on last line (line 8, character 4) - let resp = request_completion(&mut fixture, "file:///dot.py", 8, 4, 13)? - .ok_or("no completion response")?; - - assert!( - resp.contains("\"label\":\"name\""), - "should complete attribute 'name': {resp}" - ); - assert!( - resp.contains("\"label\":\"breed\""), - "should complete attribute 'breed': {resp}" - ); - assert!( - resp.contains("\"label\":\"bark\""), - "should complete method 'bark': {resp}" - ); - assert!( - resp.contains("\"label\":\"fetch\""), - "should complete method 'fetch': {resp}" - ); - Ok(()) -} - -#[test] -fn test_lsp_completion_self_dot() -> TestResult<()> { - let mut fixture = LspTestFixture::new()?; - let _ = fixture.initialize()?; - - let code = "\ -class Cat: - color: str - age: int - def meow(self) -> str: - return \"meow\" - def describe(self) -> str: - return self."; - fixture.did_open("file:///selfdot.py", code)?; - let _ = fixture.wait_for_diagnostics(); - - // Cursor after "self." inside describe method (line 6, character 20) - let resp = request_completion(&mut fixture, "file:///selfdot.py", 6, 20, 14)? - .ok_or("no completion response")?; - - assert!( - resp.contains("\"label\":\"color\""), - "should complete self.color: {resp}" - ); - assert!( - resp.contains("\"label\":\"age\""), - "should complete self.age: {resp}" - ); - assert!( - resp.contains("\"label\":\"meow\""), - "should complete self.meow: {resp}" - ); - assert!( - resp.contains("\"label\":\"describe\""), - "should complete self.describe: {resp}" - ); - Ok(()) -} - -#[test] -fn test_lsp_completion_builtins() -> TestResult<()> { - let mut fixture = LspTestFixture::new()?; - let _ = fixture.initialize()?; - - let code = "pri"; - fixture.did_open("file:///builtins.py", code)?; - let _ = fixture.wait_for_diagnostics(); - - // Cursor after "pri" (line 0, character 3) - let resp = request_completion(&mut fixture, "file:///builtins.py", 0, 3, 15)? - .ok_or("no completion response")?; - - assert!( - resp.contains("\"label\":\"print\""), - "should complete builtin 'print' for prefix 'pri': {resp}" - ); - // Should NOT include unrelated builtins - assert!( - !resp.contains("\"label\":\"len\""), - "should NOT include 'len' for prefix 'pri': {resp}" - ); - assert!( - !resp.contains("\"label\":\"map\""), - "should NOT include 'map' for prefix 'pri': {resp}" - ); - Ok(()) -} - -#[test] -fn test_lsp_completion_function_detail_shows_params() -> TestResult<()> { - let mut fixture = LspTestFixture::new()?; - let _ = fixture.initialize()?; - - let code = "\ -def calculate(x: int, y: int, op: str) -> int: - return x - -cal"; - fixture.did_open("file:///detail.py", code)?; - let _ = fixture.wait_for_diagnostics(); - - // Cursor after "cal" (line 3, character 3) - let resp = request_completion(&mut fixture, "file:///detail.py", 3, 3, 16)? - .ok_or("no completion response")?; - - assert!( - resp.contains("\"label\":\"calculate\""), - "should complete 'calculate': {resp}" - ); - // The detail should include the parameter signature - assert!( - resp.contains("x, y, op"), - "should show params in detail: {resp}" - ); - Ok(()) -} - -#[test] -fn test_lsp_completion_on_empty_file() -> TestResult<()> { - let mut fixture = LspTestFixture::new()?; - let _ = fixture.initialize()?; - - // Empty file should still return builtins - fixture.did_open("file:///empty.py", "")?; - let _ = fixture.wait_for_diagnostics(); - - let resp = request_completion(&mut fixture, "file:///empty.py", 0, 0, 17)? - .ok_or("no completion response")?; - - // Should contain builtins - assert!( - resp.contains("\"label\":\"print\""), - "empty file should still offer builtins: {resp}" - ); - assert!( - resp.contains("\"label\":\"int\""), - "empty file should still offer 'int': {resp}" - ); - assert!( - resp.contains("\"label\":\"str\""), - "empty file should still offer 'str': {resp}" - ); - assert!( - resp.contains("\"label\":\"True\""), - "empty file should still offer 'True': {resp}" - ); - assert!( - resp.contains("\"label\":\"Exception\""), - "empty file should still offer 'Exception': {resp}" - ); - Ok(()) -} diff --git a/crates/basilisk-lsp/tests/lsp/lsp_e2e_hierarchies.rs b/crates/basilisk-lsp/tests/lsp/lsp_e2e_hierarchies.rs deleted file mode 100644 index 6be28707b..000000000 --- a/crates/basilisk-lsp/tests/lsp/lsp_e2e_hierarchies.rs +++ /dev/null @@ -1,271 +0,0 @@ -//! Tests for [LSPARCH-TESTING], [LSPARCH-FEATURES-CALLHIER], [LSPARCH-FEATURES-TYPEHIER]. -//! See docs/specs/LSP-ARCHITECTURE-SPEC.md#LSPARCH-TESTING -// Tests for LSP: `lsp_e2e_hierarchies`. - -// LSP E2E tests — Call Hierarchy and Type Hierarchy. - -use super::lsp_e2e_common::{send_request, LspTestFixture, TestResult}; - -// ── Call Hierarchy ─────────────────────────────────────────────────────────── - -#[test] -fn test_lsp_prepare_call_hierarchy() -> TestResult<()> { - let mut fixture = LspTestFixture::new()?; - let _ = fixture.initialize()?; - - let code = "\ -def greet(name: str) -> str: - return f\"Hello, {name}!\" - -def main() -> None: - greet(\"world\") -"; - fixture.did_open("file:///callh.py", code)?; - let _ = fixture.wait_for_diagnostics(); - - let resp = send_request( - &mut fixture, - 304, - "textDocument/prepareCallHierarchy", - serde_json::json!({ - "textDocument": { "uri": "file:///callh.py" }, - "position": { "line": 0, "character": 4 } - }), - )? - .ok_or("no prepareCallHierarchy response")?; - - assert!(resp.contains("\"result\""), "should have a result: {resp}"); - assert!( - resp.contains("greet"), - "should contain 'greet' in call hierarchy: {resp}" - ); - Ok(()) -} - -#[test] -fn test_lsp_call_hierarchy_incoming() -> TestResult<()> { - let mut fixture = LspTestFixture::new()?; - let _ = fixture.initialize()?; - - let code = "\ -def greet(name: str) -> str: - return f\"Hello, {name}!\" - -def main() -> None: - greet(\"world\") -"; - fixture.did_open("file:///callhi.py", code)?; - let _ = fixture.wait_for_diagnostics(); - - let prep = send_request( - &mut fixture, - 305, - "textDocument/prepareCallHierarchy", - serde_json::json!({ - "textDocument": { "uri": "file:///callhi.py" }, - "position": { "line": 0, "character": 4 } - }), - )? - .ok_or("no prepareCallHierarchy response")?; - - let prep_val: serde_json::Value = serde_json::from_str(&prep)?; - let items = prep_val["result"].as_array().ok_or("no items in prepare")?; - if items.is_empty() { - return Ok(()); - } - - let resp = send_request( - &mut fixture, - 306, - "callHierarchy/incomingCalls", - serde_json::json!({ - "item": items[0] - }), - )? - .ok_or("no incomingCalls response")?; - - assert!(resp.contains("\"result\""), "should have a result: {resp}"); - Ok(()) -} - -#[test] -fn test_lsp_call_hierarchy_outgoing() -> TestResult<()> { - let mut fixture = LspTestFixture::new()?; - let _ = fixture.initialize()?; - - let code = "\ -def greet(name: str) -> str: - return f\"Hello, {name}!\" - -def main() -> None: - greet(\"world\") -"; - fixture.did_open("file:///callho.py", code)?; - let _ = fixture.wait_for_diagnostics(); - - let prep = send_request( - &mut fixture, - 307, - "textDocument/prepareCallHierarchy", - serde_json::json!({ - "textDocument": { "uri": "file:///callho.py" }, - "position": { "line": 3, "character": 4 } - }), - )? - .ok_or("no prepareCallHierarchy response")?; - - let prep_val: serde_json::Value = serde_json::from_str(&prep)?; - let items = prep_val["result"].as_array().ok_or("no items in prepare")?; - if items.is_empty() { - return Ok(()); - } - - let resp = send_request( - &mut fixture, - 308, - "callHierarchy/outgoingCalls", - serde_json::json!({ - "item": items[0] - }), - )? - .ok_or("no outgoingCalls response")?; - - assert!(resp.contains("\"result\""), "should have a result: {resp}"); - Ok(()) -} - -// ── Type Hierarchy ─────────────────────────────────────────────────────────── - -#[test] -fn test_lsp_prepare_type_hierarchy() -> TestResult<()> { - let mut fixture = LspTestFixture::new()?; - let _ = fixture.initialize()?; - - let code = "\ -class Animal: - name: str - -class Dog(Animal): - breed: str -"; - fixture.did_open("file:///typeh.py", code)?; - let _ = fixture.wait_for_diagnostics(); - - let resp = send_request( - &mut fixture, - 309, - "textDocument/prepareTypeHierarchy", - serde_json::json!({ - "textDocument": { "uri": "file:///typeh.py" }, - "position": { "line": 3, "character": 6 } - }), - )? - .ok_or("no prepareTypeHierarchy response")?; - - assert!(resp.contains("\"result\""), "should have a result: {resp}"); - assert!( - resp.contains("Dog"), - "should contain 'Dog' in type hierarchy: {resp}" - ); - Ok(()) -} - -#[test] -fn test_lsp_type_hierarchy_supertypes() -> TestResult<()> { - let mut fixture = LspTestFixture::new()?; - let _ = fixture.initialize()?; - - let code = "\ -class Animal: - name: str - -class Dog(Animal): - breed: str -"; - fixture.did_open("file:///typehs.py", code)?; - let _ = fixture.wait_for_diagnostics(); - - let prep = send_request( - &mut fixture, - 310, - "textDocument/prepareTypeHierarchy", - serde_json::json!({ - "textDocument": { "uri": "file:///typehs.py" }, - "position": { "line": 3, "character": 6 } - }), - )? - .ok_or("no prepareTypeHierarchy response")?; - - let prep_val: serde_json::Value = serde_json::from_str(&prep)?; - let items = prep_val["result"].as_array().ok_or("no items")?; - if items.is_empty() { - return Ok(()); - } - - let resp = send_request( - &mut fixture, - 311, - "typeHierarchy/supertypes", - serde_json::json!({ - "item": items[0] - }), - )? - .ok_or("no supertypes response")?; - - assert!(resp.contains("\"result\""), "should have a result: {resp}"); - assert!( - resp.contains("Animal"), - "supertypes of Dog should include Animal: {resp}" - ); - Ok(()) -} - -#[test] -fn test_lsp_type_hierarchy_subtypes() -> TestResult<()> { - let mut fixture = LspTestFixture::new()?; - let _ = fixture.initialize()?; - - let code = "\ -class Animal: - name: str - -class Dog(Animal): - breed: str -"; - fixture.did_open("file:///typehsub.py", code)?; - let _ = fixture.wait_for_diagnostics(); - - let prep = send_request( - &mut fixture, - 312, - "textDocument/prepareTypeHierarchy", - serde_json::json!({ - "textDocument": { "uri": "file:///typehsub.py" }, - "position": { "line": 0, "character": 6 } - }), - )? - .ok_or("no prepareTypeHierarchy response")?; - - let prep_val: serde_json::Value = serde_json::from_str(&prep)?; - let items = prep_val["result"].as_array().ok_or("no items")?; - if items.is_empty() { - return Ok(()); - } - - let resp = send_request( - &mut fixture, - 313, - "typeHierarchy/subtypes", - serde_json::json!({ - "item": items[0] - }), - )? - .ok_or("no subtypes response")?; - - assert!(resp.contains("\"result\""), "should have a result: {resp}"); - assert!( - resp.contains("Dog"), - "subtypes of Animal should include Dog: {resp}" - ); - Ok(()) -} diff --git a/crates/basilisk-lsp/tests/lsp/lsp_e2e_hover.rs b/crates/basilisk-lsp/tests/lsp/lsp_e2e_hover.rs deleted file mode 100644 index 8100a3b6a..000000000 --- a/crates/basilisk-lsp/tests/lsp/lsp_e2e_hover.rs +++ /dev/null @@ -1,398 +0,0 @@ -//! Tests for [LSPARCH-FEATURES-HOVER]. See docs/specs/LSP-ARCHITECTURE-SPEC.md#LSPARCH-FEATURES-HOVER -// Tests for LSP: `lsp_e2e_hover`. - -// LSP E2E tests — Hover (type signatures, docstrings, enhanced hover). - -use super::lsp_e2e_common::{send_request, LspTestFixture, TestResult}; - -// ── Basic hover ────────────────────────────────────────────────────────────── - -#[test] -fn test_lsp_hover_shows_function_signature() -> TestResult<()> { - let mut fixture = LspTestFixture::new()?; - let _ = fixture.initialize()?; - - let code = "def greet(name: str) -> str:\n return f\"Hello, {name}!\""; - fixture.did_open("file:///hover.py", code)?; - let _ = fixture.wait_for_diagnostics(); - - // Hover on "greet" (line 0, character 4) - let resp = send_request( - &mut fixture, - 20, - "textDocument/hover", - serde_json::json!({ - "textDocument": { "uri": "file:///hover.py" }, - "position": { "line": 0, "character": 4 } - }), - )? - .ok_or("no hover response")?; - - assert!( - resp.contains("def"), - "hover should show function def: {resp}" - ); - assert!( - resp.contains("greet"), - "hover should show function name: {resp}" - ); - assert!(resp.contains("name"), "hover should show parameter: {resp}"); - Ok(()) -} - -#[test] -fn test_lsp_hover_shows_class_signature() -> TestResult<()> { - let mut fixture = LspTestFixture::new()?; - let _ = fixture.initialize()?; - - let code = - "class Animal:\n name: str\n def speak(self) -> str:\n return self.name\n"; - fixture.did_open("file:///hclass.py", code)?; - let _ = fixture.wait_for_diagnostics(); - - // Hover on "Animal" (line 0, character 6) - let resp = send_request( - &mut fixture, - 21, - "textDocument/hover", - serde_json::json!({ - "textDocument": { "uri": "file:///hclass.py" }, - "position": { "line": 0, "character": 6 } - }), - )? - .ok_or("no hover response")?; - - assert!(resp.contains("class"), "hover should show 'class': {resp}"); - assert!( - resp.contains("Animal"), - "hover should show class name: {resp}" - ); - Ok(()) -} - -#[test] -fn test_lsp_hover_shows_variable_type() -> TestResult<()> { - let mut fixture = LspTestFixture::new()?; - let _ = fixture.initialize()?; - - let code = "x: int = 42\n"; - fixture.did_open("file:///hvar.py", code)?; - let _ = fixture.wait_for_diagnostics(); - - // Hover on "x" (line 0, character 0) - let resp = send_request( - &mut fixture, - 22, - "textDocument/hover", - serde_json::json!({ - "textDocument": { "uri": "file:///hvar.py" }, - "position": { "line": 0, "character": 0 } - }), - )? - .ok_or("no hover response")?; - - assert!( - resp.contains("variable"), - "hover should show 'variable': {resp}" - ); - assert!(resp.contains("int"), "hover should show type 'int': {resp}"); - Ok(()) -} - -// ── Enhanced hover ─────────────────────────────────────────────────────────── - -#[test] -fn test_lsp_hover_function_exact_signature() -> TestResult<()> { - let mut fixture = LspTestFixture::new()?; - let _ = fixture.initialize()?; - - let code = "def greet(name: str) -> str:\n return f\"Hello, {name}!\""; - fixture.did_open("file:///hover_exact.py", code)?; - let _ = fixture.wait_for_diagnostics(); - - let resp = send_request( - &mut fixture, - 200, - "textDocument/hover", - serde_json::json!({ - "textDocument": { "uri": "file:///hover_exact.py" }, - "position": { "line": 0, "character": 4 } - }), - )? - .ok_or("no hover response")?; - - assert!( - resp.contains("(function)"), - "hover should show '(function)' prefix: {resp}" - ); - assert!( - resp.contains("def greet"), - "hover should show 'def greet': {resp}" - ); - assert!( - resp.contains("name: str"), - "hover should show typed parameter 'name: str': {resp}" - ); - assert!( - resp.contains("-> str"), - "hover should show return type '-> str': {resp}" - ); - Ok(()) -} - -/// Regression for #253: hovering an unannotated function must surface -/// inferred types — the return type inferred from the body's `return` -/// statements, and `Unknown` for parameters whose type cannot be inferred — -/// instead of a bare, type-less signature. -#[test] -fn test_lsp_hover_unannotated_function_shows_inferred_types() -> TestResult<()> { - let mut fixture = LspTestFixture::new()?; - let _ = fixture.initialize()?; - - // Repro from #253 (mutation_testing/mutants_report.py:78): every dict - // value and the `.get` default are literal strings, so the return type - // retains its PEP 675 `LiteralString` precision. - let code = "def extracted_function(missed, caught, unviable, timeout, summary):\n return {\n \"MissedMutant\": \"missed\",\n \"CaughtMutant\": \"caught\",\n \"Unviable\": \"unviable\",\n \"Timeout\": \"timeout\",\n \"Success\": \"success\",\n }.get(summary, \"unknown\")\n"; - fixture.did_open("file:///hover_unannotated.py", code)?; - let _ = fixture.wait_for_diagnostics(); - - // Hover on "extracted_function" (line 0, character 8). - let resp = send_request( - &mut fixture, - 210, - "textDocument/hover", - serde_json::json!({ - "textDocument": { "uri": "file:///hover_unannotated.py" }, - "position": { "line": 0, "character": 8 } - }), - )? - .ok_or("no hover response")?; - - assert!( - resp.contains("(function)"), - "hover should show '(function)' prefix: {resp}" - ); - assert!( - resp.contains("def extracted_function"), - "hover should show 'def extracted_function': {resp}" - ); - assert!( - resp.contains("missed: Unknown"), - "unannotated parameter should render an inferred/Unknown type, not blank: {resp}" - ); - assert!( - resp.contains("-> LiteralString"), - "hover should show the inferred return type '-> LiteralString': {resp}" - ); - Ok(()) -} - -#[test] -fn test_lsp_hover_from_call_site() -> TestResult<()> { - let mut fixture = LspTestFixture::new()?; - let _ = fixture.initialize()?; - - let code = "def greet(name: str) -> str:\n return f\"Hello, {name}!\"\n\nresult: str = greet(\"world\")\n"; - fixture.did_open("file:///hover_call.py", code)?; - let _ = fixture.wait_for_diagnostics(); - - let resp = send_request( - &mut fixture, - 201, - "textDocument/hover", - serde_json::json!({ - "textDocument": { "uri": "file:///hover_call.py" }, - "position": { "line": 3, "character": 14 } - }), - )? - .ok_or("no hover response at call site")?; - - assert!( - resp.contains("(function)"), - "call-site hover should resolve to function: {resp}" - ); - assert!( - resp.contains("greet"), - "call-site hover should show function name: {resp}" - ); - assert!( - resp.contains("name: str"), - "call-site hover should show parameter type: {resp}" - ); - Ok(()) -} - -#[test] -fn test_lsp_hover_parameter_shows_type() -> TestResult<()> { - let mut fixture = LspTestFixture::new()?; - let _ = fixture.initialize()?; - - let code = "def greet(name: str) -> str:\n return f\"Hello, {name}!\""; - fixture.did_open("file:///hover_param.py", code)?; - let _ = fixture.wait_for_diagnostics(); - - let resp = send_request( - &mut fixture, - 202, - "textDocument/hover", - serde_json::json!({ - "textDocument": { "uri": "file:///hover_param.py" }, - "position": { "line": 0, "character": 10 } - }), - )? - .ok_or("no hover response for parameter")?; - - assert!( - resp.contains("(parameter)"), - "hover on parameter should show '(parameter)': {resp}" - ); - assert!( - resp.contains("name"), - "hover should show parameter name: {resp}" - ); - assert!( - resp.contains("str"), - "hover should show parameter type 'str': {resp}" - ); - Ok(()) -} - -#[test] -fn test_lsp_hover_class_attribute() -> TestResult<()> { - let mut fixture = LspTestFixture::new()?; - let _ = fixture.initialize()?; - - let code = "class Animal:\n name: str\n age: int\n"; - fixture.did_open("file:///hover_attr.py", code)?; - let _ = fixture.wait_for_diagnostics(); - - let resp = send_request( - &mut fixture, - 203, - "textDocument/hover", - serde_json::json!({ - "textDocument": { "uri": "file:///hover_attr.py" }, - "position": { "line": 1, "character": 4 } - }), - )? - .ok_or("no hover response for class attribute")?; - - assert!( - resp.contains("(property)"), - "hover on class attribute should show '(property)': {resp}" - ); - assert!( - resp.contains("Animal.name"), - "hover should show 'Animal.name': {resp}" - ); - assert!( - resp.contains("str"), - "hover should show attribute type 'str': {resp}" - ); - Ok(()) -} - -// ── Docstring hover ────────────────────────────────────────────────────────── - -#[test] -fn test_lsp_hover_shows_docstring() -> TestResult<()> { - let mut fixture = LspTestFixture::new()?; - let _ = fixture.initialize()?; - - let code = "\ -def calculate(x: int) -> int: - \"\"\"Compute the square of x.\"\"\" - return x * x -"; - fixture.did_open("file:///docstr.py", code)?; - let _ = fixture.wait_for_diagnostics(); - - let resp = send_request( - &mut fixture, - 210, - "textDocument/hover", - serde_json::json!({ - "textDocument": { "uri": "file:///docstr.py" }, - "position": { "line": 0, "character": 5 } - }), - )? - .ok_or("no hover response")?; - - assert!( - resp.contains("Compute the square of x"), - "hover should include docstring: {resp}" - ); - Ok(()) -} - -#[test] -fn test_lsp_hover_shows_docstring_at_call_site() -> TestResult<()> { - let mut fixture = LspTestFixture::new()?; - let _ = fixture.initialize()?; - - let code = "\ -def calculate(x: int) -> int: - \"\"\"Compute the square of x.\"\"\" - return x * x - -result: int = calculate(5) -"; - fixture.did_open("file:///docstr_call.py", code)?; - let _ = fixture.wait_for_diagnostics(); - - let resp = send_request( - &mut fixture, - 211, - "textDocument/hover", - serde_json::json!({ - "textDocument": { "uri": "file:///docstr_call.py" }, - "position": { "line": 4, "character": 18 } - }), - )? - .ok_or("no hover response at call site")?; - - assert!( - resp.contains("Compute the square of x"), - "hover at call site should include docstring: {resp}" - ); - Ok(()) -} - -#[test] -fn test_lsp_completion_includes_docstring() -> TestResult<()> { - let mut fixture = LspTestFixture::new()?; - let _ = fixture.initialize()?; - - let code = "\ -def helper(x: int) -> int: - \"\"\"Return x plus one.\"\"\" - return x + 1 - -hel -"; - fixture.did_open("file:///compdoc.py", code)?; - let _ = fixture.wait_for_diagnostics(); - - let resp = send_request( - &mut fixture, - 211, - "textDocument/completion", - serde_json::json!({ - "textDocument": { "uri": "file:///compdoc.py" }, - "position": { "line": 4, "character": 3 } - }), - )? - .ok_or("no completion response")?; - - assert!( - resp.contains("helper"), - "completions should include 'helper': {resp}" - ); - // Docstrings are now lazy-loaded via completionItem/resolve, so the initial - // completion list includes `data` for resolve but not inline documentation. - assert!( - resp.contains("\"data\""), - "completion should include resolve data: {resp}" - ); - Ok(()) -} diff --git a/crates/basilisk-lsp/tests/lsp/lsp_e2e_navigation.rs b/crates/basilisk-lsp/tests/lsp/lsp_e2e_navigation.rs deleted file mode 100644 index e014a0e84..000000000 --- a/crates/basilisk-lsp/tests/lsp/lsp_e2e_navigation.rs +++ /dev/null @@ -1,273 +0,0 @@ -//! Tests for [LSPARCH-TESTING]. See docs/specs/LSP-ARCHITECTURE-SPEC.md#LSPARCH-TESTING -// Tests for LSP: `lsp_e2e_navigation`. - -// LSP E2E tests — Go to Definition, Declaration, and Type Definition. - -use super::lsp_e2e_common::{send_request, LspTestFixture, TestResult}; - -// ── Go to Definition (basic) ───────────────────────────────────────────────── - -#[test] -fn test_lsp_goto_definition_function() -> TestResult<()> { - let mut fixture = LspTestFixture::new()?; - let _ = fixture.initialize()?; - - let code = "def greet(name: str) -> str:\n return f\"Hello, {name}!\"\n"; - fixture.did_open("file:///gotodef.py", code)?; - let _ = fixture.wait_for_diagnostics(); - - let resp = send_request( - &mut fixture, - 30, - "textDocument/definition", - serde_json::json!({ - "textDocument": { "uri": "file:///gotodef.py" }, - "position": { "line": 0, "character": 4 } - }), - )? - .ok_or("no definition response")?; - - assert!( - resp.contains("gotodef.py"), - "definition should point to same file: {resp}" - ); - Ok(()) -} - -#[test] -fn test_lsp_goto_definition_class() -> TestResult<()> { - let mut fixture = LspTestFixture::new()?; - let _ = fixture.initialize()?; - - let code = "class Dog:\n name: str\n def bark(self) -> str:\n return \"woof\"\n"; - fixture.did_open("file:///gotoclass.py", code)?; - let _ = fixture.wait_for_diagnostics(); - - let resp = send_request( - &mut fixture, - 31, - "textDocument/definition", - serde_json::json!({ - "textDocument": { "uri": "file:///gotoclass.py" }, - "position": { "line": 0, "character": 6 } - }), - )? - .ok_or("no definition response")?; - - assert!( - resp.contains("gotoclass.py"), - "definition should point to same file: {resp}" - ); - Ok(()) -} - -// ── Enhanced Go to Definition ──────────────────────────────────────────────── - -#[test] -fn test_lsp_goto_definition_returns_exact_position() -> TestResult<()> { - let mut fixture = LspTestFixture::new()?; - let _ = fixture.initialize()?; - - let code = "def greet(name: str) -> str:\n return f\"Hello, {name}!\"\n"; - fixture.did_open("file:///gotoexact.py", code)?; - let _ = fixture.wait_for_diagnostics(); - - let resp = send_request( - &mut fixture, - 300, - "textDocument/definition", - serde_json::json!({ - "textDocument": { "uri": "file:///gotoexact.py" }, - "position": { "line": 0, "character": 4 } - }), - )? - .ok_or("no definition response")?; - - let parsed: serde_json::Value = serde_json::from_str(&resp)?; - assert!( - parsed["result"] != serde_json::Value::Null, - "definition result must not be null: {resp}" - ); - let start = &parsed["result"]["range"]["start"]; - assert_eq!(start["line"], 0, "definition must be on line 0: {resp}"); - assert_eq!( - start["character"], 4, - "definition must start at char 4, where 'greet' begins: {resp}" - ); - Ok(()) -} - -#[test] -fn test_lsp_goto_definition_from_call_site() -> TestResult<()> { - let mut fixture = LspTestFixture::new()?; - let _ = fixture.initialize()?; - - let code = "def greet(name: str) -> str:\n return f\"Hello, {name}!\"\n\nresult: str = greet(\"world\")\n"; - fixture.did_open("file:///goto_call.py", code)?; - let _ = fixture.wait_for_diagnostics(); - - let resp = send_request( - &mut fixture, - 301, - "textDocument/definition", - serde_json::json!({ - "textDocument": { "uri": "file:///goto_call.py" }, - "position": { "line": 3, "character": 14 } - }), - )? - .ok_or("no definition response from call site")?; - - let parsed: serde_json::Value = serde_json::from_str(&resp)?; - assert!( - parsed["result"] != serde_json::Value::Null, - "goto-def from call site must resolve: {resp}" - ); - let start = &parsed["result"]["range"]["start"]; - assert_eq!( - start["line"], 0, - "goto-def from call should jump to line 0: {resp}" - ); - assert_eq!( - start["character"], 4, - "goto-def from call should land at char 4 where 'greet' is defined: {resp}" - ); - Ok(()) -} - -#[test] -fn test_lsp_goto_definition_class_from_type_annotation() -> TestResult<()> { - let mut fixture = LspTestFixture::new()?; - let _ = fixture.initialize()?; - - let code = "class Dog:\n name: str\n\ndef pet(dog: Dog) -> None:\n pass\n"; - fixture.did_open("file:///goto_type.py", code)?; - let _ = fixture.wait_for_diagnostics(); - - let resp = send_request( - &mut fixture, - 302, - "textDocument/definition", - serde_json::json!({ - "textDocument": { "uri": "file:///goto_type.py" }, - "position": { "line": 3, "character": 13 } - }), - )? - .ok_or("no definition for class used in type annotation")?; - - let parsed: serde_json::Value = serde_json::from_str(&resp)?; - assert!( - parsed["result"] != serde_json::Value::Null, - "goto-def on type annotation must resolve: {resp}" - ); - let start = &parsed["result"]["range"]["start"]; - assert_eq!( - start["line"], 0, - "goto-def should jump to class definition at line 0: {resp}" - ); - assert_eq!( - start["character"], 6, - "goto-def should land at char 6 where 'Dog' is defined: {resp}" - ); - Ok(()) -} - -// ── Go to Declaration ──────────────────────────────────────────────────────── - -#[test] -fn test_lsp_goto_declaration_function() -> TestResult<()> { - let mut fixture = LspTestFixture::new()?; - let _ = fixture.initialize()?; - - let code = "\ -def compute(x: int) -> int: - return x * 2 - -result: int = compute(10) -"; - fixture.did_open("file:///decl.py", code)?; - let _ = fixture.wait_for_diagnostics(); - - let resp = send_request( - &mut fixture, - 200, - "textDocument/declaration", - serde_json::json!({ - "textDocument": { "uri": "file:///decl.py" }, - "position": { "line": 3, "character": 16 } - }), - )? - .ok_or("no declaration response")?; - - assert!( - resp.contains("\"line\":0"), - "declaration should point to line 0 (function def): {resp}" - ); - Ok(()) -} - -// ── Go to Type Definition ──────────────────────────────────────────────────── - -#[test] -fn test_lsp_goto_type_definition_variable() -> TestResult<()> { - let mut fixture = LspTestFixture::new()?; - let _ = fixture.initialize()?; - - let code = "\ -class MyData: - value: int - -instance: MyData = MyData() -"; - fixture.did_open("file:///typedef.py", code)?; - let _ = fixture.wait_for_diagnostics(); - - let resp = send_request( - &mut fixture, - 201, - "textDocument/typeDefinition", - serde_json::json!({ - "textDocument": { "uri": "file:///typedef.py" }, - "position": { "line": 3, "character": 2 } - }), - )? - .ok_or("no type definition response")?; - - assert!( - resp.contains("\"line\":0"), - "type definition should point to line 0 (class MyData): {resp}" - ); - Ok(()) -} - -#[test] -fn test_lsp_goto_type_definition_parameter() -> TestResult<()> { - let mut fixture = LspTestFixture::new()?; - let _ = fixture.initialize()?; - - let code = "\ -class Config: - debug: bool - -def process(cfg: Config) -> None: - pass -"; - fixture.did_open("file:///typedef2.py", code)?; - let _ = fixture.wait_for_diagnostics(); - - let resp = send_request( - &mut fixture, - 202, - "textDocument/typeDefinition", - serde_json::json!({ - "textDocument": { "uri": "file:///typedef2.py" }, - "position": { "line": 3, "character": 13 } - }), - )? - .ok_or("no type definition response")?; - - assert!( - resp.contains("\"line\":0"), - "type definition should point to line 0 (class Config): {resp}" - ); - Ok(()) -} diff --git a/crates/basilisk-lsp/tests/lsp/lsp_e2e_refactoring.rs b/crates/basilisk-lsp/tests/lsp/lsp_e2e_refactoring.rs deleted file mode 100644 index 399b875d0..000000000 --- a/crates/basilisk-lsp/tests/lsp/lsp_e2e_refactoring.rs +++ /dev/null @@ -1,605 +0,0 @@ -//! Tests for [LSPARCH-TESTING]. See docs/specs/LSP-ARCHITECTURE-SPEC.md#LSPARCH-TESTING -// E2E tests for refactoring code actions. -// -// These tests spin up the full LSP server via stdio and verify that -// refactoring code actions are offered and produce correct edits. - -use super::lsp_e2e_common::{send_request, LspTestFixture, TestResult}; - -/// Request code actions for a given file, range, and no diagnostics. -fn request_code_actions( - fixture: &mut LspTestFixture, - uri: &str, - start_line: u32, - start_char: u32, - end_line: u32, - end_char: u32, - request_id: u64, -) -> TestResult { - send_request( - fixture, - request_id, - "textDocument/codeAction", - serde_json::json!({ - "textDocument": { "uri": uri }, - "range": { - "start": { "line": start_line, "character": start_char }, - "end": { "line": end_line, "character": end_char } - }, - "context": { "diagnostics": [] } - }), - )? - .ok_or_else(|| "no code action response".into()) -} - -// ── Extract Variable ──────────────────────────────────────────────────────── - -#[test] -fn test_refactor_extract_variable_offered() -> TestResult<()> { - let mut fixture = LspTestFixture::new()?; - let _ = fixture.initialize()?; - - let code = "result = some_func(42) + other_func(7)\n"; - fixture.did_open("file:///extract_var.py", code)?; - let _ = fixture.wait_for_diagnostics(); - - let resp = request_code_actions( - &mut fixture, - "file:///extract_var.py", - 0, - 9, - 0, - 22, // select `some_func(42)` - 300, - )?; - - assert!( - resp.contains("Extract variable (basilisk)"), - "should offer extract variable: {resp}" - ); - assert!( - resp.contains("refactor.extract.variable"), - "should have correct kind: {resp}" - ); - Ok(()) -} - -// ── Extract Constant ──────────────────────────────────────────────────────── - -#[test] -fn test_refactor_extract_constant_offered() -> TestResult<()> { - let mut fixture = LspTestFixture::new()?; - let _ = fixture.initialize()?; - - let code = "import os\n\ndef f() -> int:\n return 42\n"; - fixture.did_open("file:///extract_const.py", code)?; - let _ = fixture.wait_for_diagnostics(); - - let resp = request_code_actions( - &mut fixture, - "file:///extract_const.py", - 3, - 11, - 3, - 13, // select `42` - 301, - )?; - - assert!( - resp.contains("Extract constant (basilisk)"), - "should offer extract constant: {resp}" - ); - Ok(()) -} - -// ── Extract Function ──────────────────────────────────────────────────────── - -#[test] -fn test_refactor_extract_function_offered() -> TestResult<()> { - let mut fixture = LspTestFixture::new()?; - let _ = fixture.initialize()?; - - let code = "def main() -> None:\n x: int = 1\n y: int = x + 1\n print(y)\n"; - fixture.did_open("file:///extract_fn.py", code)?; - let _ = fixture.wait_for_diagnostics(); - - let resp = request_code_actions( - &mut fixture, - "file:///extract_fn.py", - 1, - 0, - 3, - 0, // select lines 1-2 - 302, - )?; - - assert!( - resp.contains("Extract function (basilisk)"), - "should offer extract function: {resp}" - ); - assert!( - resp.contains("refactor.extract.function"), - "should have correct kind: {resp}" - ); - Ok(()) -} - -// Exercises [REFACTOR-EXTRACT-FUNC-EDGE] — reject selections containing yield. -#[test] -fn test_refactor_extract_function_rejects_yield() -> TestResult<()> { - let mut fixture = LspTestFixture::new()?; - let _ = fixture.initialize()?; - - let code = "def gen() -> None:\n yield 1\n yield 2\n"; - fixture.did_open("file:///no_yield.py", code)?; - let _ = fixture.wait_for_diagnostics(); - - let resp = request_code_actions( - &mut fixture, - "file:///no_yield.py", - 1, - 0, - 3, - 0, // select yield lines - 303, - )?; - - assert!( - !resp.contains("Extract function (basilisk)"), - "should NOT offer extract function when selection contains yield: {resp}" - ); - Ok(()) -} - -// ── Union/Optional Conversion ─────────────────────────────────────────────── - -#[test] -fn test_refactor_convert_union_offered() -> TestResult<()> { - let mut fixture = LspTestFixture::new()?; - let _ = fixture.initialize()?; - - let code = "from typing import Union\nx: Union[int, str] = 1\n"; - fixture.did_open("file:///union.py", code)?; - let _ = fixture.wait_for_diagnostics(); - - let resp = request_code_actions( - &mut fixture, - "file:///union.py", - 1, - 3, - 1, - 3, // cursor on Union - 304, - )?; - - assert!( - resp.contains("Union[X, Y] to X | Y"), - "should offer Union to pipe conversion: {resp}" - ); - Ok(()) -} - -#[test] -fn test_refactor_convert_optional_offered() -> TestResult<()> { - let mut fixture = LspTestFixture::new()?; - let _ = fixture.initialize()?; - - let code = "from typing import Optional\nx: Optional[int] = None\n"; - fixture.did_open("file:///optional.py", code)?; - let _ = fixture.wait_for_diagnostics(); - - let resp = request_code_actions( - &mut fixture, - "file:///optional.py", - 1, - 3, - 1, - 3, // cursor on Optional - 305, - )?; - - assert!( - resp.contains("Optional[X] to X | None"), - "should offer Optional to pipe conversion: {resp}" - ); - Ok(()) -} - -// ── f-string Conversion ──────────────────────────────────────────────────── - -#[test] -fn test_refactor_convert_fstring_offered() -> TestResult<()> { - let mut fixture = LspTestFixture::new()?; - let _ = fixture.initialize()?; - - let code = "name: str = \"world\"\nx: str = f\"hello {name}\"\n"; - fixture.did_open("file:///fstr.py", code)?; - let _ = fixture.wait_for_diagnostics(); - - let resp = request_code_actions( - &mut fixture, - "file:///fstr.py", - 1, - 9, - 1, - 9, // cursor on f-string - 306, - )?; - - assert!( - resp.contains(".format()"), - "should offer f-string to .format() conversion: {resp}" - ); - Ok(()) -} - -// ── dict/list Literal Conversion ──────────────────────────────────────────── - -#[test] -fn test_refactor_convert_dict_offered() -> TestResult<()> { - let mut fixture = LspTestFixture::new()?; - let _ = fixture.initialize()?; - - let code = "x: dict[str, int] = dict(a=1, b=2)\n"; - fixture.did_open("file:///dictconv.py", code)?; - let _ = fixture.wait_for_diagnostics(); - - let resp = request_code_actions( - &mut fixture, - "file:///dictconv.py", - 0, - 20, - 0, - 20, // cursor on dict() - 307, - )?; - - assert!( - resp.contains("dict"), - "should offer dict() conversion: {resp}" - ); - Ok(()) -} - -#[test] -fn test_refactor_convert_list_offered() -> TestResult<()> { - let mut fixture = LspTestFixture::new()?; - let _ = fixture.initialize()?; - - let code = "x: list[int] = list()\n"; - fixture.did_open("file:///listconv.py", code)?; - let _ = fixture.wait_for_diagnostics(); - - let resp = request_code_actions( - &mut fixture, - "file:///listconv.py", - 0, - 15, - 0, - 15, // cursor on list() - 308, - )?; - - assert!( - resp.contains("list"), - "should offer list() conversion: {resp}" - ); - Ok(()) -} - -// ── Ternary Conversion ────────────────────────────────────────────────────── - -#[test] -fn test_refactor_convert_ternary_offered() -> TestResult<()> { - let mut fixture = LspTestFixture::new()?; - let _ = fixture.initialize()?; - - let code = "def f(cond: bool) -> int:\n x: int = 1 if cond else 0\n return x\n"; - fixture.did_open("file:///ternary.py", code)?; - let _ = fixture.wait_for_diagnostics(); - - let resp = request_code_actions( - &mut fixture, - "file:///ternary.py", - 1, - 4, - 1, - 4, // cursor on ternary line - 309, - )?; - - assert!( - resp.contains("if/else"), - "should offer ternary to if/else conversion: {resp}" - ); - Ok(()) -} - -// ── Inline Variable ───────────────────────────────────────────────────────── - -#[test] -fn test_refactor_inline_variable_offered() -> TestResult<()> { - let mut fixture = LspTestFixture::new()?; - let _ = fixture.initialize()?; - - let code = "def f() -> None:\n temp = calculate()\n result = temp + 1\n"; - fixture.did_open("file:///inline_var.py", code)?; - let _ = fixture.wait_for_diagnostics(); - - let resp = request_code_actions( - &mut fixture, - "file:///inline_var.py", - 1, - 4, - 1, - 4, // cursor on assignment inside function - 310, - )?; - - assert!( - resp.contains("Inline variable (basilisk)"), - "should offer inline variable: {resp}" - ); - Ok(()) -} - -// ── Inline Function ───────────────────────────────────────────────────────── - -#[test] -fn test_refactor_inline_function_offered() -> TestResult<()> { - let mut fixture = LspTestFixture::new()?; - let _ = fixture.initialize()?; - - let code = "def double(x: int) -> int:\n return x * 2\n\nresult: int = double(5)\n"; - fixture.did_open("file:///inline_fn.py", code)?; - let _ = fixture.wait_for_diagnostics(); - - let resp = request_code_actions( - &mut fixture, - "file:///inline_fn.py", - 3, - 14, - 3, - 14, // cursor on call - 311, - )?; - - assert!( - resp.contains("Inline function (basilisk)"), - "should offer inline function: {resp}" - ); - Ok(()) -} - -// ── Move Symbol ───────────────────────────────────────────────────────────── - -// Exercises [REFACTOR-MOVE] / [REFACTOR-MOVE-NEW] — move a class to a new file. -#[test] -fn test_refactor_move_symbol_offered() -> TestResult<()> { - let mut fixture = LspTestFixture::new()?; - let _ = fixture.initialize()?; - - let code = "import os\n\nclass MyWidget:\n pass\n"; - fixture.did_open("file:///move.py", code)?; - let _ = fixture.wait_for_diagnostics(); - - let resp = request_code_actions( - &mut fixture, - "file:///move.py", - 2, - 0, - 2, - 0, // cursor on class line - 312, - )?; - - assert!( - resp.contains("Move") && resp.contains("new file"), - "should offer move to new file: {resp}" - ); - assert!( - resp.contains("refactor.move"), - "should have correct kind: {resp}" - ); - Ok(()) -} - -// ── NamedTuple Conversion ─────────────────────────────────────────────────── - -#[test] -fn test_refactor_convert_namedtuple_offered() -> TestResult<()> { - let mut fixture = LspTestFixture::new()?; - let _ = fixture.initialize()?; - - let code = - "from typing import NamedTuple\n\nclass Point(NamedTuple):\n x: int\n y: int\n"; - fixture.did_open("file:///nt.py", code)?; - let _ = fixture.wait_for_diagnostics(); - - let resp = request_code_actions( - &mut fixture, - "file:///nt.py", - 2, - 0, - 2, - 0, // cursor on class line - 313, - )?; - - assert!( - resp.contains("NamedTuple") || resp.contains("namedtuple"), - "should offer NamedTuple conversion: {resp}" - ); - Ok(()) -} - -// ── Rename with Scope Awareness ───────────────────────────────────────────── - -// Exercises [REFACTOR-RENAME] / [REFACTOR-RENAME-SCOPE]. Implementation lives -// out of scope in crates/basilisk-lsp/src/references.rs + scope_tree.rs. -#[test] -fn test_refactor_rename_produces_scoped_edits() -> TestResult<()> { - let mut fixture = LspTestFixture::new()?; - let _ = fixture.initialize()?; - - let code = "\ -def outer() -> None: - x: int = 1 - print(x) - -def inner() -> None: - x: int = 2 - print(x) -"; - fixture.did_open("file:///scope_rename.py", code)?; - let _ = fixture.wait_for_diagnostics(); - - // Rename `x` in outer — should NOT touch `x` in inner. - let resp = send_request( - &mut fixture, - 314, - "textDocument/rename", - serde_json::json!({ - "textDocument": { "uri": "file:///scope_rename.py" }, - "position": { "line": 1, "character": 4 }, - "newName": "outer_x" - }), - )? - .ok_or("no rename response")?; - - assert!( - resp.contains("outer_x"), - "rename should produce outer_x: {resp}" - ); - // The response should contain changes — verify it has edits. - assert!( - resp.contains("changes"), - "rename should include workspace changes: {resp}" - ); - Ok(()) -} - -// ── Code Action Edit Verification ─────────────────────────────────────────── - -#[test] -fn test_refactor_extract_variable_edit_correctness() -> TestResult<()> { - let mut fixture = LspTestFixture::new()?; - let _ = fixture.initialize()?; - - let code = "result: int = some_func(42) + other_func(7)\n"; - fixture.did_open("file:///ev_edit.py", code)?; - let _ = fixture.wait_for_diagnostics(); - - let resp = request_code_actions( - &mut fixture, - "file:///ev_edit.py", - 0, - 14, - 0, - 27, // select `some_func(42)` - 315, - )?; - - let parsed: serde_json::Value = serde_json::from_str(&resp)?; - let actions = parsed["result"].as_array().ok_or("expected result array")?; - - let extract_action = actions - .iter() - .find(|a| { - a["title"] - .as_str() - .is_some_and(|t| t.contains("Extract variable (basilisk)")) - }) - .ok_or("no extract variable action found")?; - - // Verify it has a workspace edit with changes. - assert!( - extract_action["edit"]["changes"].is_object(), - "extract variable should produce workspace edit with changes" - ); - Ok(()) -} - -#[test] -fn test_refactor_inline_variable_edit_correctness() -> TestResult<()> { - let mut fixture = LspTestFixture::new()?; - let _ = fixture.initialize()?; - - let code = "def f() -> None:\n temp = calculate()\n result = temp + 1\n"; - fixture.did_open("file:///iv_edit.py", code)?; - let _ = fixture.wait_for_diagnostics(); - - let resp = request_code_actions( - &mut fixture, - "file:///iv_edit.py", - 1, - 4, - 1, - 4, // cursor on assignment - 316, - )?; - - let parsed: serde_json::Value = serde_json::from_str(&resp)?; - let actions = parsed["result"].as_array().ok_or("expected result array")?; - - let inline_action = actions - .iter() - .find(|a| { - a["title"] - .as_str() - .is_some_and(|t| t.contains("Inline variable")) - }) - .ok_or("no inline variable action found")?; - - assert!( - inline_action["edit"]["changes"].is_object(), - "inline variable should produce workspace edit with changes" - ); - Ok(()) -} - -// ── Negative Cases ────────────────────────────────────────────────────────── - -#[test] -fn test_refactor_extract_variable_not_offered_for_empty_selection() -> TestResult<()> { - let mut fixture = LspTestFixture::new()?; - let _ = fixture.initialize()?; - - let code = "x: int = 1\n"; - fixture.did_open("file:///ev_empty.py", code)?; - let _ = fixture.wait_for_diagnostics(); - - let resp = request_code_actions( - &mut fixture, - "file:///ev_empty.py", - 0, - 5, - 0, - 5, // zero-width selection - 317, - )?; - - assert!( - !resp.contains("Extract variable (basilisk)"), - "should NOT offer extract variable for empty selection: {resp}" - ); - Ok(()) -} - -#[test] -fn test_refactor_move_symbol_not_offered_for_assignment() -> TestResult<()> { - let mut fixture = LspTestFixture::new()?; - let _ = fixture.initialize()?; - - let code = "x: int = 42\n"; - fixture.did_open("file:///no_move.py", code)?; - let _ = fixture.wait_for_diagnostics(); - - let resp = request_code_actions(&mut fixture, "file:///no_move.py", 0, 0, 0, 0, 318)?; - - assert!( - !resp.contains("Move") || !resp.contains("new file"), - "should NOT offer move for plain assignments: {resp}" - ); - Ok(()) -} diff --git a/crates/basilisk-lsp/tests/lsp/lsp_tests.rs b/crates/basilisk-lsp/tests/lsp/lsp_tests.rs deleted file mode 100644 index 93bc80739..000000000 --- a/crates/basilisk-lsp/tests/lsp/lsp_tests.rs +++ /dev/null @@ -1,31 +0,0 @@ -//! Tests for [LSPARCH-TESTING]. See docs/specs/LSP-ARCHITECTURE-SPEC.md#LSPARCH-TESTING -// Integration tests for basilisk-lsp. - -#[test] -fn lsp_returns_diagnostics_for_unannotated_function() { - // The require-annotation house rules (BSK-0001/0002) are off by default — - // the default config is pure PEP conformance — so opt in via config, exactly - // as a project would. See [CHKARCH-CONFIGURATION-ONLY]. - let source = "def foo(x):\n pass\n"; - let config = basilisk_config::BasiliskConfig::with_rule_entries( - ["BSK-0001", "BSK-0002"] - .into_iter() - .map(|code| (code.to_owned(), basilisk_config::RuleSeverity::Error)) - .collect(), - ); - let diags = basilisk_lsp::check_source_with_config(source, &config); - assert!( - !diags.is_empty(), - "LSP must return diagnostics for unannotated function once house rules are enabled" - ); -} - -#[test] -fn lsp_returns_no_diagnostics_for_clean_code() { - let source = "def foo(x: int) -> int:\n return x\n"; - let diags = basilisk_lsp::check_source(source); - assert!( - diags.is_empty(), - "fully annotated code must produce no LSP diagnostics" - ); -} diff --git a/crates/basilisk-lsp/tests/lsp/ws_test_common.rs b/crates/basilisk-lsp/tests/lsp/ws_test_common.rs index 4a45a0c2c..5f48b6841 100644 --- a/crates/basilisk-lsp/tests/lsp/ws_test_common.rs +++ b/crates/basilisk-lsp/tests/lsp/ws_test_common.rs @@ -235,7 +235,7 @@ python-version = \"3.12\"\n\ /// /// Server-initiated requests (e.g. `workspace/applyEdit`) arriving while /// waiting are auto-answered so the server never blocks waiting on the - /// client — mirroring `LspStdioFixture::auto_respond_if_server_request`. + /// client. /// /// # Errors /// diff --git a/crates/basilisk-lsp/tests/lsp/zed_e2e_common.rs b/crates/basilisk-lsp/tests/lsp/zed_e2e_common.rs deleted file mode 100644 index 14c9160bb..000000000 --- a/crates/basilisk-lsp/tests/lsp/zed_e2e_common.rs +++ /dev/null @@ -1,278 +0,0 @@ -//! Tests for [LSPARCH-TESTING]. See docs/specs/LSP-ARCHITECTURE-SPEC.md#LSPARCH-TESTING -// Shared test infrastructure for Zed extension E2E tests. -// -// Each Zed test file imports this module via `mod zed_e2e_common;` to get -// the fixture, type alias, and helper functions. - -use std::io::{BufRead, BufReader, Read, Write}; -use std::process::{Child, ChildStdin, Command, Stdio}; -use std::sync::mpsc::{channel, Receiver}; -use std::thread; -use std::time::Duration; - -pub use basilisk_common::commands; -pub use basilisk_test_utils::TestResult; - -use basilisk_test_utils::basilisk_binary; - -/// Timeout for reading a single LSP message. -pub const READ_TIMEOUT: Duration = Duration::from_secs(5); - -/// Test fixture that manages a `basilisk lsp` child process. -/// -/// Mirrors the exact spawn-and-communicate pattern that the Zed extension uses -/// (binary + "lsp" arg, stdio JSON-RPC). -pub struct ZedLspFixture { - /// The LSP server child process. - pub child: Child, - /// Stdin handle for sending JSON-RPC messages. - pub stdin: ChildStdin, - /// Channel receiving parsed JSON-RPC response bodies. - pub responses: Receiver, - /// Auto-incrementing request ID counter. - pub next_id: i64, - /// Temp workspace root opened during initialize, shipping a `pyproject.toml` - /// whose `[tool.basilisk.rules]` opts into the annotation house rules (off - /// by default — the default config is pure PEP conformance). Documents fall - /// back to this root's config. No modes; configuration. - /// See [CHKARCH-CONFIGURATION-ONLY]. - pub workspace_root: std::path::PathBuf, -} - -impl ZedLspFixture { - /// Spawn the LSP server exactly as the Zed extension would. - /// - /// # Errors - /// Returns an error if the binary cannot be spawned or stdio handles are unavailable. - pub fn new() -> TestResult { - // Per-process sequence for unique temp workspace names. - static SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); - - let mut child = Command::new(basilisk_binary()) - .arg("lsp") - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn()?; - - let stdin = child.stdin.take().ok_or("failed to get stdin")?; - let stdout = child.stdout.take().ok_or("failed to get stdout")?; - let stderr = child.stderr.take().ok_or("failed to get stderr")?; - - let (tx, rx) = channel(); - - // Background reader for stdout: parse LSP frames. - let _ = thread::spawn(move || { - let mut reader = BufReader::new(stdout); - let mut line = String::new(); - loop { - let mut content_length: Option = None; - loop { - line.clear(); - if reader.read_line(&mut line).unwrap_or(0) == 0 { - return; - } - let trimmed = line.trim(); - if trimmed.is_empty() { - if content_length.is_some() { - break; - } - continue; - } - if let Some(rest) = trimmed.strip_prefix("Content-Length:") { - content_length = rest.trim().parse().ok(); - } - } - let Some(length) = content_length else { - continue; - }; - let mut buf = vec![0u8; length]; - if reader.read_exact(&mut buf).is_err() { - return; - } - if let Ok(body) = String::from_utf8(buf) { - if tx.send(body).is_err() { - return; - } - } - } - }); - - // Drain stderr to console. - let _ = thread::spawn(move || { - let mut reader = BufReader::new(stderr); - let mut line = String::new(); - while reader.read_line(&mut line).unwrap_or(0) > 0 { - eprint!("[LSP stderr] {line}"); - line.clear(); - } - }); - - // Create a temp workspace that opts into the annotation house rules so - // documents (which fall back to the root's checker config) see them. - let seq = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed); - let workspace_root = - std::env::temp_dir().join(format!("bsk_zed_fixture_{}_{seq}", std::process::id())); - std::fs::create_dir_all(&workspace_root)?; - std::fs::write( - workspace_root.join("pyproject.toml"), - "[tool.basilisk.rules]\n\"BSK-0001\" = \"error\"\n\"BSK-0002\" = \"error\"\n", - )?; - - Ok(Self { - child, - stdin, - responses: rx, - next_id: 1, - workspace_root, - }) - } - - /// Send a JSON-RPC message. - /// - /// # Errors - /// Returns an error if writing to stdin or flushing fails. - pub fn send_json(&mut self, value: &serde_json::Value) -> TestResult<()> { - let body = value.to_string(); - let frame = format!("Content-Length: {}\r\n\r\n{}", body.len(), body); - self.stdin.write_all(frame.as_bytes())?; - self.stdin.flush()?; - Ok(()) - } - - /// Read the next message (with timeout). - #[must_use] - pub fn recv(&self) -> Option { - self.responses.recv_timeout(READ_TIMEOUT).ok() - } - - /// Allocate the next request ID. - #[must_use] - pub fn next_id(&mut self) -> i64 { - let id = self.next_id; - self.next_id += 1; - id - } - - /// Initialize with Zed-style `initializationOptions` (workspaceRoot). - /// - /// This is exactly what `language_server_initialization_options()` sends. - /// - /// # Errors - /// Returns an error if writing the init request fails or no response is received. - pub fn initialize_zed_style(&mut self) -> TestResult { - let id = self.next_id(); - // Point both rootUri and the Zed-style workspaceRoot option at the - // configured temp workspace so documents resolve to a config with the - // annotation house rules enabled. See [CHKARCH-CONFIGURATION-ONLY]. - let root_path = self.workspace_root.to_string_lossy().into_owned(); - let root_uri = format!("file://{root_path}"); - self.send_json(&serde_json::json!({ - "jsonrpc": "2.0", - "id": id, - "method": "initialize", - "params": { - "processId": std::process::id(), - "rootUri": root_uri, - "capabilities": {}, - "initializationOptions": { - "workspaceRoot": root_path - }, - "trace": "off" - } - }))?; - - // The server may send log/notification messages before the init - // response. Search by ID to find the actual response. - let id_str = format!("\"id\":{id}"); - let mut response = None; - for _ in 0..20 { - let Some(msg) = self.recv() else { break }; - if msg.contains(&id_str) { - response = Some(msg); - break; - } - } - let response = response.ok_or("no response to initialize")?; - - self.send_json(&serde_json::json!({ - "jsonrpc": "2.0", - "method": "initialized", - "params": {} - }))?; - - // Drain any log messages from initialization. - let _ = self.responses.recv_timeout(Duration::from_millis(500)); - let _ = self.responses.recv_timeout(Duration::from_millis(500)); - - Ok(response) - } - - /// Send `textDocument/didOpen`. - /// - /// # Errors - /// Returns an error if writing to stdin fails. - pub fn did_open(&mut self, uri: &str, text: &str) -> TestResult<()> { - self.send_json(&serde_json::json!({ - "jsonrpc": "2.0", - "method": "textDocument/didOpen", - "params": { - "textDocument": { - "uri": uri, - "languageId": "python", - "version": 1, - "text": text - } - } - })) - } - - /// Wait for a `publishDiagnostics` notification, skipping unrelated messages. - #[must_use] - pub fn wait_for_diagnostics(&self) -> Option { - for _ in 0..10 { - let msg = self.recv()?; - if msg.contains("\"method\":\"textDocument/publishDiagnostics\"") { - return Some(msg); - } - } - None - } - - /// Send a request and wait for the response with the matching ID. - /// - /// # Errors - /// Returns an error if writing the request fails or no matching response is received. - pub fn request( - &mut self, - method: &str, - params: &serde_json::Value, - ) -> TestResult { - let id = self.next_id(); - self.send_json(&serde_json::json!({ - "jsonrpc": "2.0", - "id": id, - "method": method, - "params": params - }))?; - - let id_str = format!("\"id\":{id}"); - for _ in 0..20 { - let Some(msg) = self.recv() else { - return Err("timeout waiting for response".into()); - }; - if msg.contains(&id_str) { - return Ok(serde_json::from_str(&msg)?); - } - } - Err(format!("no response found for id {id}").into()) - } -} - -impl Drop for ZedLspFixture { - fn drop(&mut self) { - let _ = self.child.kill(); - let _ = self.child.wait(); - let _ = std::fs::remove_dir_all(&self.workspace_root); - } -} diff --git a/crates/basilisk-lsp/tests/lsp/zed_extension_e2e_advanced.rs b/crates/basilisk-lsp/tests/lsp/zed_extension_e2e_advanced.rs deleted file mode 100644 index d47e7ceac..000000000 --- a/crates/basilisk-lsp/tests/lsp/zed_extension_e2e_advanced.rs +++ /dev/null @@ -1,342 +0,0 @@ -//! Tests for [LSPARCH-TESTING]. See docs/specs/LSP-ARCHITECTURE-SPEC.md#LSPARCH-TESTING -// E2E tests simulating the Zed extension's interaction with the Basilisk LSP. -// -// Tests: document symbols, execute commands, inlay hints, semantic tokens, -// go to definition, find references, formatting, multiple documents, docs URL. - -use super::zed_e2e_common::*; - -// ── Document Symbols ──────────────────────────────────────────────────────── - -/// Document symbols must work — Zed uses these for the outline panel. -#[test] -fn test_zed_document_symbols() -> TestResult<()> { - let mut fixture = ZedLspFixture::new()?; - let _ = fixture.initialize_zed_style()?; - - let code = "class MyClass:\n def method(self) -> None:\n pass\n\ndef standalone(x: int) -> int:\n return x\n"; - fixture.did_open("file:///symbols.py", code)?; - let _ = fixture.wait_for_diagnostics(); - - let symbols = fixture.request( - "textDocument/documentSymbol", - &serde_json::json!({ - "textDocument": { "uri": "file:///symbols.py" } - }), - )?; - - let result = &symbols["result"]; - assert!( - result.is_array(), - "document symbols must return an array: {symbols}" - ); - - Ok(()) -} - -// ── Execute Commands ──────────────────────────────────────────────────────── - -/// Execute command: the Zed extension uses basilisk custom commands via LSP. -/// Verify the organize imports command works. -#[test] -fn test_zed_execute_organize_imports() -> TestResult<()> { - let mut fixture = ZedLspFixture::new()?; - let _ = fixture.initialize_zed_style()?; - - let code = "import os\nimport sys\n\ndef foo() -> None:\n pass\n"; - fixture.did_open("file:///imports.py", code)?; - let _ = fixture.wait_for_diagnostics(); - - let result = fixture.request( - "workspace/executeCommand", - &serde_json::json!({ - "command": commands::ORGANIZE_IMPORTS, - "arguments": [{ "uri": "file:///imports.py" }] - }), - )?; - - // Must not return an error. - assert!( - result.get("error").is_none(), - "organize imports should not error: {result}" - ); - - Ok(()) -} - -/// Execute command: start debug session. Even if debugpy isn't installed, -/// the LSP should return a structured error (not crash). -// Tests [LSPDEBUG-START] / [LSPDEBUG-WIRE]: basilisk.startDebugSession dispatch. -#[test] -fn test_zed_execute_start_debug_session() -> TestResult<()> { - let mut fixture = ZedLspFixture::new()?; - let _ = fixture.initialize_zed_style()?; - - let result = fixture.request( - "workspace/executeCommand", - &serde_json::json!({ - "command": commands::START_DEBUG_SESSION, - "arguments": [] - }), - )?; - - // The command should either succeed (if debugpy is installed) or return - // a structured error — either way, the LSP must stay alive. - // Verify the LSP didn't crash by sending another request. - let code = "x: int = 1\n"; - fixture.did_open("file:///alive_check.py", code)?; - let diag = fixture - .wait_for_diagnostics() - .ok_or("LSP died after startDebugSession")?; - - assert!( - diag.contains("\"diagnostics\""), - "LSP must still respond: {diag}" - ); - - // Check result shape — must be a response (not a crash). - assert!( - result.get("id").is_some(), - "must have response id: {result}" - ); - - Ok(()) -} - -/// Execute command: stop debug session with a fake session ID. -/// Should not crash the LSP. -// Tests [LSPDEBUG-STOP] / [LSPDEBUG-WIRE]: basilisk.stopDebugSession dispatch. -#[test] -fn test_zed_execute_stop_debug_session() -> TestResult<()> { - let mut fixture = ZedLspFixture::new()?; - let _ = fixture.initialize_zed_style()?; - - let result = fixture.request( - "workspace/executeCommand", - &serde_json::json!({ - "command": commands::STOP_DEBUG_SESSION, - "arguments": [{ "sessionId": "nonexistent-session-id" }] - }), - )?; - - // Must not crash. Result should indicate the session wasn't found. - assert!( - result.get("id").is_some(), - "must have response id: {result}" - ); - - Ok(()) -} - -// ── Inlay Hints ───────────────────────────────────────────────────────────── - -/// Inlay hints must work — Zed shows these inline in the editor. -#[test] -fn test_zed_inlay_hints() -> TestResult<()> { - let mut fixture = ZedLspFixture::new()?; - let _ = fixture.initialize_zed_style()?; - - let code = "def add(a: int, b: int) -> int:\n return a + b\n\nresult = add(1, 2)\n"; - fixture.did_open("file:///hints.py", code)?; - let _ = fixture.wait_for_diagnostics(); - - let hints = fixture.request( - "textDocument/inlayHint", - &serde_json::json!({ - "textDocument": { "uri": "file:///hints.py" }, - "range": { - "start": { "line": 0, "character": 0 }, - "end": { "line": 4, "character": 0 } - } - }), - )?; - - // Must return a response (even if empty array). - assert!( - hints.get("result").is_some(), - "inlay hints must return a result: {hints}" - ); - - Ok(()) -} - -// ── Semantic Tokens ───────────────────────────────────────────────────────── - -/// Semantic tokens must work — Zed uses these with `semantic_tokens: combined`. -#[test] -fn test_zed_semantic_tokens() -> TestResult<()> { - let mut fixture = ZedLspFixture::new()?; - let _ = fixture.initialize_zed_style()?; - - let code = "def hello(name: str) -> str:\n return name\n"; - fixture.did_open("file:///tokens.py", code)?; - let _ = fixture.wait_for_diagnostics(); - - let tokens = fixture.request( - "textDocument/semanticTokens/full", - &serde_json::json!({ - "textDocument": { "uri": "file:///tokens.py" } - }), - )?; - - assert!( - tokens.get("result").is_some(), - "semantic tokens must return a result: {tokens}" - ); - - Ok(()) -} - -// ── Navigation ────────────────────────────────────────────────────────────── - -/// Go to definition must work. -#[test] -fn test_zed_go_to_definition() -> TestResult<()> { - let mut fixture = ZedLspFixture::new()?; - let _ = fixture.initialize_zed_style()?; - - let code = "def greet(name: str) -> str:\n return f\"Hello, {name}!\"\n\ngreet(\"world\")\n"; - fixture.did_open("file:///definition.py", code)?; - let _ = fixture.wait_for_diagnostics(); - - let definition = fixture.request( - "textDocument/definition", - &serde_json::json!({ - "textDocument": { "uri": "file:///definition.py" }, - "position": { "line": 3, "character": 1 } - }), - )?; - - assert!( - definition.get("result").is_some(), - "definition must return a result: {definition}" - ); - - Ok(()) -} - -/// Find references must work. -#[test] -fn test_zed_find_references() -> TestResult<()> { - let mut fixture = ZedLspFixture::new()?; - let _ = fixture.initialize_zed_style()?; - - let code = "def greet(name: str) -> str:\n return f\"Hello, {name}!\"\n\ngreet(\"a\")\ngreet(\"b\")\n"; - fixture.did_open("file:///refs.py", code)?; - let _ = fixture.wait_for_diagnostics(); - - let refs = fixture.request( - "textDocument/references", - &serde_json::json!({ - "textDocument": { "uri": "file:///refs.py" }, - "position": { "line": 0, "character": 5 }, - "context": { "includeDeclaration": true } - }), - )?; - - assert!( - refs.get("result").is_some(), - "references must return a result: {refs}" - ); - - Ok(()) -} - -// ── Formatting ────────────────────────────────────────────────────────────── - -/// Formatting must work via the embedded Ruff formatter ([LSPFMT-ENGINE]). -#[test] -fn test_zed_formatting() -> TestResult<()> { - let mut fixture = ZedLspFixture::new()?; - let _ = fixture.initialize_zed_style()?; - - let code = "def foo( x:int )->int:\n return x\n"; - fixture.did_open("file:///format.py", code)?; - let _ = fixture.wait_for_diagnostics(); - - let format_result = fixture.request( - "textDocument/formatting", - &serde_json::json!({ - "textDocument": { "uri": "file:///format.py" }, - "options": { - "tabSize": 4, - "insertSpaces": true - } - }), - )?; - - assert!( - format_result.get("id").is_some(), - "formatting must return a response: {format_result}" - ); - // The engine is embedded in the binary — badly formatted code MUST come - // back Ruff-formatted, never a silent null (#254). - assert!( - format_result - .to_string() - .contains("def foo(x: int) -> int:"), - "formatting must produce ruff-format output: {format_result}" - ); - - Ok(()) -} - -// ── Multiple Documents ────────────────────────────────────────────────────── - -/// Multiple documents open concurrently — the Zed editor can have many tabs. -#[test] -fn test_zed_multiple_documents() -> TestResult<()> { - let mut fixture = ZedLspFixture::new()?; - let _ = fixture.initialize_zed_style()?; - - let code_with_error = "def foo(x):\n return x\n"; - let code_clean = "def bar(x: int) -> int:\n return x\n"; - - fixture.did_open("file:///doc_a.py", code_with_error)?; - fixture.did_open("file:///doc_b.py", code_clean)?; - - // Collect diagnostics for both documents. - let mut got_a = false; - let mut got_b = false; - - for _ in 0..20 { - let Some(msg) = fixture.recv() else { break }; - if msg.contains("doc_a.py") && msg.contains("BSK-0001") { - got_a = true; - } - if msg.contains("doc_b.py") && msg.contains("\"diagnostics\":[]") { - got_b = true; - } - if got_a && got_b { - break; - } - } - - assert!(got_a, "doc_a.py should have diagnostics"); - assert!(got_b, "doc_b.py should be clean"); - - Ok(()) -} - -// ── Docs URL ──────────────────────────────────────────────────────────────── - -/// Verify the LSP uses the shared constant for docs URL. -#[test] -fn test_zed_diagnostic_docs_url() -> TestResult<()> { - let mut fixture = ZedLspFixture::new()?; - let _ = fixture.initialize_zed_style()?; - - let code = "def foo(x):\n return x\n"; - fixture.did_open("file:///docs_url.py", code)?; - - let diag = fixture.wait_for_diagnostics().ok_or("no diagnostics")?; - - // Diagnostics should reference the Basilisk docs URL from basilisk_common. - assert!( - diag.contains(basilisk_common::diagnostics::DOCS_URL), - "diagnostics should contain docs URL '{}': {diag}", - basilisk_common::diagnostics::DOCS_URL - ); - - Ok(()) -} diff --git a/crates/basilisk-lsp/tests/lsp/zed_extension_e2e_tests.rs b/crates/basilisk-lsp/tests/lsp/zed_extension_e2e_tests.rs deleted file mode 100644 index a2971f8ac..000000000 --- a/crates/basilisk-lsp/tests/lsp/zed_extension_e2e_tests.rs +++ /dev/null @@ -1,325 +0,0 @@ -//! Tests for [LSPARCH-TESTING]. See docs/specs/LSP-ARCHITECTURE-SPEC.md#LSPARCH-TESTING -// E2E tests simulating the Zed extension's interaction with the Basilisk LSP. -// -// Tests: initialization, capabilities, configuration, diagnostics, hover, -// completions, code actions, and document symbols. - -use super::zed_e2e_common::*; -use basilisk_common::config_keys; - -// ── Initialization ────────────────────────────────────────────────────────── - -/// The Zed extension calls `language_server_command()` which returns the binary -/// with "lsp" arg, then sends `initialize` with `workspaceRoot` in -/// `initializationOptions`. The LSP must accept this and return capabilities. -#[test] -fn test_zed_initialize_with_workspace_root() -> TestResult<()> { - let mut fixture = ZedLspFixture::new()?; - let response = fixture.initialize_zed_style()?; - - // Must return valid LSP init result. - assert!(response.contains("\"jsonrpc\":\"2.0\"")); - assert!(response.contains("\"result\"")); - - // Must advertise the server name as "basilisk". - assert!( - response.contains("\"basilisk\""), - "server info must contain 'basilisk': {response}" - ); - - Ok(()) -} - -// ── Capabilities ──────────────────────────────────────────────────────────── - -/// The Zed extension relies on specific LSP capabilities. Verify they're all -/// advertised in the initialize response. -#[test] -fn test_zed_required_capabilities() -> TestResult<()> { - let mut fixture = ZedLspFixture::new()?; - let response = fixture.initialize_zed_style()?; - let parsed: serde_json::Value = serde_json::from_str(&response)?; - - let capabilities = &parsed["result"]["capabilities"]; - - // Text sync (incremental = 2). - assert_eq!(capabilities["textDocumentSync"], 2); - - // Hover. - assert_eq!(capabilities["hoverProvider"], true); - - // Completions. - assert!( - capabilities["completionProvider"].is_object(), - "must advertise completion provider" - ); - - // Code actions. - assert!( - capabilities.get("codeActionProvider").is_some(), - "must advertise code action provider" - ); - - // Inlay hints. - assert_eq!(capabilities["inlayHintProvider"], true); - - // Execute command — must include all basilisk custom commands. - let execute_commands = &capabilities["executeCommandProvider"]["commands"]; - assert!( - execute_commands.is_array(), - "must have executeCommandProvider" - ); - let commands_list: Vec<&str> = execute_commands - .as_array() - .map(|arr| arr.iter().filter_map(|v| v.as_str()).collect()) - .unwrap_or_default(); - - for cmd in commands::ALL { - assert!( - commands_list.contains(cmd), - "command {cmd} must be advertised, got: {commands_list:?}" - ); - } - - // Definition. - assert!( - capabilities.get("definitionProvider").is_some(), - "must advertise definition provider" - ); - - // References. - assert!( - capabilities.get("referencesProvider").is_some(), - "must advertise references provider" - ); - - // Rename. - assert!( - capabilities.get("renameProvider").is_some(), - "must advertise rename provider" - ); - - // Document symbols (used by Zed outline panel). - assert!( - capabilities.get("documentSymbolProvider").is_some(), - "must advertise document symbol provider" - ); - - // Semantic tokens (required for Zed's `semantic_tokens: combined` setting). - assert!( - capabilities.get("semanticTokensProvider").is_some(), - "must advertise semantic tokens provider" - ); - - // Formatting (via Ruff). - assert!( - capabilities.get("documentFormattingProvider").is_some(), - "must advertise formatting provider" - ); - - // Signature help. - assert!( - capabilities.get("signatureHelpProvider").is_some(), - "must advertise signature help" - ); - - // Code lens. - assert!( - capabilities.get("codeLensProvider").is_some(), - "must advertise code lens" - ); - - // Call hierarchy. - assert!( - capabilities.get("callHierarchyProvider").is_some(), - "must advertise call hierarchy" - ); - - Ok(()) -} - -// ── Configuration ─────────────────────────────────────────────────────────── - -/// The Zed extension sends workspace configuration with the shared config keys. -/// The LSP must not reject this. -#[test] -fn test_zed_workspace_configuration() -> TestResult<()> { - let mut fixture = ZedLspFixture::new()?; - let _ = fixture.initialize_zed_style()?; - - // Simulate Zed sending workspace/didChangeConfiguration with the same - // structure that language_server_workspace_configuration() produces. - fixture.send_json(&serde_json::json!({ - "jsonrpc": "2.0", - "method": "workspace/didChangeConfiguration", - "params": { - "settings": { - config_keys::ROOT: { - config_keys::INLAY_HINTS: { - config_keys::PARAM_NAMES: true, - config_keys::VAR_TYPES: true - }, - config_keys::RUFF: { - config_keys::RUFF_ENABLED: true - } - } - } - } - }))?; - - // If the LSP crashes on config, subsequent requests will fail. - // Verify it's still alive by opening a file. - let code = "def add(a: int, b: int) -> int:\n return a + b\n"; - fixture.did_open("file:///test_config.py", code)?; - - let diag = fixture - .wait_for_diagnostics() - .ok_or("LSP died after config change — no diagnostics received")?; - - assert!( - diag.contains("\"diagnostics\":[]"), - "clean code should produce no diagnostics: {diag}" - ); - - Ok(()) -} - -// ── Diagnostics ───────────────────────────────────────────────────────────── - -/// Diagnostics must flow to the Zed extension after opening a Python file. -#[test] -fn test_zed_diagnostics_on_open() -> TestResult<()> { - let mut fixture = ZedLspFixture::new()?; - let _ = fixture.initialize_zed_style()?; - - // `name` has no default to infer from (BSK-0001) and the returned method - // call is not inferable (BSK-0002) — an f-string return would infer - // `-> str` and silence BSK-0002 ([TYPEINF-FUNC-RETURN]). - let code = "def greet(name):\n return name.upper()\n"; - fixture.did_open("file:///greet.py", code)?; - - let diag = fixture - .wait_for_diagnostics() - .ok_or("no diagnostics received")?; - - // Must report missing type annotations (BSK-0001 and BSK-0002). - assert!(diag.contains("BSK-0001"), "missing param type: {diag}"); - assert!(diag.contains("BSK-0002"), "missing return type: {diag}"); - - Ok(()) -} - -/// Clean code should produce zero diagnostics. -#[test] -fn test_zed_clean_code_no_diagnostics() -> TestResult<()> { - let mut fixture = ZedLspFixture::new()?; - let _ = fixture.initialize_zed_style()?; - - let code = "def greet(name: str) -> str:\n return f\"Hello, {name}!\"\n"; - fixture.did_open("file:///clean.py", code)?; - - let diag = fixture - .wait_for_diagnostics() - .ok_or("no diagnostics received")?; - - assert!( - diag.contains("\"diagnostics\":[]"), - "clean code should have no diagnostics: {diag}" - ); - - Ok(()) -} - -// ── Hover ─────────────────────────────────────────────────────────────────── - -/// Hover must work — the Zed extension displays hover info on mouse-over. -#[test] -fn test_zed_hover() -> TestResult<()> { - let mut fixture = ZedLspFixture::new()?; - let _ = fixture.initialize_zed_style()?; - - let code = "def greet(name):\n return f\"Hello, {name}!\"\n"; - fixture.did_open("file:///hover.py", code)?; - let _ = fixture.wait_for_diagnostics(); - - let hover = fixture.request( - "textDocument/hover", - &serde_json::json!({ - "textDocument": { "uri": "file:///hover.py" }, - "position": { "line": 0, "character": 11 } - }), - )?; - - // Must return hover content (not null). - assert!( - hover.get("result").is_some(), - "hover must return a result: {hover}" - ); - - Ok(()) -} - -// ── Completions ───────────────────────────────────────────────────────────── - -/// Completions must work — the Zed extension triggers these on dot and typing. -#[test] -fn test_zed_completions() -> TestResult<()> { - let mut fixture = ZedLspFixture::new()?; - let _ = fixture.initialize_zed_style()?; - - let code = "x: str = \"hello\"\nx.\n"; - fixture.did_open("file:///completion.py", code)?; - let _ = fixture.wait_for_diagnostics(); - - let completions = fixture.request( - "textDocument/completion", - &serde_json::json!({ - "textDocument": { "uri": "file:///completion.py" }, - "position": { "line": 1, "character": 2 } - }), - )?; - - // Must return a valid response (result can be null, array, or object). - // The key assertion is that we get a response, not an error. - assert!( - completions.get("error").is_none(), - "completions must not error: {completions}" - ); - - Ok(()) -} - -// ── Code Actions ──────────────────────────────────────────────────────────── - -/// Code actions must work — Zed shows these in the lightbulb menu. -#[test] -fn test_zed_code_actions() -> TestResult<()> { - let mut fixture = ZedLspFixture::new()?; - let _ = fixture.initialize_zed_style()?; - - let code = "def greet(name):\n return f\"Hello, {name}!\"\n"; - fixture.did_open("file:///actions.py", code)?; - let _ = fixture.wait_for_diagnostics(); - - let actions = fixture.request( - "textDocument/codeAction", - &serde_json::json!({ - "textDocument": { "uri": "file:///actions.py" }, - "range": { - "start": { "line": 0, "character": 10 }, - "end": { "line": 0, "character": 14 } - }, - "context": { - "diagnostics": [] - } - }), - )?; - - let result = &actions["result"]; - assert!( - !result.is_null(), - "code actions must not be null: {actions}" - ); - - Ok(()) -} diff --git a/crates/basilisk-lsp/tests/lsp_stdio_tests.rs b/crates/basilisk-lsp/tests/lsp_stdio_tests.rs deleted file mode 100644 index b240b5ab4..000000000 --- a/crates/basilisk-lsp/tests/lsp_stdio_tests.rs +++ /dev/null @@ -1,37 +0,0 @@ -//! Tests for [LSPARCH-TESTING]. See docs/specs/LSP-ARCHITECTURE-SPEC.md#LSPARCH-TESTING -#![allow( - clippy::allow_attributes, - clippy::indexing_slicing, - clippy::expect_used, - clippy::unwrap_used, - clippy::panic, - clippy::as_conversions, - missing_docs, - clippy::needless_raw_string_hashes, - dead_code, - unused_imports -)] - -#[path = "lsp/lsp_e2e_common.rs"] -mod lsp_e2e_common; - -#[path = "lsp/lsp_e2e_advanced.rs"] -mod lsp_e2e_advanced; -#[path = "lsp/lsp_e2e_basics.rs"] -mod lsp_e2e_basics; -#[path = "lsp/lsp_e2e_change_signature.rs"] -mod lsp_e2e_change_signature; -#[path = "lsp/lsp_e2e_code_actions.rs"] -mod lsp_e2e_code_actions; -#[path = "lsp/lsp_e2e_completion.rs"] -mod lsp_e2e_completion; -#[path = "lsp/lsp_e2e_hierarchies.rs"] -mod lsp_e2e_hierarchies; -#[path = "lsp/lsp_e2e_hover.rs"] -mod lsp_e2e_hover; -#[path = "lsp/lsp_e2e_navigation.rs"] -mod lsp_e2e_navigation; -#[path = "lsp/lsp_e2e_refactoring.rs"] -mod lsp_e2e_refactoring; -#[path = "lsp/lsp_tests.rs"] -mod lsp_tests; diff --git a/crates/basilisk-lsp/tests/zed_tests.rs b/crates/basilisk-lsp/tests/zed_tests.rs deleted file mode 100644 index 0ea6f5ea8..000000000 --- a/crates/basilisk-lsp/tests/zed_tests.rs +++ /dev/null @@ -1,21 +0,0 @@ -//! Tests for [LSPARCH-TESTING]. See docs/specs/LSP-ARCHITECTURE-SPEC.md#LSPARCH-TESTING -#![allow( - clippy::allow_attributes, - clippy::indexing_slicing, - clippy::expect_used, - clippy::unwrap_used, - clippy::panic, - clippy::as_conversions, - missing_docs, - clippy::needless_raw_string_hashes, - dead_code, - unused_imports -)] - -#[path = "lsp/zed_e2e_common.rs"] -mod zed_e2e_common; - -#[path = "lsp/zed_extension_e2e_advanced.rs"] -mod zed_extension_e2e_advanced; -#[path = "lsp/zed_extension_e2e_tests.rs"] -mod zed_extension_e2e_tests; diff --git a/crates/basilisk-parser/README.md b/crates/basilisk-parser/README.md index 5010ef4fd..90326a045 100644 --- a/crates/basilisk-parser/README.md +++ b/crates/basilisk-parser/README.md @@ -1,5 +1,12 @@ # basilisk-parser +> **A record, not a product claim.** Basilisk is unlisted and its type checker is +> inert ([WITHDRAWAL](../../docs/specs/DOCS-WITHDRAWAL-MESSAGING-SPEC.md#WITHDRAWAL)). +> Nothing described below ships in anything a user can install: the `basilisk` +> binary analyses nothing, and the editor extensions carry no checker. This file +> is kept as an account of what was built, and nothing in it authorises +> rebuilding what it describes. + Python source parser for Basilisk — wraps `ruff_python_parser` to produce a typed AST. ## Role in Basilisk @@ -12,7 +19,7 @@ source text ➜ [basilisk-parser] ➜ AST ➜ resolver ➜ checker ➜ diagnosti ## Key concepts -- **Wraps `ruff_python_parser`** — no custom grammar, no maintenance burden. Ruff's parser is MIT-licensed, battle-tested, and fast. +- **Wraps `ruff_python_parser`** — no custom grammar, no maintenance burden. Ruff's parser is MIT-licensed. - **`ruff_python_ast`** — re-exports AST node types so downstream crates never depend on Ruff internals directly. - **Error recovery** — partial ASTs are returned even when the source contains syntax errors, allowing the LSP to provide diagnostics on incomplete code. @@ -26,4 +33,4 @@ source text ➜ [basilisk-parser] ➜ AST ➜ resolver ➜ checker ➜ diagnosti ## Status -Complete — stable API consumed by `basilisk-resolver`, `basilisk-checker`, and `basilisk-lsp`. +Consumed only by crates that ship in nothing. diff --git a/crates/basilisk-resolver/README.md b/crates/basilisk-resolver/README.md index eed3923c6..5825e2ede 100644 --- a/crates/basilisk-resolver/README.md +++ b/crates/basilisk-resolver/README.md @@ -1,5 +1,12 @@ # basilisk-resolver +> **A record, not a product claim.** Basilisk is unlisted and its type checker is +> inert ([WITHDRAWAL](../../docs/specs/DOCS-WITHDRAWAL-MESSAGING-SPEC.md#WITHDRAWAL)). +> Nothing described below ships in anything a user can install: the `basilisk` +> binary analyses nothing, and the editor extensions carry no checker. This file +> is kept as an account of what was built, and nothing in it authorises +> rebuilding what it describes. + Name resolution and scope analysis for Basilisk. ## Role in Basilisk @@ -33,4 +40,4 @@ AST ➜ [basilisk-resolver] ➜ scopes + resolved names ➜ checker ➜ diagnost ## Status -Complete — stable API consumed by `basilisk-checker` and `basilisk-lsp`. +Consumed only by crates that ship in nothing. diff --git a/crates/basilisk-stubs/README.md b/crates/basilisk-stubs/README.md index 0df9af886..035821e08 100644 --- a/crates/basilisk-stubs/README.md +++ b/crates/basilisk-stubs/README.md @@ -1,5 +1,12 @@ # basilisk-stubs +> **A record, not a product claim.** Basilisk is unlisted and its type checker is +> inert ([WITHDRAWAL](../../docs/specs/DOCS-WITHDRAWAL-MESSAGING-SPEC.md#WITHDRAWAL)). +> Nothing described below ships in anything a user can install: the `basilisk` +> binary analyses nothing, and the editor extensions carry no checker. This file +> is kept as an account of what was built, and nothing in it authorises +> rebuilding what it describes. + 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. @@ -52,10 +59,4 @@ normative selection contract is ## Status -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); -offline commit-object re-hashing, store immutability, and source reporting are -tracked against that spec. +Consumed only by crates that ship in nothing. diff --git a/crates/basilisk-test-utils/README.md b/crates/basilisk-test-utils/README.md index aa7681df9..976a5e45a 100644 --- a/crates/basilisk-test-utils/README.md +++ b/crates/basilisk-test-utils/README.md @@ -1,5 +1,12 @@ # basilisk-test-utils +> **A record, not a product claim.** Basilisk is unlisted and its type checker is +> inert ([WITHDRAWAL](../../docs/specs/DOCS-WITHDRAWAL-MESSAGING-SPEC.md#WITHDRAWAL)). +> Nothing described below ships in anything a user can install: the `basilisk` +> binary analyses nothing, and the editor extensions carry no checker. This file +> is kept as an account of what was built, and nothing in it authorises +> rebuilding what it describes. + Shared test helpers for Basilisk integration and E2E tests. ## Role in Basilisk @@ -20,4 +27,4 @@ This crate provides **reusable test infrastructure** for the entire workspace. I ## Status -Complete — consumed by test suites across the workspace. +Consumed by the test suites of crates that ship in nothing. diff --git a/crates/basilisk-test-utils/src/lib.rs b/crates/basilisk-test-utils/src/lib.rs index c587d1fb3..3f02861b0 100644 --- a/crates/basilisk-test-utils/src/lib.rs +++ b/crates/basilisk-test-utils/src/lib.rs @@ -5,7 +5,6 @@ //! multiple test crates (`basilisk-lsp`, `basilisk-cli`, `basilisk-resolver`). mod diagnostics; -pub mod lsp_stdio; mod semantic_tokens; mod source; @@ -22,9 +21,8 @@ mod cross_module; pub mod salsa_db; pub use diagnostics::{assert_valid_range, extract_diagnostic}; -pub use lsp_stdio::LspStdioFixture; pub use semantic_tokens::{assert_valid_semantic_token_data, parse_semantic_tokens}; -pub use source::{basilisk_binary, line_col}; +pub use source::line_col; /// Convenient result alias for test functions. pub type TestResult = Result>; diff --git a/crates/basilisk-test-utils/src/lsp_stdio.rs b/crates/basilisk-test-utils/src/lsp_stdio.rs deleted file mode 100644 index 913475273..000000000 --- a/crates/basilisk-test-utils/src/lsp_stdio.rs +++ /dev/null @@ -1,434 +0,0 @@ -//! Implements [CHKARCH-TESTING]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-TESTING -//! Stdio-based LSP test fixture. -//! -//! Spawns a `basilisk lsp` child process and communicates via JSON-RPC -//! over stdin/stdout. Used by both standard LSP E2E tests and Zed -//! extension E2E tests. - -use std::io::{BufRead, BufReader, Read, Write}; -use std::process::{Child, ChildStdin, Command, Stdio}; -use std::sync::mpsc::{channel, Receiver}; -use std::thread; -use std::time::Duration; - -use crate::source::basilisk_binary; -use crate::TestResult; - -/// Timeout for reading a single LSP message. -pub const READ_TIMEOUT: Duration = Duration::from_secs(5); - -/// Test fixture that manages a `basilisk lsp` child process. -/// -/// Consolidates the common infrastructure shared by the standard LSP -/// E2E tests and the Zed extension E2E tests. -pub struct LspStdioFixture { - /// The child process running the LSP server. - pub child: Child, - /// Stdin handle for sending JSON-RPC messages to the server. - pub stdin: ChildStdin, - /// Channel receiver for messages parsed from stdout. - pub responses: Receiver, - /// Auto-incrementing request ID counter. - pub next_id: i64, - /// Temp workspace root opened during initialize. It ships a `pyproject.toml` - /// whose `[tool.basilisk.rules]` opts into the annotation house rules (off - /// by default — the default config is pure PEP conformance). Documents fall - /// back to this root's config, so house diagnostics (`BSK-0001` …) fire - /// exactly as they do for a project that enabled them. No modes; - /// configuration. See [CHKARCH-CONFIGURATION-ONLY]. - pub workspace_root: std::path::PathBuf, -} - -impl std::fmt::Debug for LspStdioFixture { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("LspStdioFixture") - .field("next_id", &self.next_id) - .finish_non_exhaustive() - } -} - -impl LspStdioFixture { - /// Spawn the LSP server and start background reader threads. - /// - /// # Errors - /// Returns an error if the server process fails to spawn. - pub fn new() -> TestResult { - // Per-process sequence for unique temp workspace names. - static SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); - - let mut child = Command::new(basilisk_binary()) - .arg("lsp") - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn()?; - - let stdin = child.stdin.take().ok_or("failed to get stdin")?; - let stdout = child.stdout.take().ok_or("failed to get stdout")?; - let stderr = child.stderr.take().ok_or("failed to get stderr")?; - - let (tx, rx) = channel(); - - // Background reader for stdout: parse LSP frames. - let _ = thread::spawn(move || { - let mut reader = BufReader::new(stdout); - let mut line = String::new(); - loop { - let mut content_length: Option = None; - loop { - line.clear(); - if reader.read_line(&mut line).unwrap_or(0) == 0 { - return; - } - let trimmed = line.trim(); - if trimmed.is_empty() { - if content_length.is_some() { - break; - } - continue; - } - if let Some(rest) = trimmed.strip_prefix("Content-Length:") { - content_length = rest.trim().parse().ok(); - } - } - let Some(length) = content_length else { - continue; - }; - let mut buf = vec![0u8; length]; - if reader.read_exact(&mut buf).is_err() { - return; - } - if let Ok(body) = String::from_utf8(buf) { - if tx.send(body).is_err() { - return; - } - } - } - }); - - // Drain stderr to console. - let _ = thread::spawn(move || { - let mut reader = BufReader::new(stderr); - let mut line = String::new(); - while reader.read_line(&mut line).unwrap_or(0) > 0 { - eprint!("[LSP stderr] {line}"); - line.clear(); - } - }); - - // Create a temp workspace that opts into the annotation house rules so - // documents (which fall back to the root's checker config) see them. - let seq = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed); - let workspace_root = - std::env::temp_dir().join(format!("bsk_lsp_stdio_{}_{seq}", std::process::id())); - std::fs::create_dir_all(&workspace_root)?; - std::fs::write( - workspace_root.join("pyproject.toml"), - concat!( - "[tool.basilisk.rules]\n", - "\"BSK-0001\" = \"error\"\n", - "\"BSK-0002\" = \"error\"\n", - "\"BSK-0003\" = \"error\"\n", - "\"BSK-0005\" = \"error\"\n", - "\"BSK-0050\" = \"warning\"\n" - ), - )?; - - Ok(Self { - child, - stdin, - responses: rx, - next_id: 1, - workspace_root, - }) - } - - /// Send a JSON-RPC message. - /// - /// # Errors - /// Returns an error if writing to stdin fails. - pub fn send_json(&mut self, value: &serde_json::Value) -> TestResult<()> { - let body = value.to_string(); - let frame = format!("Content-Length: {}\r\n\r\n{}", body.len(), body); - self.stdin.write_all(frame.as_bytes())?; - self.stdin.flush()?; - Ok(()) - } - - /// Read the next message (with timeout). - #[must_use] - pub fn recv(&self) -> Option { - self.responses.recv_timeout(READ_TIMEOUT).ok() - } - - /// Allocate the next request ID. - pub fn next_id(&mut self) -> i64 { - let id = self.next_id; - self.next_id += 1; - id - } - - /// Perform the standard initialize / initialized handshake. - /// - /// # Errors - /// Returns an error if the handshake fails or no response is received. - pub fn initialize(&mut self) -> TestResult { - // Open the configured workspace root so documents resolve to a config - // with the annotation house rules enabled. See [CHKARCH-CONFIGURATION-ONLY]. - let root_uri = format!("file://{}", self.workspace_root.to_string_lossy()); - self.send_json(&serde_json::json!({ - "jsonrpc": "2.0", - "id": 1, - "method": "initialize", - "params": { - "processId": std::process::id(), - "rootUri": root_uri, - "capabilities": {}, - "trace": "off" - } - }))?; - - let response = self.recv().ok_or("no response to initialize")?; - - self.send_json(&serde_json::json!({ - "jsonrpc": "2.0", - "method": "initialized", - "params": {} - }))?; - - // Drain the server's log message. - let _ = self.responses.recv_timeout(Duration::from_millis(500)); - - Ok(response) - } - - /// Initialize with Zed-style `initializationOptions` (workspaceRoot). - /// - /// This is exactly what `language_server_initialization_options()` sends. - /// - /// # Errors - /// Returns an error if the handshake fails or no response is received. - pub fn initialize_zed_style(&mut self) -> TestResult { - let id = self.next_id(); - // Point both rootUri and the Zed-style workspaceRoot option at the - // configured temp workspace so documents resolve to a config with the - // annotation house rules enabled. See [CHKARCH-CONFIGURATION-ONLY]. - let root_path = self.workspace_root.to_string_lossy().into_owned(); - let root_uri = format!("file://{root_path}"); - self.send_json(&serde_json::json!({ - "jsonrpc": "2.0", - "id": id, - "method": "initialize", - "params": { - "processId": std::process::id(), - "rootUri": root_uri, - "capabilities": {}, - "initializationOptions": { - "workspaceRoot": root_path - }, - "trace": "off" - } - }))?; - - // The server may send log/notification messages before the init - // response. Search by ID to find the actual response. - let id_str = format!("\"id\":{id}"); - let mut response = None; - for _ in 0..20 { - let Some(msg) = self.recv() else { break }; - if msg.contains(&id_str) { - response = Some(msg); - break; - } - } - let response = response.ok_or("no response to initialize")?; - - self.send_json(&serde_json::json!({ - "jsonrpc": "2.0", - "method": "initialized", - "params": {} - }))?; - - // Drain any log messages from initialization. - let _ = self.responses.recv_timeout(Duration::from_millis(500)); - let _ = self.responses.recv_timeout(Duration::from_millis(500)); - - Ok(response) - } - - /// Send `textDocument/didOpen`. - /// - /// # Errors - /// Returns an error if writing to stdin fails. - pub fn did_open(&mut self, uri: &str, text: &str) -> TestResult<()> { - self.send_json(&serde_json::json!({ - "jsonrpc": "2.0", - "method": "textDocument/didOpen", - "params": { - "textDocument": { - "uri": uri, - "languageId": "python", - "version": 1, - "text": text - } - } - })) - } - - /// Wait for a `publishDiagnostics` notification, skipping unrelated messages. - #[must_use] - pub fn wait_for_diagnostics(&self) -> Option { - self.wait_for_diagnostics_matching(|_| true) - } - - /// Wait for a `publishDiagnostics` notification whose text satisfies - /// `predicate`, draining earlier publishes and unrelated messages. - /// - /// The server may emit several `publishDiagnostics` for one document (an - /// initial publish followed by the settled one, or a stale populated - /// publish still in flight when a clearing publish is expected after - /// `didClose`). Reading a single notification and asserting on it therefore - /// races under parallel load. This drains publishes until one matches, - /// keeping the assertion intact while removing the ordering assumption. - #[must_use] - pub fn wait_for_diagnostics_matching( - &self, - predicate: impl Fn(&str) -> bool, - ) -> Option { - for _ in 0..12 { - let msg = self.recv()?; - if msg.contains("\"method\":\"textDocument/publishDiagnostics\"") && predicate(&msg) { - return Some(msg); - } - } - None - } - - /// Send a request with an explicit ID and wait for the matching response. - /// - /// Returns `None` if the response is not received within the timeout. - /// - /// # Errors - /// Returns an error if writing the request fails. - #[expect( - clippy::needless_pass_by_value, - reason = "preserving standalone-function call style" - )] - pub fn send_request( - &mut self, - id: u64, - method: &str, - params: serde_json::Value, - ) -> TestResult> { - self.send_json(&serde_json::json!({ - "jsonrpc": "2.0", - "id": id, - "method": method, - "params": params - }))?; - - let id_str = format!("\"id\":{id}"); - for _ in 0..20 { - let Some(msg) = self.recv() else { break }; - if msg.contains(&id_str) { - return Ok(Some(msg)); - } - // Auto-respond to server-initiated requests (e.g. workspace/applyEdit) - // so the server doesn't block waiting for a client response. - self.auto_respond_if_server_request(&msg); - } - Ok(None) - } - - /// Send a request with auto-incremented ID and return parsed JSON. - /// - /// # Errors - /// Returns an error if the request fails or no response is received. - pub fn request( - &mut self, - method: &str, - params: &serde_json::Value, - ) -> TestResult { - let id = self.next_id(); - self.send_json(&serde_json::json!({ - "jsonrpc": "2.0", - "id": id, - "method": method, - "params": params - }))?; - - let id_str = format!("\"id\":{id}"); - for _ in 0..20 { - let Some(msg) = self.recv() else { - return Err("timeout waiting for response".into()); - }; - if msg.contains(&id_str) { - return Ok(serde_json::from_str(&msg)?); - } - self.auto_respond_if_server_request(&msg); - } - Err(format!("no response found for id {id}").into()) - } - - /// Send a `textDocument/completion` request and wait for the response. - /// - /// # Errors - /// Returns an error if the request fails. - pub fn request_completion( - &mut self, - uri: &str, - line: u32, - character: u32, - request_id: u64, - ) -> TestResult> { - self.send_request( - request_id, - "textDocument/completion", - serde_json::json!({ - "textDocument": { "uri": uri }, - "position": { "line": line, "character": character } - }), - ) - } - - /// If `msg` is a server-to-client request (has both "id" and "method"), - /// send back a success response so the server doesn't block. - /// - /// Handles `workspace/applyEdit`, `window/showMessageRequest`, etc. - fn auto_respond_if_server_request(&mut self, msg: &str) { - let Ok(parsed) = serde_json::from_str::(msg) else { - return; - }; - // Server requests have both "id" and "method". - let Some(req_id) = parsed.get("id") else { - return; - }; - if parsed.get("method").is_none() { - return; // It's a response, not a request. - } - - // Respond with `{ "applied": true }` for workspace/applyEdit, - // or `null` for anything else. - let result = if parsed.get("method") - == Some(&serde_json::Value::String("workspace/applyEdit".to_owned())) - { - serde_json::json!({ "applied": true }) - } else { - serde_json::Value::Null - }; - - let _ = self.send_json(&serde_json::json!({ - "jsonrpc": "2.0", - "id": req_id, - "result": result - })); - } -} - -impl Drop for LspStdioFixture { - fn drop(&mut self) { - let _ = self.child.kill(); - let _ = self.child.wait(); - let _ = std::fs::remove_dir_all(&self.workspace_root); - } -} diff --git a/crates/basilisk-test-utils/src/source.rs b/crates/basilisk-test-utils/src/source.rs index f86e29e1d..13d796635 100644 --- a/crates/basilisk-test-utils/src/source.rs +++ b/crates/basilisk-test-utils/src/source.rs @@ -1,26 +1,10 @@ //! Implements [CHKARCH-TESTING]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-TESTING -//! Source-level helpers: binary discovery, line/col computation. - -/// Path to the pre-built basilisk binary. -/// -/// Derives the target directory from the test executable's own location, -/// which works regardless of whether `cargo test` or `cargo llvm-cov` -/// (which uses a different `--target-dir`) invoked us. -#[must_use] -pub fn basilisk_binary() -> String { - // The test binary lives under /debug/deps/... - // We want /debug/basilisk - if let Ok(exe) = std::env::current_exe() { - if let Some(debug_dir) = exe.parent().and_then(|deps| deps.parent()) { - let candidate = debug_dir.join("basilisk"); - if candidate.exists() { - return candidate.to_string_lossy().into_owned(); - } - } - } - // Fallback to the original hardcoded path. - format!("{}/../../target/debug/basilisk", env!("CARGO_MANIFEST_DIR")) -} +//! Source-level helpers: line/col computation. +//! +//! `basilisk_binary()` used to live here, so a fixture could spawn the built +//! CLI and drive a language server over its stdio. The CLI is inert +//! ([WITHDRAWAL-INERT]) — it starts no server — so there is nothing to spawn +//! and the helper is gone with the suites that used it. /// Convert a byte offset in `source` into a 1-based (line, col) pair. #[must_use] diff --git a/crates/basilisk-uv/README.md b/crates/basilisk-uv/README.md index 7b5a44cec..26b5c3a40 100644 --- a/crates/basilisk-uv/README.md +++ b/crates/basilisk-uv/README.md @@ -1,5 +1,12 @@ # basilisk-uv +> **A record, not a product claim.** Basilisk is unlisted and its type checker is +> inert ([WITHDRAWAL](../../docs/specs/DOCS-WITHDRAWAL-MESSAGING-SPEC.md#WITHDRAWAL)). +> Nothing described below ships in anything a user can install: the `basilisk` +> binary analyses nothing, and the editor extensions carry no checker. This file +> is kept as an account of what was built, and nothing in it authorises +> rebuilding what it describes. + uv package manager integration for the Basilisk LSP. ## Role in Basilisk @@ -14,4 +21,4 @@ This crate provides **uv workspace detection and package intelligence** for the ## Status -Working — consumed by `basilisk-lsp`. +Consumed only by the language server, which ships in nothing. diff --git a/delist/00-publish-zed-final.sh b/delist/00-publish-zed-final.sh new file mode 100755 index 000000000..689bb8ae5 --- /dev/null +++ b/delist/00-publish-zed-final.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash +# Publish the FINAL Zed extension — run this BEFORE 01-verify-final-release.sh. +# +# Implements [WITHDRAWAL-UNLIST] and [ZED-MIRROR]. Zed is the one channel the +# Release workflow does not publish: the `publish-zed` job was removed from +# release.yml after its registry-listing step failed the v0.41.0 release, so +# every other channel ships from the tag and Zed ships from here, by hand. +# +# Why it still has to ship. Zed users are not reached by the CLI release: their +# extension downloads the binary itself, so once the final binary is inert their +# editor shows "language server failed to start" and never shows the statement. +# The final extension is what replaces that with the statement — it registers no +# language server at all and prints the notice under `/basilisk`. +# +# Two things happen here, in order: +# 1. push + tag the rendered tree to Nimblesite/basilisk-zed (the mirror) +# 2. open the PR bumping `basilisk` in zed-industries/extensions to that tag +# +# Step 2 lands in someone else's review queue. Until it merges, Zed serves the +# previous version — so `06-unlist-zed.sh` (the removal PR) waits for it. +# +# Needs: gh authenticated; push rights to Nimblesite/basilisk-zed; cargo with +# the wasm32-wasip2 target (the push is gated on a real standalone build). +# +# delist/00-publish-zed-final.sh v0.42.0 [--yes] + +source "$(dirname "${BASH_SOURCE[0]}")/common.sh" + +VERSION="${1:-}" +[ -n "$VERSION" ] || fail "usage: 00-publish-zed-final.sh [--yes]" +shift +parse_args "$@" +BARE="${VERSION#v}" +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +banner "Zed extension — Nimblesite/basilisk-zed + zed-industries/extensions" + +require_cmd gh "the registry PR is opened through the GitHub API" +require_cmd git "the mirror is pushed as a clone" +require_cmd cargo "the push is gated on a standalone wasm build" + +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT + +step "Render the standalone tree at $BARE" +"$REPO_ROOT/scripts/render-zed-mirror.sh" "$work/render" "$BARE" + +# Gate the push on the same build the registry will run. A tree that does not +# compile standalone is a listing that fails on their CI, not ours. +step "Build it exactly as the registry will" +( cd "$work/render" && cargo build --release --target wasm32-wasip2 ) +ok "standalone wasm build passed" + +# The notice-only contract, checked against the artefact that is about to be +# published rather than against the working tree ([ZED-NOW]). +step "Verify the rendered manifest ships no checker" +for forbidden in "[language_servers" "[debug_adapters" "[grammars"; do + if grep -qF "$forbidden" "$work/render/extension.toml"; then + fail "rendered extension.toml still declares ${forbidden}...] — do not publish" + fi +done +grep -q "Basilisk is unlisted" "$work/render/src/withdrawal_notice.txt" || + fail "the rendered tree carries no withdrawal notice" +ok "no language server, no debug adapter, no grammar; the notice is present" + +if confirm "publish the final Zed extension and open the registry bump PR"; then + step "Push the mirror" + # render-zed-mirror.sh replaces the clone's tracked content and preserves + # its .git, so the mirror keeps its history rather than being force-reset. + act git clone "https://github.com/Nimblesite/basilisk-zed.git" "$work/mirror" + act "$REPO_ROOT/scripts/render-zed-mirror.sh" "$work/mirror" "$BARE" + act git -C "$work/mirror" add -A + act git -C "$work/mirror" commit -m "basilisk $BARE" + act git -C "$work/mirror" push + act git -C "$work/mirror" tag "$VERSION" + act git -C "$work/mirror" push origin "$VERSION" + + step "Open the registry bump PR" + act python3 "$REPO_ROOT/scripts/publish_zed_registry.py" "$BARE" "$VERSION" + + ok "mirror pushed and tagged $VERSION; bump PR opened" + warn "Zed still serves the PREVIOUS version until a maintainer merges that PR." + warn "Do not run 06-unlist-zed.sh until it is merged and live." +fi diff --git a/delist/01-verify-final-release.sh b/delist/01-verify-final-release.sh new file mode 100755 index 000000000..2e3da392c --- /dev/null +++ b/delist/01-verify-final-release.sh @@ -0,0 +1,98 @@ +#!/usr/bin/env bash +# Verify the FINAL version is live on every channel — run before unlisting anything. +# +# Implements [WITHDRAWAL-UNLIST]. Unlisting hides a listing; it does nothing for +# a copy already installed. The only thing that reaches an existing install is a +# published update, so the order is: publish the final version, PROVE it is live +# here, then unlist. Running the unlisting scripts before this one passes leaves +# every existing user on the last checking build, permanently. +# +# Read-only: this script publishes nothing and removes nothing. +# +# delist/01-verify-final-release.sh v0.42.0 + +source "$(dirname "${BASH_SOURCE[0]}")/common.sh" + +VERSION="${1:-}" +[ -n "$VERSION" ] || fail "usage: 01-verify-final-release.sh " +BARE="${VERSION#v}" + +require_cmd curl "the channel checks are plain HTTP" +require_cmd python3 "the JSON responses are parsed with python3" + +failures=0 +check() { + local label="$1" found="$2" + if [ "$found" = "$BARE" ]; then + ok "$label is at $BARE" + else + printf "%b✗ %s is at '%s', expected %s%b\n" "$RED" "$label" "$found" "$BARE" "$RESET" + failures=$((failures + 1)) + fi +} + +step "GitHub Release" +gh_version="$(curl -fsSL "https://api.github.com/repos/Nimblesite/Basilisk/releases/latest" | + python3 -c 'import json,sys; print(json.load(sys.stdin)["tag_name"].lstrip("v"))' 2>/dev/null || echo "")" +check "GitHub Releases" "$gh_version" + +step "PyPI" +pypi_version="$(curl -fsSL "https://pypi.org/pypi/basilisk-python/json" | + python3 -c 'import json,sys; print(json.load(sys.stdin)["info"]["version"])' 2>/dev/null || echo "")" +check "PyPI basilisk-python" "$pypi_version" + +step "VS Code Marketplace" +marketplace_version="$(curl -fsSL \ + -H 'Accept: application/json;api-version=7.2-preview.1' \ + -H 'Content-Type: application/json' \ + -X POST 'https://marketplace.visualstudio.com/_apis/public/gallery/extensionquery' \ + -d '{"filters":[{"criteria":[{"filterType":7,"value":"Nimblesite.basilisk"}]}],"flags":914}' | + python3 -c 'import json,sys; print(json.load(sys.stdin)["results"][0]["extensions"][0]["versions"][0]["version"])' 2>/dev/null || echo "")" +check "VS Code Marketplace" "$marketplace_version" + +step "Open VSX" +ovsx_version="$(curl -fsSL "https://open-vsx.org/api/Nimblesite/basilisk" | + python3 -c 'import json,sys; print(json.load(sys.stdin)["version"])' 2>/dev/null || echo "")" +check "Open VSX" "$ovsx_version" + +step "Homebrew tap" +brew_version="$(curl -fsSL "https://raw.githubusercontent.com/Nimblesite/homebrew-tap/main/Formula/basilisk.rb" | + sed -n 's/^ version "\(.*\)"$/\1/p' || echo "")" +check "Homebrew tap" "$brew_version" + +step "Scoop bucket" +scoop_version="$(curl -fsSL "https://raw.githubusercontent.com/Nimblesite/scoop-bucket/main/bucket/basilisk.json" | + python3 -c 'import json,sys; print(json.load(sys.stdin)["version"])' 2>/dev/null || echo "")" +check "Scoop bucket" "$scoop_version" + +step "Neovim mirror tag" +nvim_tag="$(curl -fsSL "https://api.github.com/repos/Nimblesite/basilisk.nvim/tags" | + python3 -c 'import json,sys; print(json.load(sys.stdin)[0]["name"].lstrip("v"))' 2>/dev/null || echo "")" +check "Nimblesite/basilisk.nvim" "$nvim_tag" + +# Zed does not ship from the release workflow — delist/00-publish-zed-final.sh +# pushes the mirror by hand. Check the mirror tag here; the registry entry it +# points at only goes live once a Zed maintainer merges the bump PR, which is a +# separate wait and not a blocker for the other channels ([ZED-MIRROR]). +step "Zed mirror tag" +zed_tag="$(curl -fsSL "https://api.github.com/repos/Nimblesite/basilisk-zed/tags" | + python3 -c 'import json,sys; print(json.load(sys.stdin)[0]["name"].lstrip("v"))' 2>/dev/null || echo "")" +check "Nimblesite/basilisk-zed" "$zed_tag" + +step "Zed registry entry" +zed_listed="$(curl -fsSL "https://raw.githubusercontent.com/zed-industries/extensions/main/extensions.toml" | + python3 -c 'import sys,tomllib; print(tomllib.loads(sys.stdin.read()).get("basilisk", {}).get("version", ""))' 2>/dev/null || echo "")" +if [ "$zed_listed" = "$BARE" ]; then + ok "Zed registry is at $BARE" +elif [ -z "$zed_listed" ]; then + warn "Zed registry lists no basilisk entry — nothing to unlist there" +else + warn "Zed registry is still at '$zed_listed' — the bump PR has not merged yet." + warn "Do not run 06-unlist-zed.sh until it lands." +fi + +echo +if [ "$failures" -ne 0 ]; then + fail "$failures channel(s) are not on $BARE — DO NOT UNLIST YET. Publish the final version first." +fi +ok "every channel is on $BARE — the statement has reached existing installs; unlisting may proceed" diff --git a/delist/02-unlist-marketplace.sh b/delist/02-unlist-marketplace.sh new file mode 100755 index 000000000..ae83a69dd --- /dev/null +++ b/delist/02-unlist-marketplace.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# Unpublish the extension from the VS Code Marketplace. +# +# Implements [WITHDRAWAL-UNLIST]. `vsce unpublish` removes the extension from +# the gallery entirely: it stops appearing in search and in the web listing, and +# no new install can find it. Copies already installed are NOT removed — that is +# what the final notice-only version is for, so run 01-verify-final-release.sh +# first. +# +# Needs: VSCE_PAT (Azure DevOps PAT, scope Marketplace → Manage). +# +# delist/02-unlist-marketplace.sh [--yes] + +source "$(dirname "${BASH_SOURCE[0]}")/common.sh" +parse_args "$@" +banner "VS Code Marketplace — Nimblesite.basilisk" + +require_cmd npx "vsce runs through npx" +require_env VSCE_PAT "mint one at https://aka.ms/vscodepat (Marketplace → Manage)" + +if confirm "unpublish Nimblesite.basilisk from the VS Code Marketplace"; then + act npx --yes @vscode/vsce unpublish --pat "$VSCE_PAT" Nimblesite.basilisk --force + ok "unpublished — confirm at https://marketplace.visualstudio.com/items?itemName=Nimblesite.basilisk (expect 404)" +fi diff --git a/delist/03-unlist-homebrew.sh b/delist/03-unlist-homebrew.sh new file mode 100755 index 000000000..07f7b38d7 --- /dev/null +++ b/delist/03-unlist-homebrew.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# Remove the Basilisk formula from the Homebrew tap. +# +# Implements [WITHDRAWAL-UNLIST]. Deleting Formula/basilisk.rb makes +# `brew install nimblesite/tap/basilisk` fail to resolve. Machines that already +# installed it keep the binary — which is the inert one, after the final +# release. +# +# Needs: gh, authenticated with write access to Nimblesite/homebrew-tap. +# +# delist/03-unlist-homebrew.sh [--yes] + +source "$(dirname "${BASH_SOURCE[0]}")/common.sh" +parse_args "$@" +banner "Homebrew tap — Nimblesite/homebrew-tap" + +require_cmd gh "the tap is edited through the GitHub API" +require_cmd git "the tap is edited as a clone" + +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT + +if confirm "delete Formula/basilisk.rb from Nimblesite/homebrew-tap"; then + act gh repo clone Nimblesite/homebrew-tap "$work/tap" -- --depth 1 + act git -C "$work/tap" rm -q Formula/basilisk.rb + act git -C "$work/tap" commit -m "Remove basilisk: unlisted" + act git -C "$work/tap" push + ok "formula removed — confirm with: brew install nimblesite/tap/basilisk (expect 'No available formula')" +fi diff --git a/delist/04-unlist-scoop.sh b/delist/04-unlist-scoop.sh new file mode 100755 index 000000000..4be7e06e2 --- /dev/null +++ b/delist/04-unlist-scoop.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# Remove the Basilisk manifest from the Scoop bucket. +# +# Implements [WITHDRAWAL-UNLIST]. Deleting bucket/basilisk.json makes +# `scoop install nimblesite/basilisk` fail to resolve, and stops Scoop's +# autoupdate from ever fetching another version. +# +# Needs: gh, authenticated with write access to Nimblesite/scoop-bucket. +# +# delist/04-unlist-scoop.sh [--yes] + +source "$(dirname "${BASH_SOURCE[0]}")/common.sh" +parse_args "$@" +banner "Scoop bucket — Nimblesite/scoop-bucket" + +require_cmd gh "the bucket is edited through the GitHub API" +require_cmd git "the bucket is edited as a clone" + +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT + +if confirm "delete bucket/basilisk.json from Nimblesite/scoop-bucket"; then + act gh repo clone Nimblesite/scoop-bucket "$work/bucket" -- --depth 1 + act git -C "$work/bucket" rm -q bucket/basilisk.json + act git -C "$work/bucket" commit -m "Remove basilisk: unlisted" + act git -C "$work/bucket" push + ok "manifest removed — confirm with: scoop search basilisk (expect no result)" +fi diff --git a/delist/05-unlist-nvim-mirror.sh b/delist/05-unlist-nvim-mirror.sh new file mode 100755 index 000000000..d62a55c91 --- /dev/null +++ b/delist/05-unlist-nvim-mirror.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +# Archive the Neovim plugin mirror. +# +# Implements [WITHDRAWAL-UNLIST]. The mirror repo IS the plugin listing: plugin +# managers install straight from it. It is archived rather than deleted — +# deleting it breaks every lockfile that pins a commit and erases the record, +# while archiving makes it read-only and visibly dead. Its README is the +# statement, pushed by the final release. +# +# Needs: gh, authenticated with admin access to Nimblesite/basilisk.nvim. +# +# delist/05-unlist-nvim-mirror.sh [--yes] + +source "$(dirname "${BASH_SOURCE[0]}")/common.sh" +parse_args "$@" +banner "Neovim plugin mirror — Nimblesite/basilisk.nvim" + +require_cmd gh "the repo is edited through the GitHub API" + +readme_head="$(curl -fsSL https://raw.githubusercontent.com/Nimblesite/basilisk.nvim/main/README.md 2>/dev/null | head -1 || echo "")" +case "$readme_head" in + *"unlisted"*) ok "the mirror README already carries the statement" ;; + *) warn "the mirror README does not start with the statement ('$readme_head') — publish the final release first" ;; +esac + +if confirm "archive Nimblesite/basilisk.nvim (read-only, permanent-ish)"; then + act gh repo edit Nimblesite/basilisk.nvim \ + --description "Basilisk's type checker produced incorrect results. Basilisk is unlisted and is being rebuilt from the ground up as a new product." \ + --homepage "https://www.basilisk-python.dev" + act gh repo archive Nimblesite/basilisk.nvim --yes + ok "archived — confirm at https://github.com/Nimblesite/basilisk.nvim" +fi diff --git a/delist/06-unlist-zed.sh b/delist/06-unlist-zed.sh new file mode 100755 index 000000000..e2b959ccb --- /dev/null +++ b/delist/06-unlist-zed.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +# Open the PR that removes Basilisk from the Zed extension registry. +# +# Implements [WITHDRAWAL-UNLIST]. The Zed registry is zed-industries/extensions, +# a repo we do not own: the entry is a `[basilisk]` block in extensions.toml +# plus a git submodule. Removing it is a pull request, so this script prepares +# and opens that PR — a human on their side merges it. +# +# Needs: gh, authenticated; a fork of zed-industries/extensions is created if +# one does not exist. +# +# delist/06-unlist-zed.sh [--yes] + +source "$(dirname "${BASH_SOURCE[0]}")/common.sh" +parse_args "$@" +banner "Zed extension registry — zed-industries/extensions" + +require_cmd gh "the PR is opened through the GitHub API" +require_cmd git "the registry is edited as a clone" + +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT +branch="remove-basilisk" + +body="Please remove the \`basilisk\` extension from the registry. + +Basilisk's type checker was producing incorrect results. We asked for it to be +removed from the python/typing conformance results, and it has been removed +(https://github.com/python/typing/pull/2330). The code responsible is not +isolated to a known set of rules, so we cannot say how many rules are affected. +A code-quality tool that does not produce correct results is worse than useless, +so Basilisk is being unlisted from every distribution channel and its CLI is +inert — the extension can no longer start a language server. + +Full statement: https://www.basilisk-python.dev/" + +if confirm "open a PR removing basilisk from zed-industries/extensions"; then + act gh repo fork zed-industries/extensions --clone=false --remote=false + act gh repo clone zed-industries/extensions "$work/extensions" -- --depth 1 + act git -C "$work/extensions" checkout -b "$branch" + act git -C "$work/extensions" submodule deinit -f extensions/basilisk + act git -C "$work/extensions" rm -f extensions/basilisk + act python3 "$(dirname "${BASH_SOURCE[0]}")/remove_registry_entry.py" "$work/extensions/extensions.toml" basilisk + act git -C "$work/extensions" commit -am "Remove basilisk" + act git -C "$work/extensions" push --set-upstream "$(gh api user --jq .login)" "$branch" + act gh pr create --repo zed-industries/extensions \ + --title "Remove basilisk" --body "$body" --head "$branch" + ok "PR opened — track it until merged, then confirm the extension is gone from Zed's registry" +fi diff --git a/delist/07-unlist-github-repo.sh b/delist/07-unlist-github-repo.sh new file mode 100755 index 000000000..04da0c3f5 --- /dev/null +++ b/delist/07-unlist-github-repo.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# Point the GitHub repository itself at the statement, and stop it releasing. +# +# Implements [WITHDRAWAL-UNLIST]. The repo STAYS PUBLIC — taking it down would +# erase what happened — but its description, topics and website are a listing +# like any other, and the Release workflow must not be able to publish again +# after the final version. +# +# Needs: gh, authenticated with admin access to Nimblesite/Basilisk. +# +# delist/07-unlist-github-repo.sh [--yes] + +source "$(dirname "${BASH_SOURCE[0]}")/common.sh" +parse_args "$@" +banner "GitHub repository — Nimblesite/Basilisk" + +require_cmd gh "the repo is edited through the GitHub API" + +line="$(python3 -c ' +import sys; sys.path.insert(0, "scripts") +from gen_withdrawal_copy import copy_blocks +print(copy_blocks().line) +')" + +if confirm "rewrite the repo description/topics and disable the Release workflow"; then + act gh repo edit Nimblesite/Basilisk \ + --description "$line" \ + --homepage "https://www.basilisk-python.dev" + # Topics are a discovery surface. Every one of them advertised the checker. + act gh api -X PUT "repos/Nimblesite/Basilisk/topics" -f "names[]=unlisted" + # No further releases ([WITHDRAWAL-UNLIST]). Disabling beats deleting the + # workflow: the file stays as the record of what shipped last. + act gh workflow disable "Release" --repo Nimblesite/Basilisk + ok "repository listing updated and the Release workflow disabled" +fi diff --git a/delist/08-verify-unlisted.sh b/delist/08-verify-unlisted.sh new file mode 100755 index 000000000..47dc378ee --- /dev/null +++ b/delist/08-verify-unlisted.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +# Prove every channel is actually unlisted. +# +# Implements [WITHDRAWAL-UNLIST]. A script that ran without erroring is not +# evidence that a listing is gone — a PAT can be scoped wrong, a PR can sit +# unmerged, a CDN can serve a cached page. This asks each channel's public API +# the same question a user's tooling would, and reports what it actually sees. +# +# Read-only. Run it after the unlisting scripts, and again a day later. +# +# delist/08-verify-unlisted.sh + +source "$(dirname "${BASH_SOURCE[0]}")/common.sh" + +require_cmd curl "the channel checks are plain HTTP" + +still_listed=0 + +# Report on a URL that MUST NOT resolve to a live listing any more. +gone() { + local label="$1" url="$2" + local code + code="$(curl -o /dev/null -sw '%{http_code}' -L "$url" || echo "000")" + case "$code" in + 404|410) ok "$label: gone ($code)" ;; + 000) warn "$label: could not be reached — check by hand: $url" ;; + *) + printf "%b✗ %s: STILL LISTED (%s) — %s%b\n" "$RED" "$label" "$code" "$url" "$RESET" + still_listed=$((still_listed + 1)) + ;; + esac +} + +step "Channels that must 404" +gone "VS Code Marketplace" "https://marketplace.visualstudio.com/items?itemName=Nimblesite.basilisk" +gone "Open VSX" "https://open-vsx.org/api/Nimblesite/basilisk" +gone "Homebrew formula" "https://raw.githubusercontent.com/Nimblesite/homebrew-tap/main/Formula/basilisk.rb" +gone "Scoop manifest" "https://raw.githubusercontent.com/Nimblesite/scoop-bucket/main/bucket/basilisk.json" +gone "Zed registry entry" "https://raw.githubusercontent.com/zed-industries/extensions/main/extensions/basilisk/extension.toml" + +step "PyPI — yanked, not deleted" +# Yanking keeps the files installable by exact pin (so existing lockfiles do not +# break) while removing the release from resolution. `yanked` is the field pip +# reads, so it is the field that matters here. +yanked="$(curl -fsSL https://pypi.org/pypi/basilisk-python/json | + python3 -c ' +import json, sys +data = json.load(sys.stdin) +releases = data.get("releases", {}) +live = [v for v, files in releases.items() if files and not all(f.get("yanked") for f in files)] +print(",".join(sorted(live)) if live else "") +' 2>/dev/null || echo "unreachable")" +if [ -z "$yanked" ]; then + ok "PyPI: every release is yanked" +elif [ "$yanked" = "unreachable" ]; then + warn "PyPI: project not found (fully deleted) or unreachable" +else + printf "%b✗ PyPI: these releases are NOT yanked: %s%b\n" "$RED" "$yanked" "$RESET" + still_listed=$((still_listed + 1)) +fi + +step "Surfaces that must STAY up" +for url in \ + "https://www.basilisk-python.dev/" \ + "https://github.com/Nimblesite/Basilisk" \ + "https://api.github.com/repos/Nimblesite/Basilisk/releases" +do + code="$(curl -o /dev/null -sw '%{http_code}' -L "$url" || echo "000")" + if [ "$code" = "200" ]; then + ok "still up: $url" + else + printf "%b✗ MISSING (%s): %s — the record must stay public%b\n" "$RED" "$code" "$url" "$RESET" + still_listed=$((still_listed + 1)) + fi +done + +echo +if [ "$still_listed" -ne 0 ]; then + fail "$still_listed check(s) failed — unlisting is not complete" +fi +ok "every channel is unlisted, and the statement and the record are still public" diff --git a/delist/README.md b/delist/README.md new file mode 100644 index 000000000..3870fea61 --- /dev/null +++ b/delist/README.md @@ -0,0 +1,51 @@ +# Unlisting runbook + +Implements [WITHDRAWAL-UNLIST](../docs/specs/DOCS-WITHDRAWAL-MESSAGING-SPEC.md#WITHDRAWAL-UNLIST). Every published word comes from that spec; nothing here restates it. + +## The order, and why it is not negotiable + +**Publish the final version → verify it is live → unlist.** + +Unlisting hides a listing. It does nothing to a copy already installed on a developer's machine — that copy keeps checking, and keeps being wrong. The only thing that reaches an existing install is a published update. So the last version shipped to every channel is the one carrying the statement and the [inert CLI](../docs/specs/DOCS-WITHDRAWAL-MESSAGING-SPEC.md#WITHDRAWAL-INERT), and the listing comes down straight afterwards. + +Unlist first and the message never arrives. + +## Scripts + +Every script is **dry run by default** and prints what it would do. Pass `--yes` to act; each then asks you to type `UNLIST` before touching anything public. Run them from the repository root. + +| # | Script | Does | +|---|---|---| +| 0 | `00-publish-zed-final.sh v0.42.0` | Publishes the final Zed extension: pushes and tags the mirror, then opens the registry bump PR. **Zed is the one channel the Release workflow does not publish** — its `publish-zed` job was removed after it failed the v0.41.0 release, so Zed ships from here, by hand, right after the tag. | +| 1 | `01-verify-final-release.sh v0.42.0` | Read-only. Asserts every channel is serving the final version. **Nothing below runs until this passes.** | +| 2 | `02-unlist-marketplace.sh` | `vsce unpublish` removes the extension from the VS Code Marketplace. Needs `VSCE_PAT`. | +| 3 | `03-unlist-homebrew.sh` | Deletes `Formula/basilisk.rb` from `Nimblesite/homebrew-tap`. Needs `gh`. | +| 4 | `04-unlist-scoop.sh` | Deletes `bucket/basilisk.json` from `Nimblesite/scoop-bucket`. Needs `gh`. | +| 5 | `05-unlist-nvim-mirror.sh` | Archives `Nimblesite/basilisk.nvim` (read-only, not deleted). Needs `gh`. | +| 6 | `06-unlist-zed.sh` | Opens the PR removing `basilisk` from `zed-industries/extensions`. Needs `gh`. | +| 7 | `07-unlist-github-repo.sh` | Rewrites the repo description/topics and disables the Release workflow. Needs `gh`. | +| 8 | `08-verify-unlisted.sh` | Read-only. Asks each channel's public API what it still serves. Run after, and again a day later. | + +## Manual steps + +These have no API that a token can drive, or they end in someone else's review queue. Do them in this order, alongside the scripts. + +| Channel | What to do | Where | Done when | +|---|---|---|---| +| **PyPI — `basilisk-python`** | **Yank every release** (Manage project → Releases → each version → Options → Yank). Yank, do not delete: deleting breaks existing pinned lockfiles and destroys the record, while yanking removes the release from resolution so no new install picks it up. | https://pypi.org/manage/project/basilisk-python/releases/ | `08-verify-unlisted.sh` reports every release yanked | +| **PyPI — project description** | The project page stays, so its description must be the statement. It is set by the wheel metadata, so this is already correct if the final release published — check the rendered page. | https://pypi.org/project/basilisk-python/ | The page opens with "Basilisk is unlisted" | +| **Open VSX** | There is no unpublish in the `ovsx` CLI and no public API for it. Open an issue asking the Eclipse Foundation to remove `Nimblesite.basilisk`, stating that the extension is withdrawn; link the statement. | https://github.com/EclipseFdn/open-vsx.org/issues | The extension 404s at https://open-vsx.org/extension/Nimblesite/basilisk | +| **Zed registry — final version** | Script 0 opens the *bump* PR. Until a maintainer merges it, Zed serves the previous version, whose extension launches a language server that no longer exists — so a Zed user sees "server failed to start", not the statement. Chase it. | https://github.com/zed-industries/extensions/pulls | `extensions.toml` lists `basilisk` at the final version | +| **Zed registry — removal** | Script 6 opens the *removal* PR; a Zed maintainer merges it. Open it only after the bump above is merged and live, or you are asking one reviewer to merge two contradictory PRs. | https://github.com/zed-industries/extensions/pulls | The `basilisk` entry is gone from `extensions.toml` | +| **VS Code Marketplace publisher** | If `Nimblesite` publishes nothing else, remove the publisher's marketing profile text too — the publisher page survives the extension's removal. | https://marketplace.visualstudio.com/manage/publishers/Nimblesite | The publisher page lists no Basilisk | +| **GitHub Release workflow secrets** | Revoke `VSCODE_MARKETPLACE_PAT`, `OPEN_VSX_PAT` and `BREW_SCOOP_PAT` once unlisting is done. A disabled workflow plus live publish tokens is one re-enable away from republishing. | Org Settings → Secrets and variables → Actions | The three secrets are deleted | +| **PyPI Trusted Publisher** | Remove the `pypi` trusted publisher for `Nimblesite/Basilisk` / `release.yml`, for the same reason. | https://pypi.org/manage/project/basilisk-python/settings/publishing/ | No publisher listed | +| **Search engines** | Every retired page redirects to `/` and the sitemap lists only `/`, so this resolves on its own. Optionally request re-indexing to speed it up. | Google Search Console | Old URLs resolve to the statement | +| **Third-party listings** | Awesome-lists, comparison articles, aggregator entries. Search for `basilisk-python.dev` and `Nimblesite/Basilisk` and ask each owner to remove or annotate the entry. Do not argue; link the statement. | — | Each has been contacted once | + +## What must NOT be removed + +- **The GitHub repository.** It stays public. Taking it down erases what happened. +- **Existing GitHub Releases.** They stay. Deleting them destroys the record and breaks pinned installs. +- **The website.** It stays, serving the statement, with every retired URL redirecting to it — including the `/errors/BSK-XXXX/` links printed by binaries already installed. +- **The internal specs, plans, and the [integrity audit](../docs/CONFORMANCE-INTEGRITY-AUDIT.md).** They are the record, not marketing. diff --git a/delist/common.sh b/delist/common.sh new file mode 100755 index 000000000..f9a2533f6 --- /dev/null +++ b/delist/common.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash +# Shared helpers for the unlisting scripts. +# +# Implements [WITHDRAWAL-UNLIST]. See +# docs/specs/DOCS-WITHDRAWAL-MESSAGING-SPEC.md#WITHDRAWAL-UNLIST +# +# Every script in this directory removes something from the public internet, so +# they all share one rule: DRY RUN BY DEFAULT. A script prints exactly what it +# would do and changes nothing until it is passed `--yes`. Nothing here is +# reversible by re-running it. + +set -euo pipefail + +RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[0;33m'; CYAN='\033[0;36m' +BOLD='\033[1m'; RESET='\033[0m' + +DRY_RUN=1 + +# Parse the one flag every script accepts. Call with "$@". +parse_args() { + for arg in "$@"; do + case "$arg" in + --yes) DRY_RUN=0 ;; + --help|-h) + sed -n '2,/^$/p' "$0" | sed 's/^# \{0,1\}//' + exit 0 + ;; + *) + printf "%bunknown argument: %s (only --yes is accepted)%b\n" "$RED" "$arg" "$RESET" >&2 + exit 2 + ;; + esac + done +} + +step() { printf "\n%b%b▶ %s%b\n" "$BOLD" "$CYAN" "$1" "$RESET"; } +ok() { printf "%b✓ %s%b\n" "$GREEN" "$1" "$RESET"; } +warn() { printf "%b⚠ %s%b\n" "$YELLOW" "$1" "$RESET"; } +fail() { printf "%b%b✗ %s%b\n" "$BOLD" "$RED" "$1" "$RESET" >&2; exit 1; } + +# Run a command, or print it when this is a dry run. +act() { + if [ "$DRY_RUN" -eq 1 ]; then + printf " %bwould run:%b %s\n" "$YELLOW" "$RESET" "$*" + return 0 + fi + printf " %b\$%b %s\n" "$CYAN" "$RESET" "$*" + "$@" +} + +# Refuse to act without a named credential in the environment. +require_env() { + local name="$1" why="$2" + if [ -z "${!name:-}" ]; then + fail "$name is not set — $why" + fi +} + +require_cmd() { + command -v "$1" >/dev/null 2>&1 || fail "$1 is not installed — $2" +} + +# A last human gate in front of an irreversible public change. +confirm() { + local what="$1" + if [ "$DRY_RUN" -eq 1 ]; then + warn "DRY RUN — nothing was changed. Re-run with --yes to $what." + return 1 + fi + printf "%b%bAbout to %s. This is public and not undone by re-running.%b\n" \ + "$BOLD" "$YELLOW" "$what" "$RESET" + printf "Type the word UNLIST to continue: " + local answer + read -r answer + [ "$answer" = "UNLIST" ] || fail "aborted" + return 0 +} + +banner() { + printf "%b%b%s%b\n" "$BOLD" "$CYAN" "$1" "$RESET" + if [ "$DRY_RUN" -eq 1 ]; then + warn "dry run — pass --yes to actually make changes" + fi +} diff --git a/delist/remove_registry_entry.py b/delist/remove_registry_entry.py new file mode 100755 index 000000000..e0ce29666 --- /dev/null +++ b/delist/remove_registry_entry.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python3 +"""Delete one `[name]` block from a Zed registry `extensions.toml`. + +Implements [WITHDRAWAL-UNLIST]. The registry file is a flat list of +`[extension-id]` blocks in a repository we do not own, so the edit must be +surgical: remove exactly the named block and leave every other byte — ordering, +spacing, comments — untouched, or the removal PR arrives full of unrelated diff. + + delist/remove_registry_entry.py path/to/extensions.toml basilisk +""" + +from __future__ import annotations + +import sys +from pathlib import Path + + +def without_block(text: str, name: str) -> str: + """`text` with the `[name]` block and its trailing blank line removed.""" + header = f"[{name}]" + lines = text.splitlines(keepends=True) + kept: list[str] = [] + dropping = False + for line in lines: + if line.strip() == header: + dropping = True + continue + if dropping: + # The block ends at the next header, or at the blank line before it. + if line.startswith("["): + dropping = False + elif not line.strip(): + dropping = False + continue + else: + continue + kept.append(line) + return "".join(kept) + + +def main(argv: list[str]) -> int: + if len(argv) != 3: + print(__doc__, file=sys.stderr) + return 2 + path, name = Path(argv[1]), argv[2] + text = path.read_text(encoding="utf-8") + if f"[{name}]" not in text: + print(f"{path}: no [{name}] entry — already removed", file=sys.stderr) + return 0 + path.write_text(without_block(text, name), encoding="utf-8") + print(f"removed [{name}] from {path}") + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/docs/INDEX.md b/docs/INDEX.md index 4261597f5..bb9139b4f 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -4,20 +4,33 @@ | File | Purpose | |---|---| -| [CONTRIBUTING.md](../CONTRIBUTING.md) | Contribution workflow and human/agent responsibilities. | +| [CONTRIBUTING.md](../CONTRIBUTING.md) | Basilisk is unlisted and is not accepting contributions. What the repository is now, and what an issue is still for. | ## Specifications Specifications document shipped contracts. Explicitly planned behavior is labelled and linked to an active plan. +**Read them as a record, not as promises.** Basilisk is unlisted, the CLI is inert, and +the editor extensions ship no checker +([WITHDRAWAL](specs/DOCS-WITHDRAWAL-MESSAGING-SPEC.md#WITHDRAWAL)). Most of what these +specs describe no longer runs. They are kept because they are the account of what was +built and how it went wrong — deleting them would erase that. Nothing here is a current +product claim, and nothing here authorises rebuilding what it describes. + +| Superseded, kept as record | | +|---|---| +| [Website error pages](specs/WEBSITE-ERROR-PAGES-SPEC.md) | Per-diagnostic pages the site used to serve; those URLs now redirect to the statement. | +| [Website screenshots](specs/WEBSITE-SCREENSHOTS-SPEC.md) | The automated CLI screenshot pipeline. There are no product screenshots. | + | File | Purpose | |---|---| +| [Withdrawal messaging](specs/DOCS-WITHDRAWAL-MESSAGING-SPEC.md) | **Single source of truth for everything Basilisk says publicly.** Every README, listing, website page, and the CLI's own output copies from here. No surface writes its own version. Where old copy conflicts, this wins. | | [Checker architecture](specs/CHECKER-ARCHITECTURE-SPEC.md) | Configuration, rules, diagnostics, analysis pipeline, CLI, and quality gates — including [CHKARCH-TEXT-MATCHED-LOGIC](specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-TEXT-MATCHED-LOGIC), the failing-test → delete → report rule that governs any code deciding from source text. | | [Type inference](specs/CHECKER-TYPE-INFERENCE-SPEC.md) | The bidirectional/constraint inference engine — the checker's single type oracle — its narrowing contracts, research grounding, and the condemned legacy mechanisms under demolition. | | [Stub resolution](specs/CHECKER-STUB-RESOLUTION-SPEC.md) | Pinned typing-spec import order, custom typeshed, offline pin verification against the store, a PyPI-package wheel pin, 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 cross-session result cache, its `[tool.basilisk]` keys, and how it differs from always-on Salsa memoization. | +| [Checker cache](specs/CHECKER-CACHE-SPEC.md) | **Superseded**: the cross-session result cache is deleted with the checking it cached. | | [Rule tagging](specs/CHECKER-RULE-TAGGING-SPEC.md) | Rule provenance/category/free-form tags and conflict rules. | | [LSP architecture](specs/LSP-ARCHITECTURE-SPEC.md) | Shared server protocol, analysis, commands, and capabilities. | | [Configuration editor](specs/LSP-CONFIGURATION-EDITOR-SPEC.md) | Typed preview/apply configuration transaction and VS Code shell. | @@ -31,16 +44,14 @@ linked to an active plan. | [Refactoring](specs/LSP-REFACTORING-SPEC.md) | Deterministic rename/extract/inline/move/convert actions. | | [Test integration](specs/LSP-TEST-INTEGRATION-SPEC.md) | Test discovery, execution, debug, and coverage protocol. | | [Activity panel](specs/EXTENSION-ACTIVITY-PANEL-SPEC.md) | Module/health wire data and shipped VS Code views. | -| [VS Code extension](specs/VSIX-SPEC.md) | VS Code client behavior. | +| [VS Code extension](specs/VSIX-SPEC.md) | VS Code client behavior. **Mostly superseded**: the extension ships no checker ([WITHDRAWAL-SURFACES](specs/DOCS-WITHDRAWAL-MESSAGING-SPEC.md#WITHDRAWAL-SURFACES)). | | [Real-world e2e suites](specs/VSIX-REAL-WORLD-SPEC.md) | Pinned real-repo journeys with memory/CPU budgets. | | [Neovim extension](specs/NEOVIM-SPEC.md) | `basilisk.nvim` client behavior. | | [Zed extension](specs/ZED-SPEC.md) | Zed WASM client behavior. | | [WASM](specs/WASM-SPEC.md) | The checker compiled for the browser: one-shot in-memory checking with no filesystem, network, or threads. | | [Editor screenshots](specs/VSIX-EDITOR-SCREENSHOTS-SPEC.md) | Automated real VS Code screenshots. | -| [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. | +| [Website E2E](specs/WEBSITE-E2E-SPEC.md) | The withdrawal-contract tests: approved copy on the statement page, every retired URL still resolving, and nothing forbidden anywhere in the build. | +| [READMEs](specs/DOCS-README-SPEC.md) | One authored README, generated to every storefront — GitHub, the VSIX (Marketplace + Open VSX), PyPI, Zed, and Neovim. | | [Repository standards](specs/REPO-STANDARDS-SPEC.md) | Root/`.github` gates: duplication budget, coverage thresholds, committed editor directories, Dependabot, CodeQL, and dependency review. | | [Release manual verification](specs/RELEASE-MANUAL-VERIFICATION-SPEC.md) | The manual passes a release person runs before publishing and again after, against the installed artifact: where `/ci-prep` fits, the artifact-provenance gate, the responsiveness smoke test, and the full hands-on test surface. | @@ -73,4 +84,4 @@ Plans contain only unfinished work. Delete a plan when its acceptance gate passe | File | Contents | |---|---| -| [Conformance integrity audit](CONFORMANCE-INTEGRITY-AUDIT.md#CHKARCH-CONFORMANCE-INTEGRITY-AUDIT) | Phase 1: the fitted alias predicates, measured impact, wider checker scan, remediation status, and process changes found by the 2026-08 audit. Linked from the site's [conformance correction](../website/src/docs/conformance.md). | +| [Conformance integrity audit](CONFORMANCE-INTEGRITY-AUDIT.md#CHKARCH-CONFORMANCE-INTEGRITY-AUDIT) | Phase 1: the fitted alias predicates, measured impact, wider checker scan, remediation status, and process changes found by the 2026-08 audit. The public site no longer carries a conformance page; this audit is the internal record ([WITHDRAWAL-SURFACES](specs/DOCS-WITHDRAWAL-MESSAGING-SPEC.md#WITHDRAWAL-SURFACES)). | diff --git a/docs/WITHDRAWAL-MESSAGING-REVIEW.md b/docs/WITHDRAWAL-MESSAGING-REVIEW.md new file mode 100644 index 000000000..41e5eabf4 --- /dev/null +++ b/docs/WITHDRAWAL-MESSAGING-REVIEW.md @@ -0,0 +1,17 @@ +# Withdrawal messaging and final-release review + +**Verdict: mostly fixed, but do not tag yet.** The seven real release paths—GitHub Releases, VS Code Marketplace, Open VSX, PyPI, Homebrew, Scoop, and Neovim—are wired and currently serve `0.41.1`. + +- **Remove Zed from distribution scope; do not create a listing now.** The [official registry](https://github.com/zed-industries/extensions/blob/main/extensions.toml) has no `basilisk` entry, and the repository records that listing was never completed. Remove the Zed channel claims and generated copies; retire `delist/00-publish-zed-final.sh`, `delist/06-unlist-zed.sh`, the registry publisher/tests, and the Zed checks in the final-release verifier. Keep the source or mirror only as a historical artefact. + +- **Make the release mechanically one-shot.** Pin `.github/workflows/release.yml` to the chosen stable final tag—currently implied to be `v0.42.0`—instead of every `v*`; allow reruns only for that tag. + +- **Preflight every real publisher before tagging.** Reconfirm `VSCODE_MARKETPLACE_PAT`, `OPEN_VSX_PAT`, `BREW_SCOOP_PAT` access to the tap, bucket, and Neovim mirror, plus the PyPI trusted publisher. The `v0.41.1` workflow succeeded on all seven paths on 2026-08-08, but credentials can change. + +- **Make retries safe and confirm the target set.** Add `skip-existing: true` to the PyPI action. The workflow covers Linux x64/ARM64, macOS ARM64, Windows x64/ARM64, and one universal VSIX; add Intel macOS now if its omission is not intentional. + +- **Generate every final-release message from the spec.** Delete the hand-written VS Code `ANNOUNCEMENT` and show the generated notice. Define one final-release block and render it verbatim from `gen_release_notes.py`; remove the custom “This release” copy and its conflicting unlisting tenses. + +- **Make the unlisting promise achievable.** PyPI yanking and Open VSX removal are external, so promise: publish → verify live → begin unlisting → verify absent, not “unlisted immediately.” Confirm irreversible Marketplace `unpublish` is intended. + +- **Resolve the remaining copy-contract conflicts, then regenerate.** Choose one README assignment (`Short` conflicts with `Full + Action`), allow notice-only commands and any required legal footer explicitly, and apply the recorded wording fixes: “used how code was *spelled*…”, “prints the withdrawal notice”, “Treat every result Basilisk produced as unverified”, and explicit subjects instead of dangling “it”. diff --git a/docs/readme/README.src.md b/docs/readme/README.src.md index 6dc1516e4..c988e875b 100644 --- a/docs/readme/README.src.md +++ b/docs/readme/README.src.md @@ -2,218 +2,41 @@ THIS IS THE ONLY AUTHORED README. Every published README — GitHub, the VS Code Marketplace / Open VSX, PyPI — is generated from this file by scripts/gen_readmes.py. Do not edit the generated copies. + The statement itself is NOT authored here: `{{withdrawal:…}}` is substituted + from docs/specs/DOCS-WITHDRAWAL-MESSAGING-SPEC.md ([WITHDRAWAL-COPY]). + Change the message there, never here. Exactly ONE paragraph may vary per target: the identity line below ([README-IDENTITY]). A second variant block is a review failure. --> -

- Basilisk -

- -

Basilisk

- -

English · 简体中文

- -

- An open-source Python type checker and language server, built in Rust.
- One extension for the whole workflow — diagnostics, autocomplete, refactoring, formatting, debugging, and profiling — driven by a single bundled binary. -

+# {{withdrawal:title}} > **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 extension listing** for VS Code, Cursor, Windsurf, and every VS Code fork. -> **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`. +> **You are reading the `basilisk-python` wheel listing** — the Basilisk CLI packaged for `pip`/`uv`. + +> **You are reading the Basilisk Zed extension listing.** + + +> **You are reading the `basilisk.nvim` plugin listing.** + -

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

- -

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

- -> ## ⚠️ Do not use Basilisk's type checker in your pipeline -> -> **The type checker still contains code that isn't doing real type checking, and -> it is not yet trustworthy.** Some rules decide from the way code is *spelled* -> rather than what it means, so they can be wrong in both directions — a false -> error on correct code, or silence where there is a genuine bug. Until the audit -> below is finished, don't gate CI on `basilisk check`, don't block a merge with -> it, and don't read a clean run as a clean codebase. -> -> The rest of Basilisk — language server, refactoring, formatting, debugging, -> profiling — does not depend on those rules and is unaffected. - -## Restoring trust: audit, delete, and lean on a checker that works - -We withdrew our former conformance claim and our benchmark figures, and asked to be -[removed from the official `python/typing` results](https://github.com/python/typing/blob/main/conformance/results/results.html). -The cause was checker logic fitted to the contents of conformance test files -instead of implementing the typing specification generally: rules that matched -the *spelling* of code rather than its meaning. Rename an import or reformat a -file and the answer changed. A score produced that way is not evidence. - -**This was a mistake and a failure to verify.** Our process treated the score as -the goal, matching text raises a score faster than real analysis does, and we -published without ever asking whether a rule still held when the same program was -spelled differently. Basilisk's author has published a -[personal account and apology](https://www.christianfindlay.com/blog/basilisk-conformance-apology). - -**So we are auditing every rule and deleting the ones that don't do real type -checking.** Not rewriting them, not patching them, not marking them TODO — -deleting them, with a failing test left behind so the gap is visible instead of -hidden. A rule stays only if it decides from the resolved syntax tree and gives -the same answer when the code is spelled differently. - -**Where a rule can't be made reliable in a straightforward way, we will depend on -a different, established type checker rather than ship our own unreliable version -of it.** An answer from an engine that has earned trust is worth more to you than -a Basilisk-branded one that hasn't. No replacement figure gets published until it -survives off-suite and mutation testing. - -That means Basilisk gets **smaller** before it gets better. Expect fewer rules, -fewer diagnostics, and a lower conformance number. We will report each drop -rather than avoid it. What is left will be code that is honest about what it -does — nothing else. - -### Basilisk is much more than a type checker - -Type checking is one part of it. The rest is a complete Python workflow in a -single Rust binary — language server, refactoring, formatting, integrated -debugging, profiling, and the editor extensions — and none of it rests on the -rules under audit. That is what we are sharpening while the audit runs: make the -parts that are genuinely useful solid, and remove anything that could hand you a -misleading result. The point of getting smaller is to end up with a tool you can -believe. - -[Read the full correction →](https://www.basilisk-python.dev/docs/conformance/)  •  -[Integrity audit →](docs/CONFORMANCE-INTEGRITY-AUDIT.md) - -## What you get - -One extension covers the whole Python workflow. A single bundled Rust binary -drives it — no Node.js, no npm, no `pip install`: - -- **Diagnostics as you type** — incremental analysis powered by [Salsa](https://github.com/salsa-rs/salsa) -- **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](https://github.com/microsoft/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 - -Strictness is configured **per rule**, never by a mode: the unconfigured default -enables the typing-spec rule set, and each rule can be graded down to -`warning`/`info` so a codebase can adopt type safety incrementally. Every -diagnostic carries a `help`, a `note`, and a link to a per-rule explainer, so a -red squiggle tells you *why*. - -## Install +{{withdrawal:full}} -**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. +## What to do now -**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)). +{{withdrawal:action}} ## 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. - ---- +Basilisk is built on [Ruff](https://github.com/astral-sh/ruff) by [Astral](https://astral.sh/), whose parser, AST, and formatter crates it embeds (MIT), and on standard-library type stubs from [typeshed](https://github.com/python/typeshed) (Apache-2.0, with MIT-licensed parts). Neither project is responsible for how Basilisk used them. Full component list and required notices: [NOTICES](NOTICES) and [RUST-DEPENDENCY-LICENSES](RUST-DEPENDENCY-LICENSES). ## License -Basilisk source code is MIT licensed. Binary distributions also contain -third-party components under the licenses shipped beside each artifact. +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 deleted file mode 100644 index 1f634fe18..000000000 --- a/docs/readme/README.zh.src.md +++ /dev/null @@ -1,200 +0,0 @@ - -

- Basilisk -

- -

Basilisk

- -

English · 简体中文

- -

- 用 Rust 打造的开源 Python 类型检查器与语言服务器。
- 一个扩展覆盖整套工作流 —— 诊断、自动补全、重构、格式化、调试与性能分析 —— 全部由单一捆绑的二进制文件驱动。 -

- - -> **你正在阅读 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 -

- -

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

- -> ## ⚠️ 请勿在流水线中使用 Basilisk 的类型检查器 -> -> **类型检查器中仍然存在没有做真正类型检查的代码,它目前还不值得信任。** 有些规则 -> 依据的是代码的**写法**而不是含义,因此两个方向上都可能出错 —— 既可能对正确的代码 -> 报出虚假错误,也可能对真实的缺陷保持沉默。在下文所述的审计完成之前,请不要用 -> `basilisk check` 作为 CI 的门禁,不要用它拦截合并,也不要把一次干净的运行结果当作 -> 代码库是干净的。 -> -> Basilisk 的其余部分 —— 语言服务器、重构、格式化、调试、性能分析 —— 并不依赖这些 -> 规则,因此不受影响。 - -## 重建信任:审计、删除,并倚重真正可靠的检查器 - -我们撤回了此前的一致性宣称与基准测试数字,并主动请求 -[从官方 `python/typing` 结果中移除](https://github.com/python/typing/blob/main/conformance/results/results.html)。 -原因是检查器中存在针对一致性测试文件内容而写的逻辑,而不是对类型规范的通用实现: -那些规则匹配的是代码的**写法**,而不是代码的含义。改一个导入别名或重新格式化文件, -结论就会变。这样得出的分数并不能作为证据。 - -**这是一个错误、一次验证上的失职。** 我们的流程把分数当成了目标,而匹配文本比真正做 -分析更快地提高分数;我们在发布之前,始终没有问过这样一个问题 —— 同一个程序换一种 -写法时,这条规则是否依然成立。Basilisk 作者已发表 -[个人说明与致歉](https://www.christianfindlay.com/blog/basilisk-conformance-apology)。 - -**因此,我们正在逐条审计规则,并删除那些没有做真正类型检查的规则。** 不是重写,不是 -打补丁,也不是标一个 TODO —— 是删除,并留下一个失败的测试,让这个缺口可见而不是被 -掩盖。一条规则只有在依据已解析的语法树做判断、并且在代码换一种写法时给出相同结论的 -情况下,才会保留。 - -**如果一条规则无法以直截了当的方式做到可靠,我们会转而依赖另一个成熟的类型检查器, -而不是端出我们自己那份不可靠的实现。** 一个已经赢得信任的引擎给出的答案,对你而言 -比一个挂着 Basilisk 名号却没有赢得信任的答案更有价值。在通过套件之外的用例与变异 -测试之前,我们不会发布任何替代数字。 - -这意味着 Basilisk 会**先变小,再变好**。规则会更少,诊断会更少,一致性数字也会更低。 -每一次下降我们都会如实报告,而不是设法回避。留下来的,将是对自己所做之事诚实的代码 -—— 仅此而已。 - -### Basilisk 远不只是一个类型检查器 - -类型检查只是其中一部分。其余部分是装在单个 Rust 二进制文件里的完整 Python 工作流 -—— 语言服务器、重构、格式化、集成调试、性能分析,以及各个编辑器扩展 —— 它们都不 -建立在正在接受审计的规则之上。这正是我们在审计期间着力打磨的地方:把真正有用的部分 -做扎实,并移除任何可能给出误导性结果的东西。变小的意义,是最终得到一个你可以信赖的 -工具。 - -[阅读完整更正 →](https://www.basilisk-python.dev/zh/docs/conformance/)  •  -[完整性审计 →](docs/CONFORMANCE-INTEGRITY-AUDIT.md) - -## 你能得到什么 - -一个扩展即可覆盖整套 Python 工作流。一切由单一捆绑的 Rust 二进制文件驱动 —— -无需 Node.js、无需 npm、无需 `pip install`: - -- **随输入实时诊断** —— 由 [Salsa](https://github.com/salsa-rs/salsa) 提供增量分析 -- **自动补全、悬停信息、跳转到定义、查找引用、重命名** -- **重构代码操作** —— 提取、内联、移动符号、整理导入 -- **集成调试** —— 按 F5 即可通过捆绑的 [debugpy](https://github.com/microsoft/debugpy) 调试;无需额外扩展 -- **集成性能分析** —— CPU 热力图、火焰图,以及带泄漏检测的内存面板 -- **活动面板** —— 模块树与逐模块的类型健康度覆盖率,并可切换功能开关 -- 内置 **Inlay hints** 与 **Ruff** 格式化/导入整理 -- **来自 [typeshed](https://github.com/python/typeshed) 的标准库类型** —— 完整的 `stdlib/` 快照已编译进二进制文件,因此悬停与诊断在离线且零配置的情况下依然可用 - -严格程度按**规则**配置,而不是靠模式切换:未配置的默认值即启用类型规范规则集, -每条规则都可以降级为 `warning`/`info`,让代码库能够渐进地采用类型安全。每条诊断 -都附带 `help`、`note` 以及指向每条规则详解页的链接,因此一条红色波浪线总能告诉你 -*为什么*。 - -## 安装 - -**编辑器扩展** —— 从 [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 6bdc1dffb..18c25bcde 100644 --- a/docs/specs/CHECKER-ARCHITECTURE-SPEC.md +++ b/docs/specs/CHECKER-ARCHITECTURE-SPEC.md @@ -1042,6 +1042,14 @@ recognise an entry. | 1 | Error diagnostics were found | | 2 | Invalid configuration (e.g. a `pep` rule resolved to `disabled`) | | 3 | Internal failure | +| 4 | Unlisted — the CLI is inert and checked nothing ([WITHDRAWAL-INERT](DOCS-WITHDRAWAL-MESSAGING-SPEC.md#WITHDRAWAL-INERT)) | + +**`4` is the only code the shipped binary returns**, apart from `0` for +`--version`. Codes `0`–`3` describe a checker that ran; this one does not run. +It is a separate code precisely so a consumer can tell "Basilisk is gone" from +"Basilisk found problems" — reusing `1` would report a finding about code that +was never read, and reusing `0` would let a pipeline pass on a check that never +happened. ### CI use {#CHKARCH-CLI-CI} diff --git a/docs/specs/CHECKER-CACHE-SPEC.md b/docs/specs/CHECKER-CACHE-SPEC.md index dbb987183..567c41b57 100644 --- a/docs/specs/CHECKER-CACHE-SPEC.md +++ b/docs/specs/CHECKER-CACHE-SPEC.md @@ -1,5 +1,7 @@ # Checker Result Cache — Specification {#CHKCACHE} +> **SUPERSEDED — historical record.** The cross-session result cache described below is deleted. It persisted diagnostics so a fresh `basilisk check --cache` could skip unchanged files; the CLI is inert ([WITHDRAWAL-INERT](DOCS-WITHDRAWAL-MESSAGING-SPEC.md#WITHDRAWAL-INERT)) and produces no results to cache. Kept as the record of what was built. + **Spec group:** `CHKCACHE` **Status:** v1 (opt-in) **Related:** [`CHKARCH-INCREMENTAL-SALSA`](CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-INCREMENTAL-SALSA), [`CHKARCH-CLI`](CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-CLI), [`STUBRES-TYPESHED`](CHECKER-STUB-RESOLUTION-SPEC.md#STUBRES-TYPESHED) diff --git a/docs/specs/DOCS-README-SPEC.md b/docs/specs/DOCS-README-SPEC.md index 9a6399659..bd565a50f 100644 --- a/docs/specs/DOCS-README-SPEC.md +++ b/docs/specs/DOCS-README-SPEC.md @@ -2,34 +2,39 @@ ## Purpose {#README-PURPOSE} -Basilisk's front page is published to three storefronts — the GitHub repository, +Basilisk's front page is published to five 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. +VSIX), PyPI, the Zed registry, and the Neovim plugin mirror. They were separate +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 +There is now exactly **one** authored README, and the published files are **identical except for a single line** that says which artifact you are looking -at. Everything else — the withdrawal notice, 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`. +at. That matters more now than it did: every one of those pages carries the +withdrawal statement, and a storefront whose copy lags is a storefront still +selling a checker that produced incorrect results. ## Source {#README-SOURCE} -| Source | Language | +| Source | Holds | |---|---| -| `docs/readme/README.src.md` | English | -| `docs/readme/README.zh.src.md` | 简体中文 | +| `docs/readme/README.src.md` | The identity line per target, the acknowledgments, and the licence | +| `docs/specs/DOCS-WITHDRAWAL-MESSAGING-SPEC.md` | **The statement itself** ([WITHDRAWAL-COPY](DOCS-WITHDRAWAL-MESSAGING-SPEC.md#WITHDRAWAL-COPY)) | -Nothing else is authored. Editing a generated README directly is a drift bug — -CI fails it ([README-DRIFT](#README-DRIFT)). +Nothing else is authored, and there is no Chinese source: the statement has no +approved translation, and a surface that translated it would be writing its own +version of an apology for being wrong. 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 | +| `github` | `README.md` | The GitHub repository | +| `vscode` | `vscode-extension/README.md` | VS Code Marketplace **and** Open VSX — one VSIX, one file | | `pypi` | `README-pypi.md` | The `basilisk-python` wheel | +| `zed` | `basilisk-zed/README.md` | The Zed extension listing | +| `nvim` | `basilisk.nvim/README.md` | The `basilisk.nvim` plugin mirror | 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`. @@ -42,10 +47,11 @@ 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. +The statement is substituted, not duplicated: `{{withdrawal:title}}`, +`{{withdrawal:full}}` and `{{withdrawal:action}}` are lifted from the messaging +spec at generation time ([WITHDRAWAL-COPY](DOCS-WITHDRAWAL-MESSAGING-SPEC.md#WITHDRAWAL-COPY)). +The message therefore has exactly one author, and changing it means editing that +spec — never a README, and never this file. ## Rendering {#README-RENDER} @@ -55,7 +61,8 @@ The generator applies three transforms, in order: 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. +2. **Tokens.** `{{withdrawal:line|title|short|action|full}}` are substituted + from the messaging spec, as the markdown it authored. 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 @@ -64,25 +71,28 @@ The generator applies three transforms, in order: ## Stamped values {#README-STAMPED} -**No conformance or benchmark figure appears in any README, and none is stamped.** -Both claims are withdrawn ([CHKARCH-CONFORMANCE](CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-CONFORMANCE)), -so every `value` marker has been removed from the -sources and `scripts/gen_conformance_reference.py` now has nothing to stamp. The -machinery is retained, not retired, so that a *future* measured value can only -ever reach a README by generation rather than by hand. - -Re-introducing a marker for a conformance figure is forbidden regardless of what -the harness reports. A benchmark marker may return only when the number is -measured on isolated hardware and carries its indicative-only caveat -([CHKARCH-TESTING-BENCH](CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-TESTING-BENCH)). +**No figure of any kind appears in a README, and nothing is stamped.** Every +`value` marker is gone from the sources, and the +generators that produced them are deleted. Re-introducing one is forbidden +([WITHDRAWAL-PROHIBITED](DOCS-WITHDRAWAL-MESSAGING-SPEC.md#WITHDRAWAL-PROHIBITED)): +no conformance figure, no benchmark, no rule count, in any tense. ## 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 committed file differs. It runs in the CI website job beside +`gen_withdrawal_copy.py --check`, and `make lint` runs both locally. A README +edited directly, a source edit without regeneration, or a spec edit without +regeneration all fail 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. + +`scripts/test_published_readmes.py` is the second gate, and it tests the words +rather than the rendering: every published README opens with the statement, +contains every paragraph of the action block, links the apology without quoting +it, shows no product image, and contains nothing +[WITHDRAWAL-PROHIBITED](DOCS-WITHDRAWAL-MESSAGING-SPEC.md#WITHDRAWAL-PROHIBITED) +bars — a percentage, an install command, a marketplace link, a competitor name, +a benchmark claim, a `BSK-` code, or a `basilisk` invocation. diff --git a/docs/specs/DOCS-WITHDRAWAL-MESSAGING-SPEC.md b/docs/specs/DOCS-WITHDRAWAL-MESSAGING-SPEC.md new file mode 100644 index 000000000..540e5aef2 --- /dev/null +++ b/docs/specs/DOCS-WITHDRAWAL-MESSAGING-SPEC.md @@ -0,0 +1,118 @@ +# Basilisk withdrawal — canonical messaging {#WITHDRAWAL} + +Single source of truth for everything Basilisk says publicly. Every README, listing, website page, and the CLI's own output copies from here. No surface writes its own version. Where old copy conflicts, this wins. + +## The message {#WITHDRAWAL-CLAIMS} + +1. **Basilisk's type checker was producing incorrect results.** +2. **We asked for it to be removed from the `python/typing` results, and it was** — [python/typing#2330](https://github.com/python/typing/pull/2330). +3. **The code responsible is not isolated. We cannot say how many rules are affected.** +4. **A code-quality tool that does not produce correct results is worse than useless.** +5. **Remove Basilisk from your pipeline.** The type checker is being made inert, and every distribution channel is being unlisted. +6. **We are unlisting first, then rebuilding from the ground up as a new product** — not fixing, auditing, or salvaging this code. +7. **Nothing is relisted until it has been rebuilt from components we can vouch for.** It will ship only what can be trusted — most likely not type checking. If type checking returns, it will go through **external auditing** before being released. + +Nothing else is asserted. One vocabulary, in this order: *incorrect results → removed from `python/typing` → unlisted from every channel → the CLI is inert → rebuilt from the ground up as a new product*. "Unlisted" throughout — never delisted, withdrawn, deprecated, or discontinued as synonyms. + +## Copy {#WITHDRAWAL-COPY} + +Verbatim. Doesn't fit? Use the shorter one. + +**One line** {#WITHDRAWAL-COPY-LINE} — repo description, package summaries, store descriptions, OG tags: + +> Basilisk's type checker produced incorrect results. Basilisk is unlisted and is being rebuilt from the ground up as a new product. + +**Short** {#WITHDRAWAL-COPY-SHORT} — READMEs, PyPI, extension listings: + +> **Basilisk's type checker was producing incorrect results. Basilisk is unlisted everywhere.** +> +> We asked for Basilisk to be removed from the `python/typing` conformance results, and it has been removed ([python/typing#2330](https://github.com/python/typing/pull/2330)). The code responsible is not isolated to a known set of rules, so we cannot say how many rules are affected. A code-quality tool that does not produce correct results is worse than useless. +> +> **Remove Basilisk from your pipeline.** Every distribution channel is being unlisted, and the type checker is inert — it checks nothing and exits non-zero, so a build that still calls it fails loudly instead of reporting a clean run. +> +> What comes next is a new product, rebuilt from the ground up, shipping only what can be trusted. That most likely will not include type checking. Nothing is relisted until it has been rebuilt from components we can vouch for. +> +> Basilisk's author has published a full public account: [an apology](https://www.christianfindlay.com/blog/basilisk-conformance-apology). + +**What to do now** {#WITHDRAWAL-COPY-ACTION} — every README and store listing carries this under the statement. It is the only part of the message that asks the reader to do something, so it never gets cut for length: + +> **Remove Basilisk from your pipeline, your pre-commit hooks, and your editor.** Uninstall the CLI and the extension. +> +> The type checker is inert: it checks nothing, and every invocation fails. It prints this statement and exits non-zero, so a build that still calls it fails loudly rather than reporting a clean run. Do not treat that failure as a finding about your code. +> +> **Treat every result Basilisk gave you as unverified.** A clean run was never evidence that your code was clean, and an error it reported may never have been real. +> +> Every distribution channel is being unlisted. Nothing will be relisted until it has been rebuilt from components we can vouch for. + +**Full** {#WITHDRAWAL-COPY-FULL} — website home and README body. There is no longer form: + +> # Basilisk is unlisted +> +> **Basilisk's type checker was producing incorrect results.** Rules decided from the way code was *spelled* rather than what it meant, so they could be wrong in both directions — a false error on correct code, or silence on a real bug. +> +> **We asked for Basilisk to be removed from the `python/typing` conformance results, and it has been removed** ([python/typing#2330](https://github.com/python/typing/pull/2330)). That score did not demonstrate correctness. +> +> **We cannot tell you how much of the checker this affects.** The code responsible is not isolated to a known set of rules. We will not estimate. That uncertainty is the reason for everything below. +> +> **A code-quality tool that does not produce correct results is worse than useless.** Basilisk is being unlisted everywhere it was published — the VS Code Marketplace, Open VSX, the Zed registry, PyPI, the Homebrew tap, and the Scoop bucket — and the type checker is inert. Remove it from your pipeline; it checks nothing, and every invocation fails rather than reporting a clean run. +> +> **We are not fixing Basilisk's type checker code. We are rebuilding from the ground up as a new product.** It will ship only what can be trusted. That most likely will not include type checking. Nothing is relisted until it has been rebuilt from components we can vouch for. If type checking ever returns, it will be externally audited before release. +> +> Basilisk's author has published a full public account: [an apology](https://www.christianfindlay.com/blog/basilisk-conformance-apology). + +## Never {#WITHDRAWAL-PROHIBITED} + +- **Never quote the apology** — link it, neutrally, nowhere else. It speaks for itself in its author's words. +- **No conformance or benchmark figure**, in any tense, caveated or archived. +- **No feature marketing, rule counts, or per-rule docs** — including for parts that never touched the checker. +- **No scoping reassurance** — never "only a few rules", "the language server is fine, keep using it". Claim 3 forbids it. +- **No blame outside the project.** No timeline. No install instructions. + +Tone: plain declaratives, active voice, worst part first. One statement of fault, then facts. No hedging, no repeated apology. Under a minute to read. + +## Unlisting {#WITHDRAWAL-UNLIST} + +Marketplace, Open VSX, Zed registry, PyPI, Homebrew tap, Scoop bucket — all unlisted. The repo stays public with the [full copy](#WITHDRAWAL-COPY-FULL) as its README — taking it down would erase what happened. Installed copies aren't force-removed; they go inert. + +**One last release, then no more.** Unlisting hides the listing; it does not touch the copy already installed on a developer's machine. So exactly one final version ships to every channel first — carrying this statement and the [inert CLI](#WITHDRAWAL-INERT) — and the channel is unlisted immediately after. That is the only way an existing install learns what happened. Existing GitHub Releases stay (deleting them destroys the record); after the final one, no new releases. Order per channel, no exceptions: **publish the final version → verify it is live → unlist.** The runbook and its scripts: [`delist/`](../../delist/README.md). + +Sources to edit, never the generated artefact: READMEs come from [`docs/readme/*.src.md`](../readme/); the website collapses to one notice page, with every retired URL — `/docs/*`, `/blog/*`, `/errors/BSK-XXXX/` — **redirecting** to it so links from installed binaries and search results land on the explanation rather than a 404 or a second copy of the statement. Internal specs, plans, and the [integrity audit](../CONFORMANCE-INTEGRITY-AUDIT.md) are not marketing surfaces — they are the record. Keep them, marked superseded. + +## Surfaces {#WITHDRAWAL-SURFACES} + +Every surface below carries a block from [Copy](#WITHDRAWAL-COPY) and nothing else. None writes its own version; each is generated or copied from this file. + +| Surface | Block | Generated by | +|---|---|---| +| Website home | [full](#WITHDRAWAL-COPY-FULL) | `scripts/gen_withdrawal_copy.py` → `website/src/_data/withdrawal.json` | +| Every retired website URL | redirect to `/` | `website/src/notice.njk` | +| GitHub / VSIX / PyPI / Zed / Neovim READMEs | [full](#WITHDRAWAL-COPY-FULL) + [action](#WITHDRAWAL-COPY-ACTION) | `scripts/gen_readmes.py` from `docs/readme/README.src.md` | +| Package + store description fields | [one line](#WITHDRAWAL-COPY-LINE) | copied by hand, asserted by `scripts/test_published_readmes.py` | +| CLI, every invocation | [notice](#WITHDRAWAL-INERT-TEXT) | `crates/basilisk-cli/src/withdrawal_notice.txt` | +| VS Code extension | [notice](#WITHDRAWAL-INERT-TEXT) | `vscode-extension/src/withdrawal-notice.ts` | +| Zed extension, `/basilisk` | [notice](#WITHDRAWAL-INERT-TEXT) | `basilisk-zed/src/withdrawal_notice.txt` | +| Neovim plugin + `:help basilisk` | [notice](#WITHDRAWAL-INERT-TEXT) | `basilisk.nvim/lua/basilisk/notice.lua`, `basilisk.nvim/doc/basilisk.txt` | + +All five notice carriers are written by `scripts/gen_withdrawal_copy.py` from the one fence below and drift-gated by its `--check` in `make lint` and CI, so no surface can print its own version of the statement. + +**Every editor extension ships no checker binary and no type-checking UI** — no diagnostics, commands, views, settings, debugger, or profiler, and no language server to launch. The VS Code extension activates, states this, and links the website. The Zed extension registers no `[language_servers.*]`, `[debug_adapters.*]` or `[grammars.*]` table and offers one slash command that prints the statement ([ZED-NOW](ZED-SPEC.md#ZED-NOW)). Each is enforced against the packaged artefact: `scripts/verify-vsix-inert.sh` for the VSIX, `basilisk-zed/src/logic_tests.rs` for the Zed manifest. + +**Everything else public is scanned.** `scripts/check_public_copy.py` reads every public surface — the storefront READMEs, the crate READMEs, `SECURITY.md`, `CONTRIBUTING.md`, the package manifests, the Homebrew and Scoop templates, the site templates — and fails on anything [Never](#WITHDRAWAL-PROHIBITED) bars. It runs in `make lint` and in CI. When this spec gains a prohibition, add a rule there; never an exemption for a surface that trips one. + +## Inert Type Checker CLI {#WITHDRAWAL-INERT} + +**Every invocation fails.** Bare `basilisk`, every subcommand, every flag, `--help`, a bad argument: print the notice to **stderr** and exit `4` (*unlisted*, added to [CHKARCH-CLI-EXITCODES](CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-CLI-EXITCODES)). No parsing, no analysis, no file touched, no server. Stdout emits nothing ever, so `--output json > report.json` yields an empty file, not prose a consumer might parse. Never exit `0` — a pipeline that still calls Basilisk must break, loudly, rather than read a clean run into it — and never `1`, because "errors found" would be one more incorrect result. `--version` is the sole exception, exit `0`: package managers and installed extensions verify against it and would otherwise hang instead of showing the notice. + +Exact text {#WITHDRAWAL-INERT-TEXT}, no colour or emoji: + +```text +Basilisk is unlisted. Its type checker is inert and checks nothing. + +Basilisk's type checker was producing incorrect results. The code responsible is not isolated to a known set of rules, so we cannot say how many rules are affected. We asked for Basilisk to be removed from the python/typing conformance results, and it has been removed: https://github.com/python/typing/pull/2330 + +A code-quality tool that does not produce correct results is worse than useless. Remove Basilisk from your pipeline, your pre-commit hooks, and your editor. This command failed on purpose. It is not a finding about your code. + +We are not fixing this code. We are rebuilding from the ground up as a new product, shipping only what can be trusted. If type checking ever returns, it will be externally audited before release. + +A full public account: https://www.christianfindlay.com/blog/basilisk-conformance-apology +``` diff --git a/docs/specs/RELEASE-MANUAL-VERIFICATION-SPEC.md b/docs/specs/RELEASE-MANUAL-VERIFICATION-SPEC.md index 539d0e539..d394b7c11 100644 --- a/docs/specs/RELEASE-MANUAL-VERIFICATION-SPEC.md +++ b/docs/specs/RELEASE-MANUAL-VERIFICATION-SPEC.md @@ -2,6 +2,15 @@ # Release manual verification +> **SUPERSEDED for the product surface — one clause survives.** Basilisk is +> unlisted. There is exactly one release left ([WITHDRAWAL-UNLIST](DOCS-WITHDRAWAL-MESSAGING-SPEC.md#WITHDRAWAL-UNLIST)), +> and it ships an inert CLI and a notice-only extension: there is no diagnostic, +> debugger, profiler or editor surface left to walk through, so every checklist +> below describing one is a record of what used to be verified, not work to do. +> What still applies is the shape: **verify the published artifact, not the +> tree.** For the final release that means [`delist/01-verify-final-release.sh`](../../delist/README.md) +> — every channel must be serving the final version before anything is unlisted. + Every release gets a manual pass **before** the tag is pushed and a second pass once the Marketplace VSIX is publicly available. Automated gates prove the tree; these passes prove the packaged product and the version users install. diff --git a/docs/specs/VSIX-SPEC.md b/docs/specs/VSIX-SPEC.md index 64f037bde..66ca26a65 100644 --- a/docs/specs/VSIX-SPEC.md +++ b/docs/specs/VSIX-SPEC.md @@ -1,5 +1,7 @@ # Basilisk VS Code Extension {#VSIX} +> **MOSTLY SUPERSEDED — historical record.** The extension described below shipped a bundled `basilisk` binary, a language client, diagnostics, a debugger, a profiler, a test explorer, and a configuration editor. It ships none of them now: Basilisk is unlisted, and the VSIX is a notice that states so and contributes one command ([WITHDRAWAL-SURFACES](DOCS-WITHDRAWAL-MESSAGING-SPEC.md#WITHDRAWAL-SURFACES)). This spec is kept as the record of what was built. Do not implement from it. + VS Code extension connecting to the `basilisk lsp` binary. All LSP features, DAP integration, custom commands, configuration, and binary resolution are defined in **`LSP-ARCHITECTURE-SPEC.md`** (single source of truth). This spec documents only **VS Code-specific details**, kept at feature parity with the Zed and Neovim extensions. ## Architecture {#VSIX-ARCHITECTURE} diff --git a/docs/specs/WEBSITE-E2E-SPEC.md b/docs/specs/WEBSITE-E2E-SPEC.md index 043178b93..b136e3367 100644 --- a/docs/specs/WEBSITE-E2E-SPEC.md +++ b/docs/specs/WEBSITE-E2E-SPEC.md @@ -1,62 +1,20 @@ -# Website: Navigation & End-to-End Smoke Tests {#WEBSITE-E2E} +# Website: withdrawal-contract end-to-end tests {#WEBSITE-E2E} ## Purpose {#WEBSITE-E2E-PURPOSE} -Browser smoke tests for the Eleventy site (`website/`), run against the -**production build** of `_site/` on a desktop and a phone viewport, enforcing in -CI that a visitor can navigate the site. +Browser tests for the Eleventy site (`website/`), run against the **production build** of `_site/` on a desktop and a phone viewport. The site publishes one thing — the withdrawal statement ([WITHDRAWAL-COPY-FULL](DOCS-WITHDRAWAL-MESSAGING-SPEC.md#WITHDRAWAL-COPY-FULL)) — so these tests enforce that contract rather than navigation: the published words are the approved words, no retired URL 404s, and nothing forbidden survives anywhere in the build. -## Smoke Coverage {#WEBSITE-E2E-SMOKE} +## Coverage {#WEBSITE-E2E-WITHDRAWAL} -`website/tests/e2e/navigation.spec.ts` and `website/tests/e2e/homepage.spec.ts`, driven by -`website/playwright.config.ts` (two projects: `desktop` = Desktop Chrome, -`mobile` = iPhone SE 3rd generation emulated in Chromium, 375 × 667), served by -`website/tests/static-server.js`. Run with -`npm run test:e2e` (`test:e2e:ui` locally). Asserts per viewport: +`website/tests/e2e/withdrawal.spec.ts`, driven by `website/playwright.config.ts` (two projects: `desktop` = Desktop Chrome, `mobile` = iPhone SE 3rd generation emulated in Chromium, 375 × 667), served by `website/tests/static-server.js`. Run with `npm run test:e2e` (`test:e2e:ui` locally). -- **Top navigation resolves** — the home page links to Docs, Rules, Blog, - Discord and GitHub (matched by `href`, so the check holds even where the nav - is collapsed behind the hamburger on a phone). -- **Docs landing page loads** — `/docs/` renders with the docs sidebar present. -- **Desktop sidebar** — the docs sidebar is permanently visible and navigates - between sections without any toggle. -- **Mobile docs submenu** — see [WEBSITE-MOBILE-DOCS-NAV]. -- **Mobile top nav** — the hamburger reveals the collapsed top nav. -- **Homepage positioning** — the title, H1 and opening answer identify Basilisk - neutrally as a Python type checker and language server, without an unverified - speed or conformance claim. -- **Integrity disclosure is unavoidable** — the hero states that the former - conformance and benchmark figures are withdrawn, the current conformance - percentage is temporarily unknown, Basilisk was removed from the official - results at its request, and clean reimplementation plus robustness/mutation - verification must finish before new figures are published. Both notices link - to their detailed correction pages. -- **Social image matches its declared size** — the `og:image` URL resolves and - the PNG's own IHDR dimensions equal the advertised `og:image:width`/`height`, - so a re-exported image cannot silently desync from its metadata. -- **The Chinese homepage is a translation, not a second pitch** — `/zh/` and `/` - are asserted to produce an identical structural skeleton (section, stat-card, - bullet and button class lists, in order). The zh page repeats both withdrawal - notices and the temporarily unknown status, so one locale cannot retain a - claim the other has retracted. -- **Homepage mobile usability** — no horizontal overflow and visible calls to - action retain a minimum 48 px touch target on the iPhone SE viewport. +- **The statement is the approved copy** — the home page renders every paragraph of `withdrawal.full`, in order, from `website/src/_data/withdrawal.json`. That file is generated from the messaging spec by `scripts/gen_withdrawal_copy.py`, so a test failure means the page drifted from the spec, and a `--check` failure means the data did. +- **The four load-bearing facts appear** — incorrect results, removal from the `python/typing` results, the damage not being scoped to a known set of rules, and a wrong tool being worse than useless. Asserted on visible text, so deleting a paragraph fails even if the copy file still contains it. +- **Every retired URL redirects to the statement** — each entry in `website/src/_data/retiredUrls.json` has a built page, and that page is a redirect stub, not a second copy of the message ([WITHDRAWAL-UNLIST](DOCS-WITHDRAWAL-MESSAGING-SPEC.md#WITHDRAWAL-UNLIST)). A representative URL per family (`/docs/`, `/docs/rules/`, `/errors/BSK-XXXX/`, `/blog/`, `/playground/`, `/zh/docs/…`) is driven in a real browser and asserted to land on `/`; the served bytes are asserted to carry `noindex`, the canonical link to `/`, and the meta refresh. GitHub Pages has no redirect table, so the redirect is a meta refresh — which is why the test follows it rather than trusting a status code. `/errors/` matters most: shipped binaries print those links, and a 404 there strands a user with a diagnostic and no explanation. +- **Only the statement is indexable** — every built page except `/` carries `noindex`, and the sitemap lists `/` alone. 296 redirect stubs must not be offered to search engines as pages. +- **Nothing forbidden survives** — the whole build is scanned for anything [WITHDRAWAL-PROHIBITED](DOCS-WITHDRAWAL-MESSAGING-SPEC.md#WITHDRAWAL-PROHIBITED) bars: a percentage figure, install instructions for any channel, a marketplace or PyPI link, a competitor name, a benchmark claim, a `BSK-` rule code. This is the test that catches a page nobody remembered to delete. +- **The apology is linked, never quoted** — the statement links it; no page reproduces its wording. The redirect stubs carry no copy at all, so they carry no link either. ### CI constraint {#WEBSITE-E2E-NO-ARTIFACTS} -Per `[GITHUB-NO-ARTIFACTS]`, CI emits only the stdout `list` reporter — no -Playwright HTML report, trace, video or screenshot. Those (HTML report + on-retry -trace) are local-only and git-ignored (`website/.gitignore`). The website CI job -(`.github/workflows/ci.yml`) installs only Chromium, since both presets run on it. - -## Mobile Docs Submenu Reachability {#WEBSITE-MOBILE-DOCS-NAV} - -On phones (`max-width: 768px`) the docs section sidebar collapses. It **must** -remain reachable: the hamburger toggle (`mobile-menu.js`, which adds `.open` to -`.sidebar`) reveals it via `.sidebar.open { display: block; }` in -`website/src/assets/css/styles.css`, mirroring the `.nav-links.open` rule for the -top nav. Without that reveal rule the toggle has no effect and the per-section -submenu is unreachable on a phone (regression issue #186). Guard test -`"docs section submenu is reachable via the hamburger"` in -`website/tests/e2e/navigation.spec.ts` asserts the submenu is hidden by default, -becomes visible after the hamburger is tapped, and navigates to the section. +Per `[GITHUB-NO-ARTIFACTS]`, CI emits only the stdout `list` reporter — no Playwright HTML report, trace, video or screenshot. Those (HTML report + on-retry trace) are local-only and git-ignored (`website/.gitignore`). The website CI job (`.github/workflows/ci.yml`) installs only Chromium, since both presets run on it. diff --git a/docs/specs/WEBSITE-ERROR-PAGES-SPEC.md b/docs/specs/WEBSITE-ERROR-PAGES-SPEC.md index 262031695..dfaf7760c 100644 --- a/docs/specs/WEBSITE-ERROR-PAGES-SPEC.md +++ b/docs/specs/WEBSITE-ERROR-PAGES-SPEC.md @@ -1,5 +1,7 @@ # Website: Per-Diagnostic Error Pages {#WEBSITE-ERROR-PAGES} +> **SUPERSEDED — historical record.** The behaviour below no longer ships. Basilisk is unlisted and the site collapsed to one statement, with every retired URL redirecting to it ([WITHDRAWAL-UNLIST](DOCS-WITHDRAWAL-MESSAGING-SPEC.md#WITHDRAWAL-UNLIST)). This spec is kept as the record of what was built, not as a description of the site or a plan to rebuild it. + ## Purpose {#WEBSITE-ERROR-PAGES-PURPOSE} Every diagnostic Basilisk reports ends with a deep link, e.g. diff --git a/docs/specs/WEBSITE-SCREENSHOTS-SPEC.md b/docs/specs/WEBSITE-SCREENSHOTS-SPEC.md index 9f4148faf..54fdc0fbe 100644 --- a/docs/specs/WEBSITE-SCREENSHOTS-SPEC.md +++ b/docs/specs/WEBSITE-SCREENSHOTS-SPEC.md @@ -1,5 +1,7 @@ # Website: Automated CLI Screenshots {#WEBSITE-SCREENSHOTS} +> **SUPERSEDED — historical record.** The behaviour below no longer ships. Basilisk is unlisted and the site collapsed to one statement, with every retired URL redirecting to it ([WITHDRAWAL-UNLIST](DOCS-WITHDRAWAL-MESSAGING-SPEC.md#WITHDRAWAL-UNLIST)). This spec is kept as the record of what was built, not as a description of the site or a plan to rebuild it. + ## Purpose {#WEBSITE-SCREENSHOTS-PURPOSE} The site embeds real `basilisk check` output as PNGs: the homepage before/after diff --git a/docs/specs/ZED-SPEC.md b/docs/specs/ZED-SPEC.md index 5a4a6b8ba..f32b7edc0 100644 --- a/docs/specs/ZED-SPEC.md +++ b/docs/specs/ZED-SPEC.md @@ -1,11 +1,38 @@ # Basilisk Zed Extension {#ZED} -Zed extension connecting to the same `basilisk lsp` binary as the VS Code and Neovim extensions. All LSP features, DAP integration, custom commands, configuration, and binary resolution live in **[LSP-ARCHITECTURE-SPEC.md](LSP-ARCHITECTURE-SPEC.md)** (single source of truth); this spec documents only **Zed-specific details**. +> **The feature sections of this spec are superseded and kept only as a record.** +> Basilisk is unlisted and the `basilisk` binary is inert +> ([WITHDRAWAL](DOCS-WITHDRAWAL-MESSAGING-SPEC.md#WITHDRAWAL)). The extension no +> longer launches a language server, downloads a binary, registers a debug +> adapter, or ships a theme. What it does now is [ZED-NOW](#ZED-NOW); everything +> from [ZED-FEATURES](#ZED-FEATURES) onwards describes what was built and does +> not run. Target: **wasm32 (64-bit) only**. Reference: [Zed Extension Development](https://zed.dev/docs/extensions/developing-extensions), [Zed Python Language Support](https://zed.dev/docs/languages/python). +## What the extension is now {#ZED-NOW} + +One slash command, `/basilisk`, which prints the approved statement into the +assistant panel. Nothing else. The extension declares no `[language_servers.*]` +table (there is no server to launch — the binary is inert and starts none), no +`[debug_adapters.*]` table, no grammars, and no themes; it depends on +`zed_extension_api` and nothing else, and it reads no settings. + +The statement is not written here. `basilisk-zed/src/withdrawal_notice.txt` is +generated from +[WITHDRAWAL-INERT-TEXT](DOCS-WITHDRAWAL-MESSAGING-SPEC.md#WITHDRAWAL-INERT-TEXT) +by `scripts/gen_withdrawal_copy.py` and `include_str!`d, so this extension +prints the same bytes as the CLI, the VS Code extension, and the Neovim plugin +([WITHDRAWAL-SURFACES](DOCS-WITHDRAWAL-MESSAGING-SPEC.md#WITHDRAWAL-SURFACES)). + +`basilisk-zed/src/logic_tests.rs` enforces this against the shipped +`extension.toml`: no language server, no debug adapter, no grammar, exactly one +slash command, the approved one-line description, and no call to +`latest_github_release`/`download_file` anywhere in the glue. It is the Zed +equivalent of `scripts/verify-vsix-inert.sh`. + ## Zed Extension Capabilities {#ZED-CAPS} Zed extensions are Rust compiled to WASM with a deliberately narrow API: @@ -26,39 +53,7 @@ Zed extensions are Rust compiled to WASM with a deliberately narrow API: | File watchers | **No** | Not available — config watching is server-owned ([LSPARCH-CONFIG](LSP-ARCHITECTURE-SPEC.md#LSPARCH-CONFIG)) | | Terminal control | **No** | Not available | -All intelligence flows through LSP and DAP — no client-side tricks. See [LSPARCH-CMDREG](LSP-ARCHITECTURE-SPEC.md#LSPARCH-CMDREG): the server advertises all commands, clients never pre-register them. - -## Architecture {#ZED-ARCH} - -```mermaid -graph TB - subgraph "Zed Editor" - EDITOR[Editor — highlighting, outline, diagnostics] - DAP_CLIENT[Built-in DAP Client] - LSP_CLIENT[Built-in LSP Client] - SLASH[Slash Commands — /profile, /profstop] - end - - subgraph "basilisk lsp (Rust binary)" - LSP_CORE[Language Server — diagnostics, completions, hover, ...] - DEBUG_MGR[Debug Session Manager] - PROFILER[Profiler — py-spy embedded] - end - - subgraph "Python Runtime" - DEBUGPY["debugpy.adapter (TCP)"] - TARGET[User's Python Program] - end - - LSP_CLIENT -->|"LSP over stdin/stdout"| LSP_CORE - LSP_CLIENT -->|"basilisk/startDebugSession"| DEBUG_MGR - LSP_CLIENT -->|"basilisk/profiler/*"| PROFILER - DEBUG_MGR -->|"Returns host:port"| DAP_CLIENT - DAP_CLIENT -->|"DAP over TCP"| DEBUGPY - DEBUGPY -->|"Launches & controls"| TARGET - PROFILER -->|"Reads process memory"| TARGET - SLASH -->|"Triggers LSP commands"| LSP_CLIENT -``` +This table describes Zed's API, not Basilisk's use of it: of the capabilities marked available, the extension now uses only slash commands, and only to print the statement ([ZED-NOW](#ZED-NOW)). ## Extension Structure {#ZED-STRUCTURE} @@ -70,38 +65,30 @@ basilisk-zed/ 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) - themes/ - basilisk-dark.json - debug_adapter_schemas/ - basilisk-debug.json + withdrawal_notice.txt # GENERATED from the messaging spec — the statement ``` -No `languages/` directory — the extension binds to Zed's built-in Python language rather than shadowing it. See [ZED-TREESITTER](#ZED-TREESITTER). +No `languages/`, `themes/`, or `debug_adapter_schemas/` directory: the extension +registers no language, no theme, and no debug adapter. ### `extension.toml` {#ZED-EXTTOML} +The manifest ships exactly this shape — the description is the approved one-line +copy ([WITHDRAWAL-COPY-LINE](DOCS-WITHDRAWAL-MESSAGING-SPEC.md#WITHDRAWAL-COPY-LINE)), +and the only table below the package metadata is the one slash command: + ```toml id = "basilisk" name = "Basilisk" -version = "0.1.0" +version = "0.0.0-PLACEHOLDER" # stamped in CI — see [ZED-MIRROR] schema_version = 1 authors = ["Basilisk Contributors"] -description = "Strict-by-default Python type checker with debugging and profiling" +description = "Basilisk's type checker produced incorrect results. Basilisk is unlisted and is being rebuilt from the ground up as a new product." repository = "https://github.com/Nimblesite/Basilisk" -# No [grammars.python] block and no languages/ dir — binds to Zed's built-in Python. See [ZED-GRAMMAR]. - -[language_servers.basilisk] -name = "Basilisk" -languages = ["Python"] - -[language_servers.basilisk.language_ids] -"Python" = "python" - -[debug_adapters.basilisk-debug] -schema_path = "debug_adapter_schemas/basilisk-debug.json" +[slash_commands.basilisk] +description = "Why is Basilisk unlisted?" +requires_argument = false ``` ### `Cargo.toml` {#ZED-CARGOTOML} @@ -109,7 +96,7 @@ schema_path = "debug_adapter_schemas/basilisk-debug.json" ```toml [package] name = "basilisk-zed" -version = "0.1.0" +version = "0.0.0-PLACEHOLDER" edition = "2021" [lib] @@ -119,97 +106,101 @@ crate-type = ["cdylib"] zed_extension_api = "0.7.0" ``` +One dependency. The extension shares no constants with the language server — +there is no server to share them with — and serialises nothing, so neither +`basilisk-common` nor `serde_json` is linked in. That is also why the mirror +render no longer vendors a workspace crate ([ZED-MIRROR](#ZED-MIRROR)). + ### `src/lib.rs` {#ZED-LIBRS} +One trait method is overridden. Every other method of `zed::Extension` keeps its +default, and the defaults answer "not implemented" — the honest answer for a +server, adapter, or command this extension no longer provides. + ```rust use zed_extension_api::{self as zed, Result}; struct BasiliskExtension; impl zed::Extension for BasiliskExtension { - fn language_server_command( - &mut self, - language_server_id: &zed::LanguageServerId, - worktree: &zed::Worktree, - ) -> Result { - // 1. Check for user-configured path in Zed settings - // 2. Try well-known locations (~/.cargo/bin/basilisk, /usr/local/bin/basilisk, etc.) - // 3. Download from GitHub release if not found - let binary_path = self.resolve_binary(worktree)?; - - Ok(zed::Command { - command: binary_path, - args: vec!["lsp".into()], - env: Default::default(), - }) - } - - fn language_server_initialization_options( - &mut self, - _language_server_id: &zed::LanguageServerId, - worktree: &zed::Worktree, - ) -> Result> { - // Pass workspace root so LSP can find .venv, pyproject.toml, etc. - Ok(Some(zed::serde_json::json!({ - "workspaceRoot": worktree.root_path(), - }))) - } - - fn language_server_workspace_configuration( - &mut self, - _language_server_id: &zed::LanguageServerId, - _worktree: &zed::Worktree, - ) -> Result> { - // Read Zed settings and map to Basilisk config - Ok(Some(zed::serde_json::json!({ - "basilisk": { - "inlayHints": { - "parameterNames": true, - "variableTypes": true - }, - // Formatter engine: "ruff" (Ruff formatter embedded in the - // Basilisk binary, in-process — no external ruff binary) or - // "none". [LSPFMT-CONFIG] - "formatter": "ruff" - } - }))) - } - - fn get_dap_binary( - &mut self, - config: zed::DebugConfig, - ) -> Result { - // Debug sessions use the same basilisk binary - // The LSP spawns debugpy; the DAP client connects to it - let binary_path = self.resolve_binary_from_config(&config)?; - - Ok(zed::DebugAdapterBinary { - command: binary_path, - args: vec!["debug-adapter".into()], - envs: Default::default(), - cwd: config.cwd.clone(), - connection: None, - }) - } + fn new() -> Self { Self } + /// `/basilisk` — print the approved statement into the assistant panel. fn run_slash_command( - &mut self, - command: zed::SlashCommand, - args: Vec, - worktree: Option<&zed::Worktree>, + &self, + _command: zed::SlashCommand, + _args: Vec, + _worktree: Option<&zed::Worktree>, ) -> Result { - match command.name.as_str() { - "profile" => self.handle_profile_command(args, worktree), - "profstop" => self.handle_profstop_command(worktree), - _ => Err("Unknown command".into()), - } + let (label, text) = logic::notice_output(); + Ok(zed::SlashCommandOutput { + sections: vec![zed::SlashCommandOutputSection { + range: (0..text.len()).into(), + label, + }], + text, + }) } } zed::register_extension!(BasiliskExtension); ``` -## Features {#ZED-FEATURES} +## Registry Publishing {#ZED-MIRROR} + +Zed has no upload API. Extensions are listed in [`zed-industries/extensions`](https://github.com/zed-industries/extensions) as **git submodules**; that repo's CI compiles each to WASM from the pinned commit and publishes on merge. Two properties of the in-repo `basilisk-zed/` crate make it unpublishable as-is, so the release pipeline renders a self-contained mirror: + +1. **Placeholder version.** Every monorepo commit carries `0.0.0-PLACEHOLDER` in `Cargo.toml` + `extension.toml`; real versions are stamped only in CI (see [ZED-CARGOTOML](#ZED-CARGOTOML)). The registry pins a commit, so it cannot point at `main`. +2. **Workspace `[lints]` inheritance.** `[lints] workspace = true` does not resolve when the registry builds the submodule standalone, with no parent workspace above it. + +`scripts/render-zed-mirror.sh` resolves both: it stamps the release version, makes the mirror its own workspace root, and drops the workspace-only `[lints]` inheritance (lint strictness is enforced by the monorepo `zed` CI job, not by the distribution render). It vendors nothing — the extension's only dependency is `zed_extension_api`, from crates.io. The `publish-zed` job in `release.yml` renders the tree, **gates the push on a real `cargo build --release --target wasm32-wasip2`**, then pushes to [`Nimblesite/basilisk-zed`](https://github.com/Nimblesite/basilisk-zed) and tags it with the monorepo tag — same clone-replace-commit-push convention as `publish-nvim`, using the `BREW_SCOOP_PAT` org secret. + +The mirror version equals the monorepo tag (`v1.2.3` → `1.2.3`). + +**This publishes one final version, and then the listing is removed.** The order is fixed by [WITHDRAWAL-UNLIST](DOCS-WITHDRAWAL-MESSAGING-SPEC.md#WITHDRAWAL-UNLIST): publish the final version → verify it is live → unlist. Unlisting alone would leave every existing install on the last feature release, never showing the statement. The removal PR against `zed-industries/extensions` is `delist/06-unlist-zed.sh`. + +**Pushing the mirror publishes nothing.** Zed installs only what `zed-industries/extensions` lists, so the mirror push is a prerequisite, not the release. `scripts/publish_zed_registry.py`, run by `publish-zed` immediately after the mirror is tagged, performs the listing itself: it forks the registry, resets a `listing-basilisk` branch to upstream's head, adds or re-pins the `extensions/basilisk` submodule to the release tag, sets `[basilisk] version` in `extensions.toml`, re-sorts `.gitmodules`, and opens the PR — or, once that PR is open, moves the pointer on the same branch. Every release therefore proposes its own bump; upstream maintainers still merge it. + +The registry is a repository Basilisk does not own, and its `extensions.toml` holds ~1400 entries, so the edit is **surgical, not a rewrite**: the entry is spliced into alphabetical position and every other entry stays byte-identical, since a reformatting diff across someone else's registry is a rejected PR. `scripts/test_publish_zed_registry.py` proves both properties — placement, non-disturbance, bump-not-duplicate, and idempotence — in the `zed` CI job, because the real thing runs only during a tagged release. + +## Record of what was built {#ZED-FEATURES} + +Everything below this line is history. None of it runs: the manifest registers +no server, adapter, or grammar, and the binary it describes is inert. It is kept +because it is the account of what existed, not because it is a current contract +— and nothing here authorises rebuilding what it describes. + +## Architecture {#ZED-ARCH} + +```mermaid +graph TB + subgraph "Zed Editor" + EDITOR[Editor — highlighting, outline, diagnostics] + DAP_CLIENT[Built-in DAP Client] + LSP_CLIENT[Built-in LSP Client] + SLASH[Slash Commands — /profile, /profstop] + end + + subgraph "basilisk lsp (Rust binary)" + LSP_CORE[Language Server — diagnostics, completions, hover, ...] + DEBUG_MGR[Debug Session Manager] + PROFILER[Profiler — py-spy embedded] + end + + subgraph "Python Runtime" + DEBUGPY["debugpy.adapter (TCP)"] + TARGET[User's Python Program] + end + + LSP_CLIENT -->|"LSP over stdin/stdout"| LSP_CORE + LSP_CLIENT -->|"basilisk/startDebugSession"| DEBUG_MGR + LSP_CLIENT -->|"basilisk/profiler/*"| PROFILER + DEBUG_MGR -->|"Returns host:port"| DAP_CLIENT + DAP_CLIENT -->|"DAP over TCP"| DEBUGPY + DEBUGPY -->|"Launches & controls"| TARGET + PROFILER -->|"Reads process memory"| TARGET + SLASH -->|"Triggers LSP commands"| LSP_CLIENT +``` ### Language Intelligence {#ZED-LSP} @@ -287,6 +278,8 @@ Bundling `[grammars.python]` would force Zed to compile the grammar from source ## Binary Distribution {#ZED-DIST} +> **Superseded.** The extension downloads nothing. `resolve_binary`, `download_binary` and `check_for_updates` are deleted, and `basilisk-zed/src/logic_tests.rs` fails the build if `latest_github_release` or `download_file` reappears in the glue. The rest of this section is the record of how it worked. + Installing the extension is enough — no separate binary install. Per the Shipwright contract, the binary ships with every release (`.github/workflows/release.yml`); the extension downloads the matching asset on first activation, caches it in its data directory, and reuses it until a newer release appears. There is **no filesystem default** (no `~/.cargo/bin`, no PATH guess) — a missing override means "download", never "guess". Resolution order (`basilisk-zed/src/lib.rs::resolve_binary`): @@ -319,21 +312,6 @@ Target assets (must match `release.yml` — see `basilisk_common::release::asset Archive kind and in-archive binary path are platform-specific (macOS zip nested; Linux `tar.gz` and Windows zip flat), derived from `basilisk_common::release::{is_zip_archive, extracted_binary_path}` so the downloader cannot drift from the release pipeline. -## Registry Publishing {#ZED-MIRROR} - -Zed has no upload API. Extensions are listed in [`zed-industries/extensions`](https://github.com/zed-industries/extensions) as **git submodules**; that repo's CI compiles each to WASM from the pinned commit and publishes on merge. Two properties of the in-repo `basilisk-zed/` crate make it unpublishable as-is, so the release pipeline renders a self-contained mirror: - -1. **Placeholder version.** Every monorepo commit carries `0.0.0-PLACEHOLDER` in `Cargo.toml` + `extension.toml`; real versions are stamped only in CI (see [ZED-CARGOTOML](#ZED-CARGOTOML)). The registry pins a commit, so it cannot point at `main`. -2. **Workspace path dependency.** The crate depends on `basilisk-common` via `{ path = "../crates/basilisk-common" }`, which does not resolve when the registry builds the submodule standalone. - -`scripts/render-zed-mirror.sh` resolves both: vendors `basilisk-common` (zero-dependency, WASM-safe) under `vendor/basilisk-common`, rewrites the path dependency, stamps the release version, makes the mirror its own workspace root, and drops the workspace-only `[lints]` inheritance. The `publish-zed` job in `release.yml` renders the tree, **gates the push on a real `cargo build --release --target wasm32-wasip2`**, then pushes to [`Nimblesite/basilisk-zed`](https://github.com/Nimblesite/basilisk-zed) and tags it with the monorepo tag — same clone-replace-commit-push convention as `publish-nvim`, using the `BREW_SCOOP_PAT` org secret. - -The mirror version equals the monorepo tag (`v1.2.3` → `1.2.3`); the binary [ZED-DIST](#ZED-DIST) updates independently at runtime. - -**Pushing the mirror publishes nothing.** Zed installs only what `zed-industries/extensions` lists, so the mirror push is a prerequisite, not the release. `scripts/publish_zed_registry.py`, run by `publish-zed` immediately after the mirror is tagged, performs the listing itself: it forks the registry, resets a `listing-basilisk` branch to upstream's head, adds or re-pins the `extensions/basilisk` submodule to the release tag, sets `[basilisk] version` in `extensions.toml`, re-sorts `.gitmodules`, and opens the PR — or, once that PR is open, moves the pointer on the same branch. Every release therefore proposes its own bump; upstream maintainers still merge it. - -The registry is a repository Basilisk does not own, and its `extensions.toml` holds ~1400 entries, so the edit is **surgical, not a rewrite**: the entry is spliced into alphabetical position and every other entry stays byte-identical, since a reformatting diff across someone else's registry is a rejected PR. `scripts/test_publish_zed_registry.py` proves both properties — placement, non-disturbance, bump-not-duplicate, and idempotence — in the `zed` CI job, because the real thing runs only during a tagged release. - ## Zed Settings {#ZED-CONFIG} > Shared settings are defined in [LSP-ARCHITECTURE-SPEC.md §LSPARCH-CONFIG](LSP-ARCHITECTURE-SPEC.md#LSPARCH-CONFIG); mapped into Zed's `settings.json` below. diff --git a/examples/README.md b/examples/README.md deleted file mode 100644 index 74c767a6c..000000000 --- a/examples/README.md +++ /dev/null @@ -1,124 +0,0 @@ -

English · 简体中文

- -# Basilisk Examples - -Realistic Python scripts that demonstrate what Basilisk catches — and what -clean, fully-typed code looks like. - -## Running the examples - -```bash -# Typing-spec errors in a single file -basilisk check examples/bad.py - -# The opt-in house rules on that same file -basilisk analyze examples/bad.py - -# Every example at once -basilisk check examples/ - -# JSON output (for editors / CI) -basilisk check examples/bad.py --output json -``` - -`check` and `analyze` read one rule universe partitioned by provenance -([CHKARCH-COMMANDS]): `check` reports the `pep`-tagged typing-spec rules and -nothing else, while `analyze` reports the non-`pep` house rules a table -selected. Both honour the severities the tables below describe. The `BSK-` codes -tabulated later therefore appear under `analyze` — never under `check`. - -## Spec rules vs house rules - -Every **PEP** diagnostic below is a genuine violation of the -[Python typing spec](https://typing.python.org/en/latest/spec/index.html). -Those rules are on out of the box — in your own project, with no -configuration, you get exactly them, at `error`. A config file can grade one -of them down to `warning` or `info`, but no table may switch it off. - -The rest are Basilisk's opt-in house rules (annotations required everywhere, -`@override` required, and so on). They stay silent until a `[tool.basilisk]` -table selects them. Basilisk resolves configuration per checked file by -walking up from the file's own folder, and the nearest table that decides a -rule wins outright. Two tables decide things here: the root `pyproject.toml` -selects the house rules for the repository, and `examples/pyproject.toml` — the -nearer one for everything under `examples/` — grades `BSK-0001`–`BSK-0005` and -`BSK-0025` down to `warning`. That is the incremental-adoption setup the docs -teach: warnings mean "this type-checks, but strictness isn't at full yet". -Rules the examples' table says nothing about, such as `BSK-0014` and -`BSK-0050`, are still decided by the root table. - -That is the whole scoping mechanism. To run a rule at a different severity in -one part of a tree, put a `pyproject.toml` carrying its own `[tool.basilisk]` -table in that folder; there are no glob path patterns, no per-module tables, -and no presets or modes. - -## Files - -### Violation showcases (many diagnostics) - -| File | Domain | PEP rules (always on, errors here) | Basilisk house rules (opt-in) | -|---|---|---|---| -| [bad.py](bad.py) | Minimal tour | `calls_argument_type`, `returns_compatibility`, `assignment_compatibility`, `calls_argument_count`, `classes_override`, `names_unbound`, `match_exhaustiveness` | BSK-0001, BSK-0002, BSK-0004 | -| [mixed.py](mixed.py) | Mixed typed / untyped | `calls_argument_type` | BSK-0001, BSK-0002 | -| [api_server.py](api_server.py) | REST API handler | `assignment_compatibility`, `overloads_consistency`, `names_unbound`, `dict_key_hashable`, `classes_override_2` | BSK-0001–BSK-0003, BSK-0025, BSK-0014, BSK-0050 | -| [data_pipeline.py](data_pipeline.py) | ETL pipeline | `assignment_compatibility`, `overloads_consistency`, `names_unbound`, `dict_key_hashable`, `classes_override_2` | BSK-0001–BSK-0003, BSK-0025, BSK-0014 | -| [ml_trainer.py](ml_trainer.py) | ML training loop | `assignment_compatibility`, `overloads_consistency`, `match_exhaustiveness`, `dict_key_hashable`, `classes_override_2` | BSK-0001–BSK-0003, BSK-0025, BSK-0014, BSK-0050 | -| [finance.py](finance.py) | Financial calculations | `assignment_compatibility`, `classes_override_2`, `overloads_consistency`, `names_unbound`, `match_exhaustiveness`, `dict_key_hashable` | BSK-0001–BSK-0003, BSK-0025, BSK-0014, BSK-0050 | -| [cli_tool.py](cli_tool.py) | CLI application | `assignment_compatibility`, `classes_override_2`, `overloads_consistency`, `names_unbound`, `match_exhaustiveness`, `dict_key_hashable` | BSK-0001–BSK-0003, BSK-0025, BSK-0014, BSK-0050 | -| [weird_violations.py](weird_violations.py) | Subtle edge cases | `overloads_consistency`, `names_unbound`, `classes_override_2`, `assignment_compatibility`, `match_exhaustiveness`, `dict_key_hashable` | BSK-0001–BSK-0003, BSK-0014, BSK-0050 | - -### Clean counterparts (zero diagnostics) - -| File | Counterpart | -|---|---| -| [good.py](good.py) | `bad.py` fixed — passes at full strictness | -| [api_server_clean.py](api_server_clean.py) | `api_server.py` fixed | - -### Debugger & profiler demos (launch with F5) - -These are clean, fully-typed scripts meant to be *run* under the Basilisk -debugger rather than statically checked. Open one and press F5. - -| File | Demonstrates | How to use | -|---|---|---| -| [debug_demo.py](debug_demo.py) | Breakpoints, Watch panel, Locals, Debug Console | Set a breakpoint and step through | -| [profile_demo.py](profile_demo.py) | CPU profiling — a few seconds of CPU-bound work with a clear hot spot, so the flame chart and hot-line heat map fill in | One click: **Run & Profile CPU (Current File)** | -| [cpu_demo.py](cpu_demo.py) | CPU sampling — hot/warm/cold flame chart, hot-line hints | Attach the CPU profiler to the live session | -| [memory_demo.py](memory_demo.py) | Memory — sustained leak, transient spike, reference cycle; the run captures a final snapshot at exit, so it ends in a viewable heat map / `.heapprofile` | One click: **Run & Track Memory (Current File)** | -| [heap_demo.py](heap_demo.py) | Memory — a chunky ~70 MB warm cache across ~40 distinct allocation sites, so the `.heapprofile` flame chart and Self-Size table fill with varied, real slices | One click: **Run & Track Memory (Current File)** | - -## Rule reference - -Every diagnostic ends with a `see:` link to its documentation page. The full -catalog lives at [basilisk-python.dev/docs/rules](https://www.basilisk-python.dev/docs/rules/). - -### PEP typing-spec rules shown here (always on, errors here) - -| Code | Meaning | -|---|---| -| `calls_argument_type` | Argument incompatible with the parameter's declared type | -| `calls_argument_count` | Wrong number of arguments in a call | -| `returns_compatibility` / `returns_compatibility_2` | Returned value not assignable to the declared return type | -| `assignment_compatibility` | Assigned value not assignable to the annotation | -| `classes_override` | `@override` method incompatible with the base-class method | -| `classes_override_2` | Attribute override incompatible with the base class | -| `names_unbound` | Variable may be unbound on some execution paths | -| `match_exhaustiveness` | Non-exhaustive `match` — no wildcard `case _:` branch | -| `dict_key_hashable` | Unhashable type used as a dict key | -| `overloads_consistency` | Inconsistent or overlapping `@overload` group | - -### Basilisk house rules shown here (opt-in) - -The severity column is what the tables governing `examples/` select, not a -property of the code — a rule code carries no severity class. In an -unconfigured project none of these rules run at all. - -| Code | Meaning | Severity here | -|---|---|---| -| BSK-0001 | Missing parameter type annotation | warning | -| BSK-0002 | Missing return type annotation | warning | -| BSK-0003 | Cannot infer type of empty collection or `None` | warning | -| BSK-0004 | Missing `*args` / `**kwargs` type annotation | warning | -| BSK-0025 | Override missing `@override` decorator | warning | -| BSK-0014 | Explicit `Any` without justification | warning | -| BSK-0050 | Redundant type annotation | warning | diff --git a/examples/README.zh.md b/examples/README.zh.md deleted file mode 100644 index 29e9808b3..000000000 --- a/examples/README.zh.md +++ /dev/null @@ -1,118 +0,0 @@ -

English · 简体中文

- -> 📝 本文档由机器翻译生成,欢迎母语者校对改进。 - -# Basilisk 示例 - -真实的 Python 脚本,展示 Basilisk 能捕获哪些问题,以及无错误(干净)、完整类型注解的代码是什么样子。 - -## 运行示例 - -```bash -# 单个文件中的类型规范错误 -basilisk check examples/bad.py - -# 同一文件上可选启用的自定规则 -basilisk analyze examples/bad.py - -# 一次性检查所有示例 -basilisk check examples/ - -# JSON 输出(用于编辑器 / CI) -basilisk check examples/bad.py --output json -``` - -`check` 与 `analyze` 读取的是同一套规则,只是按来源做了划分([CHKARCH-COMMANDS]): -`check` 只报告带 `pep` 标签的类型规范规则,而 `analyze` 报告由配置表选用的、不带 -`pep` 标签的自定规则;两者都遵循下文表格所描述的严重级别。因此下文列出的 `BSK-` -代码只会出现在 `analyze` 中——`check` 永远不会输出它们。 - -## 规范规则 vs 自定规则 - -下面的每个 **PEP** 诊断都是对 -[Python 类型规范](https://typing.python.org/en/latest/spec/index.html)的真实违反。 -这些规则开箱即用——在您自己的项目中,无需任何配置,您得到的正是它们,级别为 -`error`。配置文件可以把其中某条降级为 `warning` 或 `info`,但任何表都无法把 -它关掉。 - -其余的都是 Basilisk 的可选自定规则(处处要求注解、要求 `@override` 等)。 -在某个 `[tool.basilisk]` 表选中它们之前,它们保持沉默。Basilisk 针对每个被 -检查的文件,从该文件所在目录逐级向上查找配置,最近的、对某条规则作出决定的表 -直接胜出。这里有两个表在起作用:根 `pyproject.toml` 为整个仓库选中这些自定 -规则,而 `examples/pyproject.toml`——对 `examples/` 下的一切来说更近的那个 -表——把 `BSK-0001`–`BSK-0005` 与 `BSK-0025` 降级为 `warning`。这正是文档教授 -的渐进式采纳方式:警告意味着"这段代码通过类型检查,但严格度还没有拉满"。 -examples 的表没有提到的规则(例如 `BSK-0014` 与 `BSK-0050`)仍由根表决定。 - -这就是全部的作用域机制。若要让某条规则在目录树的某一部分以不同严重级别运行, -请在该文件夹放一个带有自己的 `[tool.basilisk]` 表的 `pyproject.toml`;这里 -没有 glob 路径模式,没有按模块的表,也没有预设或模式。 - -## 文件 - -### 违规展示(包含大量诊断) - -| 文件 | 领域 | PEP 规则(始终启用,此处为错误) | Basilisk 自定规则(可选启用) | -|---|---|---|---| -| [bad.py](bad.py) | 最小化导览 | `calls_argument_type`, `returns_compatibility`, `assignment_compatibility`, `calls_argument_count`, `classes_override`, `names_unbound`, `match_exhaustiveness` | BSK-0001, BSK-0002, BSK-0004 | -| [mixed.py](mixed.py) | 混合:有类型 / 无类型 | `calls_argument_type` | BSK-0001, BSK-0002 | -| [api_server.py](api_server.py) | REST API 处理器 | `assignment_compatibility`, `overloads_consistency`, `names_unbound`, `dict_key_hashable`, `classes_override_2` | BSK-0001–BSK-0003, BSK-0025, BSK-0014, BSK-0050 | -| [data_pipeline.py](data_pipeline.py) | ETL 管道 | `assignment_compatibility`, `overloads_consistency`, `names_unbound`, `dict_key_hashable`, `classes_override_2` | BSK-0001–BSK-0003, BSK-0025, BSK-0014 | -| [ml_trainer.py](ml_trainer.py) | 机器学习训练循环 | `assignment_compatibility`, `overloads_consistency`, `match_exhaustiveness`, `dict_key_hashable`, `classes_override_2` | BSK-0001–BSK-0003, BSK-0025, BSK-0014, BSK-0050 | -| [finance.py](finance.py) | 财务计算 | `assignment_compatibility`, `classes_override_2`, `overloads_consistency`, `names_unbound`, `match_exhaustiveness`, `dict_key_hashable` | BSK-0001–BSK-0003, BSK-0025, BSK-0014, BSK-0050 | -| [cli_tool.py](cli_tool.py) | CLI 应用程序 | `assignment_compatibility`, `classes_override_2`, `overloads_consistency`, `names_unbound`, `match_exhaustiveness`, `dict_key_hashable` | BSK-0001–BSK-0003, BSK-0025, BSK-0014, BSK-0050 | -| [weird_violations.py](weird_violations.py) | 微妙的边界情况 | `overloads_consistency`, `names_unbound`, `classes_override_2`, `assignment_compatibility`, `match_exhaustiveness`, `dict_key_hashable` | BSK-0001–BSK-0003, BSK-0014, BSK-0050 | - -### 无错误对照版本(零诊断) - -| 文件 | 对照 | -|---|---| -| [good.py](good.py) | `bad.py` 的修复版——在完全严格模式下通过 | -| [api_server_clean.py](api_server_clean.py) | `api_server.py` 的修复版 | - -### 调试器与性能分析器演示(按 F5 启动) - -这些是无错误(干净)、完整类型注解的脚本,旨在 Basilisk 调试器下*运行*,而非进行静态检查。打开其中一个并按 F5。 - -| 文件 | 演示内容 | 使用方式 | -|---|---|---| -| [debug_demo.py](debug_demo.py) | 断点、Watch 面板、Locals、Debug Console | 设置一个断点并单步执行 | -| [profile_demo.py](profile_demo.py) | CPU 性能分析——几秒钟具有明显热点的 CPU 密集型工作,让火焰图和热点行热力图填充起来 | 一键操作:**Run & Profile CPU (Current File)** | -| [cpu_demo.py](cpu_demo.py) | CPU 采样——热/温/冷火焰图、热点行提示 | 将 CPU 性能分析器附加到正在运行的会话 | -| [memory_demo.py](memory_demo.py) | 内存——持续泄漏、瞬时峰值、引用循环;该运行会在退出时捕获最终快照,因此结束时会生成可查看的热力图 / `.heapprofile` | 一键操作:**Run & Track Memory (Current File)** | -| [heap_demo.py](heap_demo.py) | 内存——约 70 MB 的大块温缓存,分布在约 40 个不同的分配位置,使 `.heapprofile` 火焰图和 Self-Size 表格填满多样、真实的数据切片 | 一键操作:**Run & Track Memory (Current File)** | - -## 规则参考 - -每条诊断末尾都带有指向其文档页面的 `see:` 链接。完整目录见 -[basilisk-python.dev/docs/rules](https://www.basilisk-python.dev/docs/rules/)。 - -### 此处展示的 PEP 类型规范规则(始终启用,此处为错误) - -| 代码 | 含义 | -|---|---| -| `calls_argument_type` | 实参与形参声明的类型不兼容 | -| `calls_argument_count` | 调用时参数数量错误 | -| `returns_compatibility` / `returns_compatibility_2` | 返回值不能赋值给声明的返回类型 | -| `assignment_compatibility` | 赋的值不能赋值给注解类型 | -| `classes_override` | `@override` 方法与基类方法不兼容 | -| `classes_override_2` | 属性重写与基类不兼容 | -| `names_unbound` | 变量在某些执行路径上可能未绑定 | -| `match_exhaustiveness` | 非穷尽的 `match`——缺少通配 `case _:` 分支 | -| `dict_key_hashable` | 不可哈希的类型被用作字典键 | -| `overloads_consistency` | `@overload` 组不一致或相互重叠 | - -### 此处展示的 Basilisk 自定规则(可选启用) - -严重级别一列是管辖 `examples/` 的那些表所选中的值,而不是代码自身的属性—— -规则代码不携带任何严重级别类别。在未作配置的项目中,这些规则根本不会运行。 - -| 代码 | 含义 | 此处的严重级别 | -|---|---|---| -| BSK-0001 | 缺少参数类型注解 | warning | -| BSK-0002 | 缺少返回值类型注解 | warning | -| BSK-0003 | 无法推断空集合或 `None` 的类型 | warning | -| BSK-0004 | 缺少 `*args` / `**kwargs` 类型注解 | warning | -| BSK-0025 | 重写缺少 `@override` 装饰器 | warning | -| BSK-0014 | 使用显式 `Any` 但缺少说明 | warning | -| BSK-0050 | 冗余的类型注解 | warning | diff --git a/examples/api_server.py b/examples/api_server.py deleted file mode 100644 index 7635603ba..000000000 --- a/examples/api_server.py +++ /dev/null @@ -1,88 +0,0 @@ -""" -REST API handler — realistic web service code with type violations. - -Run: basilisk check examples/api_server.py -""" - -from __future__ import annotations - -import json -from typing import Any, overload - - -# ── BSK-0003: can't infer type from empty dict literal ───────────────────── -_route_table = {} # BSK-0003: empty dict, no annotation -_middleware_stack = [] # BSK-0003: empty list, no annotation - - -# ── BSK-0001/0002: untyped handler signatures ────────────────────────────── -def handle_get(request, context): # BSK-0001: request, context untyped - user_id = request.get("user_id") - return {"user": user_id} # BSK-0002: no return type - - -def handle_post(request, body, auth): # BSK-0001: three untyped params - if not auth: - return None - return body # BSK-0002: no return type - - -# ── returns_compatibility: naked Any in public API signature ───────────────────────────── -def serialize(value: Any) -> Any: # returns_compatibility: Any in/out, no justification - return json.dumps(value) - - -# ── assignment_compatibility: int field assigned a string at module level ─────────────────── -MAX_RETRIES: int = "three" # assignment_compatibility: "three" is not int -TIMEOUT_MS: int = 30.5 # assignment_compatibility: float assigned to int - - -# ── classes_override_2: child route overrides attribute with incompatible type ───────── -class BaseRoute: - path: str - method: str - priority: int - - -class AdminRoute(BaseRoute): - priority: str = "high" # classes_override_2: str overrides int - - -# ── overloads_consistency: overload signatures identical (both take no-annotation param) ── -@overload -def parse_id(raw) -> int: ... # BSK-0001: raw untyped - - -@overload -def parse_id(raw) -> int: ... # BSK-0001 + overloads_consistency: duplicate overload - - -def parse_id(raw: str) -> int: - return int(raw) - - -# ── dict_key_hashable: unhashable list literal as dict key ──────────────────────────── -def default_routes() -> dict[ - list[str], str -]: # dict_key_hashable inside return annotation - return {["GET", "POST"]: "/"} # dict_key_hashable: list literal as key - - -# ── BSK-0025: override without @override decorator ───────────────────────── -class Router: - def resolve(self, path: str) -> str: - return path - - -class PrefixRouter(Router): - prefix: str = "/api" - - def resolve(self, path: str) -> str: # BSK-0025: missing @override - return self.p + path - - -# ── names_unbound: variable assigned inside if, returned outside ───────────────── -def extract_token(headers: dict[str, str]) -> str: - if "Authorization" in headers: - token = headers["Authorization"].split(" ")[-1] - return token # names_unbound: token may be unbound diff --git a/examples/api_server_clean.py b/examples/api_server_clean.py deleted file mode 100644 index 96445740a..000000000 --- a/examples/api_server_clean.py +++ /dev/null @@ -1,90 +0,0 @@ -""" -REST API handler — fully typed, passes Basilisk with zero diagnostics. - -Run: basilisk check examples/api_server_clean.py -""" - -from __future__ import annotations - -import json -from typing import ClassVar, overload, override - - -# Properly annotated module-level state -_route_table: dict[str, str] = {} -_middleware_stack: list[str] = [] - - -# Typed handler signatures -def handle_get(request: dict[str, str], context: str) -> dict[str, str]: - user_id = request.get("user_id", "") - return {"user": user_id} - - -def handle_post( - request: dict[str, str], - body: dict[str, str], - auth: str, -) -> dict[str, str] | None: - if not auth: - return None - return body - - -# `object` instead of `Any` — JSON can be any valid JSON type, and -# `json.dumps` accepts `object`, so no suppression is needed -def serialize(value: object) -> str: - return json.dumps(value) - - -# Properly typed constants -MAX_RETRIES = 3 -TIMEOUT_MS = 30_000 - - -# Consistent attribute types in the hierarchy -class BaseRoute: - path: str - method: str - priority: int - - -class AdminRoute(BaseRoute): - priority = 100 # same type as the base class - - -# Non-overlapping overloads -@overload -def parse_id(raw: str) -> int: ... - - -@overload -def parse_id(raw: bytes) -> int: ... # different param type - - -def parse_id(raw: str | bytes) -> int: - return int(raw) - - -# Hashable key -def register_handler(path: str, method: str) -> None: - _route_table[path] = method # str is hashable - - -# @override present -class Router: - def resolve(self, path: str) -> str: - return path - - -class PrefixRouter(Router): - prefix: ClassVar[str] = "/api" - - @override - def resolve(self, path: str) -> str: - return self.prefix + path - - -# Variable always bound before use -def extract_token(headers: dict[str, str]) -> str: - return headers.get("Authorization", "").split(" ")[-1] diff --git a/examples/bad.py b/examples/bad.py deleted file mode 100644 index 192f44345..000000000 --- a/examples/bad.py +++ /dev/null @@ -1,71 +0,0 @@ -# Every diagnostic in the first section is a genuine PEP typing-spec -# violation. Basilisk reports all of them out of the box — no configuration, -# every one an error. -# -# Run: basilisk check examples/bad.py -# -# The final section violates only Basilisk's opt-in strictness rules. Those -# stay silent until a project enables them — per rule, at any severity — in -# `[tool.basilisk.rules]` or via "Basilisk: Open Configuration Editor" in -# VS Code. This repository enables them for `examples/**` as warnings in the -# root `pyproject.toml`: the incremental-adoption setup, where warnings mean -# "this type-checks, but strictness isn't at full yet". - -from typing import override - - -def greet(name: str) -> str: - return "Hello, " + name - - -greet(42) # error[calls_argument_type]: `name` expects `str`, got an `int` - - -def get_score() -> int: # error[returns_compatibility]: declared `int`, returns `str` - return "high" # error[returns_compatibility_2]: `str` is not assignable to `int` - - -count: int = "zero" # error[assignment_compatibility]: annotated `int`, assigned `str` - - -def add(x: int, y: int) -> int: - return x + y - - -add(1) # error[calls_argument_count]: missing required argument `y` - - -class Shape: - def area(self, scale: float) -> float: - return scale - - -class Circle(Shape): - @override - def area( - self, scale: str - ) -> float: # error[classes_override]: incompatible with `Shape.area` - return 1.0 - - -def describe(flag: bool) -> str: - if flag: - label = "on" - return label # error[names_unbound]: `label` is unbound when `flag` is false - - -def classify(value: int | str) -> str: - match value: # error[match_exhaustiveness]: no `case _:` branch - case int(): - return "number" - - -# ── Opt-in strictness rules — silent until enabled ────────────────────────── - - -def process(data): # BSK-0001: `data` untyped; BSK-0002: no return type - return data.upper() - - -def log_all(*args, **kwargs): # BSK-0004: `*args` / `**kwargs` untyped - pass diff --git a/examples/cli_tool.py b/examples/cli_tool.py deleted file mode 100644 index 45dc22731..000000000 --- a/examples/cli_tool.py +++ /dev/null @@ -1,131 +0,0 @@ -""" -CLI tool — realistic command-line application with type violations. - -This models the kind of ad-hoc scripting code that gradually grows -into a maintenance problem. Every violation has a plausible story. - -Run: basilisk check examples/cli_tool.py -""" - -from __future__ import annotations - -import sys -from typing import Any, overload - - -# ── BSK-0003: unannotated state at module scope ───────────────────────────── -_parsed_flags = {} # BSK-0003: empty dict -_positional_args = [] # BSK-0003: empty list -_subcommand_map = {} # BSK-0003: empty dict - - -# ── BSK-0001/0002: argument parsing functions without any types ───────────── -def parse_flag(argv, name, default): # BSK-0001: three untyped params - """Return the value of --name from argv, or default.""" - for i, arg in enumerate(argv): - if arg == f"--{name}" and i + 1 < len(argv): - return argv[i + 1] - return default # BSK-0002: no return type - - -def run_subcommand(name, args, env): # BSK-0001: three untyped params - handler = _subcommand_map.get(name) - if handler: - handler(args, env) - # BSK-0002: no return type - - -def format_error(code, message, context): # BSK-0001: three untyped params - return f"[E{code}] {message} ({context})" # BSK-0002: no return type - - -# ── returns_compatibility: Any in public-facing output function ────────────────────────── -def print_result( - value: Any, -) -> None: # returns_compatibility: Any param, no justification - print(value) - - -def load_config(path: str) -> Any: # returns_compatibility: Any return - return {} - - -# ── assignment_compatibility: exit code assigned a string, verbosity a float ──────────────── -EXIT_SUCCESS: int = "0" # assignment_compatibility: str assigned to int -EXIT_FAILURE: int = "1" # assignment_compatibility: str assigned to int -DEFAULT_VERBOSITY: int = 1.5 # assignment_compatibility: float assigned to int - - -# ── classes_override_2: subcommand narrows timeout type incompatibly ────────────────── -class Command: - name: str - timeout: int - retryable: bool - - -class NetworkCommand(Command): - timeout: float = 30.0 # classes_override_2: float overrides int - retryable: str = "yes" # classes_override_2: str overrides bool - - -# ── names_undefined: reference before module-level assignment ────────────────────── -def get_version_string() -> str: - return f"v{VERSION}" # names_undefined: VERSION not yet defined - - -VERSION: str = "1.0.0" - - -# ── names_unbound: output path only bound inside a branch ──────────────────────── -def resolve_output(flags: dict[str, str], default: bool) -> str: - if "output" in flags: - out_path = flags["output"] - elif default: - out_path = "/tmp/out.txt" - # no else — out_path unbound if neither condition holds - return out_path # names_unbound: out_path may be unbound - - -# ── overloads_consistency: unannotated params make overloads identical ─────────────────── -@overload -def coerce_value(raw, kind) -> int: ... # BSK-0001: raw, kind untyped - - -@overload -def coerce_value(raw, kind) -> int: ... # BSK-0001 + overloads_consistency: duplicate - - -def coerce_value(raw: str, kind: str) -> int: - return int(raw) - - -# ── dict_key_hashable: list literal as a dict key (command alias map) ───────────────── -def default_aliases() -> dict[list[str], str]: - return {["help", "h", "?"]: "help"} # dict_key_hashable: list literal as key - - -# ── match_exhaustiveness: non-exhaustive match on log level ───────────────────────────── -def emit_log(level: str, msg: str) -> None: - match level: - case "info": - print(f"[INFO] {msg}") - case "warn": - print(f"[WARN] {msg}", file=sys.stderr) - case "error": - print(f"[ERROR] {msg}", file=sys.stderr) - # match_exhaustiveness: no wildcard — "debug", "trace" etc. are silently dropped - - -# ── BSK-0025: override missing @override decorator ───────────────────────── -class BaseFormatter: - def format(self, record: dict[str, str]) -> str: - return str(record) - - -class JsonFormatter(BaseFormatter): - indent: int = 2 - - def format(self, record: dict[str, str]) -> str: # BSK-0025: no @override - import json - - return json.dumps(record, indent=self.indent) diff --git a/examples/cpu_demo.py b/examples/cpu_demo.py deleted file mode 100644 index 6343825a5..000000000 --- a/examples/cpu_demo.py +++ /dev/null @@ -1,76 +0,0 @@ -"""Basilisk CPU Profiling Demo — open this file and start a profiling session. - -Launch it under the Basilisk debugger (F5), then attach the CPU profiler to -the live session. The workload below is deliberately lopsided so the flame -chart, bottom-up table, and inline hot-line hints all have something to show: - - hot_primes() -> dominates self-time (trial division, no sieve) - warm_strings() -> moderate self-time (quadratic string concat) - cold_io() -> almost all wall time in sleep, near-zero CPU - -Things to look for once the .cpuprofile opens: - * `is_prime` should be the heaviest leaf in the bottom-up view. - * `fib_recursive` shows a deep, self-similar flame (exponential recursion). - * `cold_io` barely appears — sampling profilers don't bill time spent asleep. -""" - -import time - - -def is_prime(candidate: int) -> bool: - """Deliberately naive primality test — the CPU hot spot of this demo.""" - if candidate < 2: - return False - divisor = 2 - while divisor * divisor <= candidate: # Hot line: most samples land here. - if candidate % divisor == 0: - return False - divisor += 1 - return True - - -def hot_primes(limit: int) -> list[int]: - """Collect primes below `limit` the slow way to burn CPU on one function.""" - return [n for n in range(limit) if is_prime(n)] - - -def fib_recursive(n: int) -> int: - """Exponential recursion — produces a tall, self-similar flame graph.""" - if n < 2: - return n - return fib_recursive(n - 1) + fib_recursive(n - 2) - - -def warm_strings(rows: int) -> str: - """Quadratic string building — moderate, steady self-time.""" - report = "" - for index in range(rows): - report += f"row {index}: {'#' * (index % 40)}\n" # Reallocates each pass. - return report - - -def cold_io(rounds: int) -> int: - """Mostly sleeping — shows how little CPU blocked I/O actually costs.""" - total = 0 - for _ in range(rounds): - time.sleep(0.05) # Wall time burns here, but the CPU profile stays flat. - total += 1 - return total - - -def main() -> None: - # Run a few rounds so the sampler accumulates a clear, stable picture. - for round_number in range(5): - primes = hot_primes(60_000) - digest = fib_recursive(30) - report = warm_strings(4_000) - idle = cold_io(4) - print( - f"round {round_number}: " - f"{len(primes)} primes, fib={digest}, " - f"{len(report)} report chars, {idle} idle ticks" - ) - - -if __name__ == "__main__": - main() diff --git a/examples/cpu_demo_loop.py b/examples/cpu_demo_loop.py deleted file mode 100644 index e194f2e0a..000000000 --- a/examples/cpu_demo_loop.py +++ /dev/null @@ -1,86 +0,0 @@ -"""Basilisk CPU Profiling Demo (long-running) — start it, then attach the profiler. - -Same lopsided workload as `cpu_demo.py`, but it never stops on its own: it runs -the workload in an endless loop so you can launch it (F5 under the Basilisk -debugger), let it warm up, then attach the CPU profiler to the live session and -watch samples accumulate in real time. - - hot_primes() -> dominates self-time (trial division, no sieve) - warm_strings() -> moderate self-time (quadratic string concat) - cold_io() -> almost all wall time in sleep, near-zero CPU - -Stop it with Ctrl-C (or by detaching/stopping the debug session) when you're -done collecting samples. - -Things to look for once the .cpuprofile opens: - * `is_prime` should be the heaviest leaf in the bottom-up view. - * `fib_recursive` shows a deep, self-similar flame (exponential recursion). - * `cold_io` barely appears — sampling profilers don't bill time spent asleep. -""" - -import time - - -def is_prime(candidate: int) -> bool: - """Deliberately naive primality test — the CPU hot spot of this demo.""" - if candidate < 2: - return False - divisor = 2 - while divisor * divisor <= candidate: # Hot line: most samples land here. - if candidate % divisor == 0: - return False - divisor += 1 - return True - - -def hot_primes(limit: int) -> list[int]: - """Collect primes below `limit` the slow way to burn CPU on one function.""" - return [n for n in range(limit) if is_prime(n)] - - -def fib_recursive(n: int) -> int: - """Exponential recursion — produces a tall, self-similar flame graph.""" - if n < 2: - return n - return fib_recursive(n - 1) + fib_recursive(n - 2) - - -def warm_strings(rows: int) -> str: - """Quadratic string building — moderate, steady self-time.""" - report = "" - for index in range(rows): - report += f"row {index}: {'#' * (index % 40)}\n" # Reallocates each pass. - return report - - -def cold_io(rounds: int) -> int: - """Mostly sleeping — shows how little CPU blocked I/O actually costs.""" - total = 0 - for _ in range(rounds): - time.sleep(0.05) # Wall time burns here, but the CPU profile stays flat. - total += 1 - return total - - -def main() -> None: - # Loop forever so the sampler can be attached at any time and keep filling. - # Press Ctrl-C (or stop the debug session) to exit. - round_number = 0 - while True: - primes = hot_primes(60_000) - digest = fib_recursive(30) - report = warm_strings(4_000) - idle = cold_io(4) - print( - f"round {round_number}: " - f"{len(primes)} primes, fib={digest}, " - f"{len(report)} report chars, {idle} idle ticks" - ) - round_number += 1 - - -if __name__ == "__main__": - try: - main() - except KeyboardInterrupt: - print("\nstopped") diff --git a/examples/data_pipeline.py b/examples/data_pipeline.py deleted file mode 100644 index 45b47b22d..000000000 --- a/examples/data_pipeline.py +++ /dev/null @@ -1,103 +0,0 @@ -""" -ETL data pipeline — realistic data engineering code with type violations. - -Run: basilisk check examples/data_pipeline.py -""" - -from __future__ import annotations - -from typing import Any, overload - - -# ── BSK-0003: unannotated empty collections at module scope ───────────────── -_schema_cache = {} # BSK-0003: empty dict -_transform_registry = [] # BSK-0003: empty list - - -# ── BSK-0001/0002: untyped ETL stage functions ───────────────────────────── -def extract(source, options): # BSK-0001: source, options untyped - records = source.read_all() - return records # BSK-0002: no return type - - -def transform(records, schema, strict): # BSK-0001: three untyped params - result = [] - for row in records: - result.append(row) - return result # BSK-0002: no return type - - -def load(records, destination): # BSK-0001: records, destination untyped - destination.write(records) - # implicit return None — no annotation # BSK-0002: no return type - - -# ── returns_compatibility: Any annotation without justification ────────────────────────── -def coerce_field(value: Any, target_type: Any) -> Any: # returns_compatibility ×3 - return target_type(value) - - -# ── assignment_compatibility: type-incompatible constant assignments ──────────────────────── -BATCH_SIZE: int = "1000" # assignment_compatibility: str, not int -NULL_SENTINEL: float = "NaN" # assignment_compatibility: str, not float -MAX_ERRORS: int = 0.5 # assignment_compatibility: float, not int - - -# ── classes_override_2: subclass narrows column type incompatibly ───────────────────── -class Column: - name: str - dtype: str - nullable: bool - - -class PartitionKey(Column): - nullable: int = 0 # classes_override_2: int overrides bool - - -# ── names_undefined: name used before any assignment in the module ───────────────── -def validate_schema(name: str) -> bool: - return name in _known_types # names_undefined: _known_types undefined - - -_known_types: set[str] = {"int", "str", "float", "bool"} - - -# ── names_unbound: conditionally assigned variable returned unconditionally ─────── -def detect_encoding(raw_bytes: bytes) -> str: - if raw_bytes[:3] == b"\xef\xbb\xbf": - encoding = "utf-8-sig" - elif raw_bytes[:2] in (b"\xff\xfe", b"\xfe\xff"): - encoding = "utf-16" - # no else branch — encoding may be unbound if no BOM matches - return encoding # names_unbound: encoding may be unbound - - -# ── overloads_consistency: unannotated overload params produce a duplicate ─────────────── -@overload -def read_source(path) -> list[dict[str, str]]: ... # BSK-0001: path untyped - - -@overload -def read_source( - path, -) -> list[dict[str, str]]: ... # BSK-0001 + overloads_consistency: duplicate - - -def read_source(path: str) -> list[dict[str, str]]: - return [] - - -# ── dict_key_hashable: list literal used as a dict key ─────────────────────────────── -def empty_schema() -> dict[list[str], str]: # unhashable key type in annotation - return {["a", "b"]: "string"} # dict_key_hashable: list literal as key - - -# ── BSK-0025: override not decorated ─────────────────────────────────────── -class BaseWriter: - def flush(self, data: list[bytes]) -> int: - return len(data) - - -class ParquetWriter(BaseWriter): - def flush(self, data: list[bytes]) -> int: # BSK-0025: missing @override - return len(data) * 2 diff --git a/examples/debug_demo.py b/examples/debug_demo.py deleted file mode 100644 index 10dd6ce04..000000000 --- a/examples/debug_demo.py +++ /dev/null @@ -1,54 +0,0 @@ -"""Basilisk Debug Demo — open this file and press F5 to try the debugger. - -Set breakpoints on any line, then step through to see variables in the -Watch panel, Locals scope, and Debug Console. -""" - - -def fibonacci(n: int) -> list[int]: - """Generate the first n Fibonacci numbers.""" - seq: list[int] = [] - a, b = 0, 1 - for _ in range(n): - seq.append(a) # Set a breakpoint here to watch the sequence grow - a, b = b, a + b - return seq - - -def classify_numbers(numbers: list[int]) -> dict[str, list[int]]: - """Split a list into evens and odds.""" - result: dict[str, list[int]] = {"even": [], "odd": []} - for num in numbers: - if num % 2 == 0: - result["even"].append(num) - else: - result["odd"].append(num) - return result - - -def main() -> None: - # Step through these lines and inspect variables in the Watch panel. - name = "Basilisk" - version = "0.1.0" - greeting = f"Welcome to {name} v{version} debugger demo!" - print(greeting) - - # Watch `fib` grow as you step through fibonacci(). - fib = fibonacci(10) - print(f"Fibonacci(10): {fib}") - - # Inspect the classified dict in the Variables pane. - classified = classify_numbers(fib) - print(f"Even: {classified['even']}") - print(f"Odd: {classified['odd']}") - - # Try evaluating these in the Debug Console (REPL): - # sum(fib) - # len(classified["even"]) - # [x ** 2 for x in fib] - total = sum(fib) - print(f"Sum: {total}") - - -if __name__ == "__main__": - main() diff --git a/examples/exception_demo.py b/examples/exception_demo.py deleted file mode 100644 index 9861375cc..000000000 --- a/examples/exception_demo.py +++ /dev/null @@ -1,59 +0,0 @@ -"""Basilisk Exception Demo — set breakpoints inside except blocks to inspect exceptions. - -Press F5 with this file open. Put breakpoints on the `print(...)` lines -inside each `except` block, then check the Variables pane for `exc`. -""" - - -class ValidationError(Exception): - """Custom exception with extra attributes.""" - - def __init__(self, field: str, message: str) -> None: - super().__init__(f"{field}: {message}") - self.field = field - self.message = message - - -def main() -> None: - # 1. KeyError — inspect exc, exc.args - try: - data: dict[str, int] = {"a": 1, "b": 2} - _value = data["missing_key"] - except KeyError as exc: - print(f"Caught KeyError: {exc}") # breakpoint here - - # 2. ZeroDivisionError — inspect exc.args[0] - try: - _result = 100 / 0 - except ZeroDivisionError as exc: - print(f"Caught ZeroDivisionError: {exc}") # breakpoint here - - # 3. IndexError — inspect type(exc), exc.args - try: - numbers: list[int] = [10, 20, 30] - _bad = numbers[99] - except IndexError as exc: - print(f"Caught IndexError: {exc}") # breakpoint here - - # 4. ValueError with chained exception — inspect exc.__cause__ - try: - try: - int("not_a_number") - except ValueError as original: - raise ValueError("Failed to parse config") from original - except ValueError as exc: - print(f"Caught chained ValueError: {exc}") # breakpoint here - print(f" Original cause: {exc.__cause__}") - - # 5. Custom exception — inspect exc.field, exc.message - try: - raise ValidationError("age", "must be >= 0") - except ValidationError as exc: - print(f"Caught ValidationError: {exc}") # breakpoint here - print(f" field={exc.field}, message={exc.message}") - - print("\nAll exceptions handled successfully.") - - -if __name__ == "__main__": - main() diff --git a/examples/finance.py b/examples/finance.py deleted file mode 100644 index 6803a5b17..000000000 --- a/examples/finance.py +++ /dev/null @@ -1,126 +0,0 @@ -""" -Financial calculations — realistic fintech code with type violations. - -The violations here are subtle: wrong numeric types, shadowed names, -conditional assignments in risk functions that may never bind. - -Run: basilisk check examples/finance.py -""" - -from __future__ import annotations - -from typing import Any, overload - - -# ── BSK-0003: empty portfolio and ledger ──────────────────────────────────── -_open_positions = {} # BSK-0003: empty dict, no annotation -_trade_log = [] # BSK-0003: empty list, no annotation - - -# ── BSK-0001/0002: core pricing functions missing all annotations ─────────── -def black_scholes(S, K, T, r, sigma): # BSK-0001: five untyped params - """Call option price — classic formula.""" - import math - - d1 = (math.log(S / K) + (r + 0.5 * sigma**2) * T) / (sigma * math.sqrt(T)) - _d2 = d1 - sigma * math.sqrt(T) - # omit N(d1)/N(d2) for brevity - return S - K * math.exp(-r * T) # BSK-0002: no return type - - -def present_value(cash_flows, discount_rate): # BSK-0001: untyped params - total = 0.0 - for i, cf in enumerate(cash_flows): - total += cf / (1 + discount_rate) ** i - return total # BSK-0002: no return type - - -def kelly_criterion(win_prob, win_amount, loss_amount): # BSK-0001 - edge = win_prob * win_amount - (1 - win_prob) * loss_amount - return edge / win_amount # BSK-0002: no return type - - -# ── returns_compatibility: Any type with no justification ───────────────────────────────── -def execute_order(order: Any) -> Any: # returns_compatibility ×2 - return order - - -# ── assignment_compatibility: currency constant assigned wrong type ───────────────────────── -BASE_CURRENCY: str = 42 # assignment_compatibility: int assigned to str -RISK_FREE_RATE: float = "0.05" # assignment_compatibility: str assigned to float -MAX_POSITION_SIZE: int = 1_000_000.0 # assignment_compatibility: float assigned to int - - -# ── classes_override_2: subclass changes field type in class hierarchy ──────────────── -class Instrument: - ticker: str - notional: float - is_derivative: bool - - -class Future(Instrument): - notional: int = 0 # classes_override_2: int overrides float - - -class Option(Instrument): - is_derivative: str = "yes" # classes_override_2: str overrides bool - - -# ── names_undefined: forward reference to name assigned later ────────────────────── -def get_benchmark() -> str: - return BENCHMARK_INDEX # names_undefined: referenced before assignment - - -BENCHMARK_INDEX: str = "SP500" - - -# ── names_unbound: VaR only assigned inside the risk branch ────────────────────── -def compute_portfolio_risk( - returns: list[float], confidence: float, stressed: bool -) -> float: - if stressed: - sorted_returns = sorted(returns) - cutoff = int(len(sorted_returns) * (1 - confidence)) - var = abs(sorted_returns[cutoff]) - return var # names_unbound: var may be unbound - - -# ── overloads_consistency: unannotated params make overloads identical ─────────────────── -@overload -def round_to_tick(price, tick) -> float: ... # BSK-0001: price, tick untyped - - -@overload -def round_to_tick( - price, tick -) -> float: ... # BSK-0001 + overloads_consistency: duplicate - - -def round_to_tick(price: float, tick: int) -> float: - return round(price / tick) * tick - - -# ── dict_key_hashable: list literal used as a dict key in a position record ─────────── -def empty_book() -> dict[list[str], float]: - return {["AAPL", "MSFT"]: 0.0} # dict_key_hashable: list literal as key - - -# ── match_exhaustiveness: non-exhaustive match on order side ──────────────────────────── -def apply_slippage(side: str, price: float, bps: float) -> float: - match side: - case "buy": - return price * (1 + bps / 10_000) - case "sell": - return price * (1 - bps / 10_000) - # match_exhaustiveness: no wildcard — "short", "cover", etc. fall through - - -# ── BSK-0025: settlement override missing @override ──────────────────────── -class BaseSettlement: - def settle(self, amount: float, currency: str) -> str: - return f"{amount} {currency}" - - -class T2Settlement(BaseSettlement): - def settle(self, amount: float, currency: str) -> str: # BSK-0025 - return f"T+2: {amount} {currency}" diff --git a/examples/good.py b/examples/good.py deleted file mode 100644 index 8cf702e74..000000000 --- a/examples/good.py +++ /dev/null @@ -1,59 +0,0 @@ -# The fixed counterpart of `bad.py` — every diagnostic addressed, including -# the opt-in strictness rules. Passes Basilisk cleanly at full strictness. -# Run: basilisk check examples/good.py - -from typing import override - - -def greet(name: str) -> str: - return "Hello, " + name - - -def get_score() -> int: - return 42 - - -def add(x: int, y: int) -> int: - return x + y - - -class Shape: - def area(self, scale: float) -> float: - return scale - - -class Circle(Shape): - @override - def area(self, scale: float) -> float: - return scale * 3.14 - - -def describe(flag: bool) -> str: - return "on" if flag else "off" - - -def classify(value: int | str) -> str: - match value: - case int(): - return "number" - case _: - return "text" - - -def process(data: str) -> str: - return data.upper() - - -def log_all(*args: str, **kwargs: int) -> None: - pass - - -def main() -> None: - print(greet("world")) - print(add(get_score(), 2)) - print(describe(flag=True)) - print(classify("basilisk")) - - -if __name__ == "__main__": - main() diff --git a/examples/heap_demo.py b/examples/heap_demo.py deleted file mode 100644 index 2c4aa520b..000000000 --- a/examples/heap_demo.py +++ /dev/null @@ -1,140 +0,0 @@ -"""Basilisk Heap-Profile Demo — open this file and click -"Run & Track Memory (Current File)" in the Python Processes panel. - -A deliberately *chunky* memory workload: it builds an in-memory analytics cache -of tens of megabytes across many distinct allocation sites, then keeps all of it -alive in the module-global WAREHOUSE until the program exits. The run needs no -breakpoint — Basilisk starts tracemalloc at the entry pause, runs to completion, -and captures a final snapshot as the program exits ([PROFILE-MEMORY-FINAL]). - -Why this makes a good `.heapprofile`: Basilisk filters the debugger's own -allocations out and keeps each allocation's full call stack, so the viewer shows -a real call tree of *your* code — `warm_cache` branching into each builder below, -down to the line that allocated. With big buffers, medium columns, and a long -tail of small structures, the flame chart and the Self-Size table fill with real, -varied entries instead of a single dominant bar: - - allocate_frame_buffers -> a few large contiguous bytearrays (the wide bars) - build_*_series -> medium lists of numbers (the mid bars) - build_inverted_index -> thousands of small strings + posting lists (tail) - build_session_records -> objects with per-record blobs - build_adjacency_graph -> nested dict/list structure - -What to look for once the snapshot opens: - * The 8 MiB / 16 MiB buffers dominate the flame chart's widest slices. - * Each `build_*` line shows up separately in the bottom-up (Self-Size) table. - * Peak vs current: only WAREHOUSE survives, so the final total is what's live. -""" - -from __future__ import annotations - -# Everything is retained here, so the at-exit snapshot attributes each megabyte -# to the line that allocated it. Nothing is ever evicted. -WAREHOUSE: dict[str, object] = {} - -# ── Large contiguous buffers (the wide flame-chart bars) ──────────────────── - - -def allocate_frame_buffers() -> dict[str, bytearray]: - """A render cache of differently-sized buffers — one big slice per line.""" - return { - "rgba_canvas": bytearray(8 * 1024 * 1024), # 8 MiB — the widest bar. - "depth_buffer": bytearray(4 * 1024 * 1024), # 4 MiB. - "shadow_map": bytearray(2 * 1024 * 1024), # 2 MiB. - "lightmap": bytearray(1 * 1024 * 1024), # 1 MiB. - } - - -def allocate_embedding_matrix(vectors: int, dimensions: int) -> bytearray: - """A flat float32 matrix as raw bytes — one large, clean allocation.""" - return bytearray(vectors * dimensions * 4) # 4 bytes per float32 cell. - - -# ── Medium columnar series (the mid bars) ─────────────────────────────────── - - -def build_price_series(rows: int) -> list[float]: - """Distinct float objects (not interned) — a real per-line allocation.""" - return [float(index) * 1.5 + 0.25 for index in range(rows)] - - -def build_volume_series(rows: int) -> list[int]: - """Big ints (above the small-int cache) — each one really allocated.""" - return [index * index + 9_999_999 for index in range(rows)] - - -def build_label_series(rows: int) -> list[str]: - """Many short, distinct strings — a fat slice of small allocations.""" - return [f"row-{index:07d}" for index in range(rows)] - - -# ── Long tail of small structures (fills the bottom-up table) ─────────────── - - -def synth_terms(doc_id: int, terms_per_doc: int) -> list[str]: - """Synthesize a document's tokens — distinct interned-busting strings.""" - return [ - f"t{(doc_id * 131 + position) % 4096:04x}" for position in range(terms_per_doc) - ] - - -def build_inverted_index(documents: int, terms_per_doc: int) -> dict[str, list[int]]: - """token -> posting list. Thousands of tiny strings and lists.""" - index: dict[str, list[int]] = {} - for doc_id in range(documents): - for term in synth_terms(doc_id, terms_per_doc): - index.setdefault(term, []).append(doc_id) - return index - - -class SessionRecord: - """A per-session object carrying its own payload — objects with __dict__.""" - - def __init__(self, session_id: int) -> None: - self.session_id = session_id - self.token = f"sess-{session_id:08x}" - self.payload = bytes(8 * 1024) # 8 KiB blob retained per record. - - -def build_session_records(count: int) -> list[SessionRecord]: - """A list of objects, each holding an 8 KiB blob — a chunky mid slice.""" - return [SessionRecord(session_id) for session_id in range(count)] - - -def build_adjacency_graph(nodes: int, fan_out: int) -> dict[int, list[int]]: - """node -> neighbours. A nested dict/list structure with many small lists.""" - return { - node: [(node * 2_654_435_761 + step) % nodes for step in range(fan_out)] - for node in range(nodes) - } - - -# ── Orchestration ─────────────────────────────────────────────────────────── - - -def warm_cache() -> None: - """Build every subsystem and retain it, so the heap fills up for real.""" - WAREHOUSE["frame_buffers"] = allocate_frame_buffers() - WAREHOUSE["embeddings"] = allocate_embedding_matrix(vectors=65_536, dimensions=64) - WAREHOUSE["price"] = build_price_series(120_000) - WAREHOUSE["volume"] = build_volume_series(120_000) - WAREHOUSE["labels"] = build_label_series(120_000) - WAREHOUSE["index"] = build_inverted_index(documents=4_000, terms_per_doc=48) - WAREHOUSE["sessions"] = build_session_records(2_000) - WAREHOUSE["graph"] = build_adjacency_graph(nodes=8_000, fan_out=6) - - -def describe() -> str: - """A one-line summary so the run prints something on completion.""" - parts = [f"{name}={type(value).__name__}" for name, value in WAREHOUSE.items()] - return "warehouse: " + ", ".join(parts) - - -def main() -> None: - warm_cache() - print(describe()) - print(f"Loaded {len(WAREHOUSE)} retained subsystems — heap is warm for profiling.") - - -if __name__ == "__main__": - main() diff --git a/examples/memory_demo.py b/examples/memory_demo.py deleted file mode 100644 index 0a97b4e85..000000000 --- a/examples/memory_demo.py +++ /dev/null @@ -1,83 +0,0 @@ -"""Basilisk Memory Profiling Demo — open this file and click -"Run & Track Memory (Current File)" in the Python Processes panel. - -No breakpoint required: Basilisk starts tracemalloc at the entry pause, runs the -program to completion, and captures a final memory snapshot as it exits — so the -run ends in a viewable result (the V8 `.heapprofile` plus the purple allocation -heat map on the hot line), never a dead end. - -The workload exercises every signal the memory profiler reports: - - leak_cache -> retained forever in a module-global list (the leak) - transient_spike -> a big allocation that is freed before the program ends - make_cycle -> reference cycle with __del__, only the GC can reclaim - -What to look for once the final snapshot opens: - * `leak_cache`'s `bytes(512 * 1024)` line dominates — ~3 MiB still retained. - * `transient_spike` shows up in PEAK memory but not in the final total. - * The Node cycle survives until `gc.collect()` runs at the very end. - -Prefer the interactive flow? Set a breakpoint on the marked line inside `main`'s -loop, launch under the debugger (F5), and take a snapshot each pass — the diff -between snapshots is where leak confidence escalates. -""" - -import gc - - -# Module-level store that is never cleared — the classic accidental leak. -_LEAK: list[bytes] = [] - - -class Node: - """A graph node that points back at its owner, forming a reference cycle.""" - - def __init__(self, label: str) -> None: - self.label = label - self.peer: Node | None = None # Set to another Node to close the cycle. - self.payload = bytearray(256 * 1024) # Real bytes so the leak is visible. - - def __del__(self) -> None: - # __del__ on a cycle historically blocked collection — a leak smell. - print(f"collected node {self.label}") - - -def leak_cache(round_number: int) -> int: - """Append to a module-global list that nothing ever frees.""" - chunk = bytes(512 * 1024) # 512 KiB retained forever, one per round. - _LEAK.append(chunk) - return len(_LEAK) - - -def transient_spike() -> int: - """Allocate a large buffer and drop it — peak rises, baseline does not.""" - scratch = [bytearray(1024 * 1024) for _ in range(8)] # ~8 MiB, short-lived. - total = sum(len(buffer) for buffer in scratch) - return total # `scratch` dies here; the next snapshot won't see it. - - -def make_cycle(label: str) -> None: - """Build a two-node cycle that escapes reference counting.""" - left = Node(f"{label}-left") - right = Node(f"{label}-right") - left.peer = right - right.peer = left # Now neither node's refcount can ever reach zero. - # Both go out of scope here, but the cycle keeps them alive until gc runs. - - -def main() -> None: - for round_number in range(6): - retained = leak_cache(round_number) # Breakpoint here: snapshot each pass. - spike = transient_spike() - make_cycle(f"round{round_number}") - print( - f"round {round_number}: {retained} leaked chunks, {spike} transient bytes" - ) - - # Force a collection so the cycle's __del__ output appears at the end. - unreachable = gc.collect() - print(f"gc reclaimed {unreachable} objects") - - -if __name__ == "__main__": - main() diff --git a/examples/mixed.py b/examples/mixed.py deleted file mode 100644 index f109dd0c6..000000000 --- a/examples/mixed.py +++ /dev/null @@ -1,37 +0,0 @@ -# A realistic file with a mix of typed and untyped code. -# Run: basilisk check examples/mixed.py -# -# The genuine type error is an error out of the box. The untyped parts only -# surface once the opt-in strictness rules are enabled — this repository -# enables them for `examples/**` as warnings in the root `pyproject.toml`. - -from typing import Optional - - -def fetch_user(user_id: int) -> Optional[str]: - # pretend DB lookup - return None - - -def save_record(data): # BSK-0001: data untyped - pass # BSK-0002: no return type - - -class Config: - debug: bool - timeout: int - - def __init__(self, debug: bool, timeout: int) -> None: - self.debug = debug - self.timeout = timeout - - def reset(self): # BSK-0002: no return type - self.debug = False - self.timeout = 30 - - -def compute(x: int, y: int) -> int: - return x * y - - -compute(2, "three") # error[calls_argument_type]: `y` expects `int`, got a `str` diff --git a/examples/ml_trainer.py b/examples/ml_trainer.py deleted file mode 100644 index 12e2ee5d0..000000000 --- a/examples/ml_trainer.py +++ /dev/null @@ -1,118 +0,0 @@ -""" -Machine-learning training loop — realistic ML code with type violations. - -Run: basilisk check examples/ml_trainer.py -""" - -from __future__ import annotations - -from typing import Any, overload - - -# ── BSK-0003: unannotated empty collections ───────────────────────────────── -_metric_history = [] # BSK-0003: empty list, type unknown -_checkpoint_index = {} # BSK-0003: empty dict, type unknown - - -# ── BSK-0001/0002: untyped training functions ────────────────────────────── -def forward_pass(model, batch, device): # BSK-0001: three untyped params - inputs, labels = batch - logits = model(inputs.to(device)) - return logits # BSK-0002: no return type - - -def compute_loss(logits, labels, weights): # BSK-0001: three untyped params - loss = ((logits - labels) ** 2).mean() - return loss # BSK-0002: no return type - - -def backward_and_step(loss, optimizer): # BSK-0001: two untyped params - loss.backward() - optimizer.step() - optimizer.zero_grad() # BSK-0002: no return type - - -# ── returns_compatibility: Any used in public interfaces without justification ──────────── -def load_checkpoint(path: str) -> Any: # returns_compatibility: Any return, no comment - pass - - -def apply_augmentation(sample: Any, config: Any) -> Any: # returns_compatibility ×3 - return sample - - -# ── assignment_compatibility: float hyperparameter assigned a string ──────────────────────── -LEARNING_RATE: float = "1e-3" # assignment_compatibility: str assigned to float -NUM_EPOCHS: int = 10.0 # assignment_compatibility: float assigned to int -DROPOUT: float = "0.5" # assignment_compatibility: str assigned to float - - -# ── classes_override_2: subclass changes metric type ─────────────────────────────────── -class Metric: - name: str - value: float - higher_is_better: bool - - -class LossMetric(Metric): - higher_is_better: str = "no" # classes_override_2: str overrides bool - - -# ── names_undefined: reference to name not yet defined ───────────────────────────── -def get_default_optimizer() -> str: - return DEFAULT_OPTIMIZER # names_undefined: not yet assigned - - -DEFAULT_OPTIMIZER: str = "adam" - - -# ── names_unbound: epoch stats built conditionally, returned unconditionally ────── -def run_epoch(data: list[dict[str, float]], validate: bool) -> dict[str, float]: - if validate: - val_loss = sum(r["loss"] for r in data) / len(data) - return {"val_loss": val_loss} # names_unbound: val_loss may be unbound - - -# ── overloads_consistency: unannotated params make overloads identical ─────────────────── -@overload -def decode_predictions(raw) -> list[int]: ... # BSK-0001: raw untyped - - -@overload -def decode_predictions( - raw, -) -> list[int]: ... # BSK-0001 + overloads_consistency: duplicate - - -def decode_predictions(raw: list[float]) -> list[int]: - return [round(x) for x in raw] - - -# ── dict_key_hashable: list literal as dict key ────────────────────────────────────── -def make_layer_index() -> dict[list[str], int]: - return {["conv1", "conv2"]: 0} # dict_key_hashable: list literal as key - - -# ── match_exhaustiveness: non-exhaustive match on optimizer name ──────────────────────── -def build_optimizer(name: str, lr: float) -> str: - match name: - case "adam": - return f"Adam(lr={lr})" - case "sgd": - return f"SGD(lr={lr})" - # match_exhaustiveness: no wildcard branch — other values fall through silently - - -# ── BSK-0025: override without @override ──────────────────────────────────── -class BaseCallback: - def on_epoch_end(self, epoch: int, metrics: dict[str, float]) -> None: - pass - - -class EarlyStoppingCallback(BaseCallback): - patience: int = 5 - - def on_epoch_end( # BSK-0025: missing @override - self, epoch: int, metrics: dict[str, float] - ) -> None: - pass diff --git a/examples/profile_demo.py b/examples/profile_demo.py deleted file mode 100644 index f9f75def3..000000000 --- a/examples/profile_demo.py +++ /dev/null @@ -1,74 +0,0 @@ -"""Basilisk CPU Profiling Demo — open this file and click -"Run & Profile CPU (Current File)" in the Python Processes panel. - -Unlike debug_demo.py — which finishes in about a millisecond, far too fast for a -sampling profiler to catch — this program runs a CPU-bound workload for a few -seconds. That gives the sampler enough snapshots to build a real flame chart, -bottom-up table, and inline hot-line heat map. - -The work is deliberately lopsided so the profile points straight at the -bottleneck: - - sum_of_squares() -> the hot spot: a tight arithmetic loop - count_primes() -> moderate: naive trial-division primality - format_round() -> light: a little string formatting - -What to look for once the profile opens: - * sum_of_squares dominates the bottom-up (self-time) table. - * The `total += index * index` line wears the brightest heat-map color. - * count_primes is a clear but smaller slice; format_round barely registers. -""" - -import time - -# Run the workload for about this many seconds so the sampler builds a stable -# picture regardless of machine speed. A fixed iteration count would finish too -# fast on a quick machine (too few samples) and drag under the debugger's line -# tracing — a wall-clock deadline keeps the demo's profile meaningful either way. -PROFILE_SECONDS = 3.0 - - -def sum_of_squares(count: int) -> int: - """The hot spot: a tight arithmetic loop where most samples should land.""" - total = 0 - for index in range(count): - total += index * index # Hot line — the profiler paints this brightest. - return total - - -def count_primes(limit: int) -> int: - """Moderate cost: naive primality by trial division (no sieve).""" - found = 0 - for candidate in range(2, limit): - divisor = 2 - is_prime = True - while divisor * divisor <= candidate: - if candidate % divisor == 0: - is_prime = False - break - divisor += 1 - if is_prime: - found += 1 - return found - - -def format_round(round_number: int, squares: int, primes: int) -> str: - """Light cost: a little string formatting per round.""" - return f"round {round_number}: sum_of_squares={squares:,}, primes={primes}" - - -def main() -> None: - deadline = time.time() + PROFILE_SECONDS - round_number = 0 - summary = "" - while time.time() < deadline: - squares = sum_of_squares(100_000) - primes = count_primes(2_000) - summary = format_round(round_number, squares, primes) - round_number += 1 - print(summary) - print(f"Profiled {round_number} rounds over ~{PROFILE_SECONDS:.0f}s of CPU work.") - - -if __name__ == "__main__": - main() diff --git a/examples/pyproject.toml b/examples/pyproject.toml deleted file mode 100644 index b8ec93635..000000000 --- a/examples/pyproject.toml +++ /dev/null @@ -1,20 +0,0 @@ -# Folder-scoped Basilisk configuration for the examples ([CHKARCH-CONFIG-MODEL]). -# -# There is no per-path override table. Configuration is scoped by WHERE the -# file lives: `basilisk` walks up from each checked file and the nearest table -# that decides a rule wins outright. This file is that nearest table for -# everything under examples/, and the repository root's -# `pyproject.toml [tool.basilisk]` still decides every rule left undecided here -# (including the typeshed pin, which is a non-rule key and merges through). -# -# The examples model the incremental-adoption story the docs teach: PEP -# typing-spec violations stay errors, while Basilisk's opt-in strictness rules -# surface as warnings — "this type-checks, but strictness isn't at full yet". -# See website/src/docs/quick-start.md step 3. -[tool.basilisk.rules] -BSK-0001 = "warning" -BSK-0002 = "warning" -BSK-0003 = "warning" -BSK-0004 = "warning" -BSK-0005 = "warning" -BSK-0025 = "warning" diff --git a/examples/redundant_annotations.py b/examples/redundant_annotations.py deleted file mode 100644 index ea2f6cc25..000000000 --- a/examples/redundant_annotations.py +++ /dev/null @@ -1,713 +0,0 @@ -# ruff: noqa: E402, E731 -# Redundant type annotations — Basilisk infers these automatically. -# -# W0050 fires when the annotation adds no information beyond what inference provides. -# E0005 does NOT fire when a subclass overrides a parent's annotated attribute. -# E402/E731 are suppressed: imports are placed per-section intentionally, and -# lambda assignments are part of the test fixtures for annotation inference. - -# --------------------------------------------------------------------------- -# Module-level: scalar literals are always inferrable -# --------------------------------------------------------------------------- - -count: int = 42 # W0050 — obviously int -name: str = "hello" # W0050 — obviously str -rate: float = 3.14 # W0050 — obviously float -enabled: bool = True # W0050 — obviously bool -disabled: bool = False # W0050 — obviously bool -header: bytes = b"\x00\xff" # W0050 — obviously bytes -nothing: None = None # W0050 — obviously None - -# Edge cases: zero/empty values -zero: int = 0 # W0050 — still obviously int -empty: str = "" # W0050 — still obviously str -zero_f: float = 0.0 # W0050 — still obviously float - -# --------------------------------------------------------------------------- -# Module-level: annotations that ADD information (no W0050) -# --------------------------------------------------------------------------- - -widened: float = 42 # NO warning — int widened to float -items: list[int] = [1, 2, 3] # NO warning — collection type is useful -pairs: dict[str, int] = {"a": 1} # NO warning — collection type is useful -nums: set[int] = {1, 2, 3} # NO warning — collection type is useful -coords: tuple[int, int] = (1, 2) # NO warning — collection type is useful - - -# --------------------------------------------------------------------------- -# Class attributes: same rules apply -# --------------------------------------------------------------------------- - - -class Settings: - retries: int = 3 # W0050 — redundant - label: str = "default" # W0050 — redundant - threshold: float = 0.5 # W0050 — redundant - verbose: bool = True # W0050 — redundant - magic: bytes = b"\x00" # W0050 — redundant - nothing: None = None # W0050 — redundant - - -# --------------------------------------------------------------------------- -# Subclass overrides: inherited annotation satisfies E0005 -# --------------------------------------------------------------------------- - - -class BaseRoute: - path: str = "/" - method: str = "GET" - auth_required: bool = False - priority: int = 0 - timeout: float = 30.0 - - -class AuthenticatedRoute(BaseRoute): - auth_required = True # NO E0005 — inherits bool from BaseRoute - - -class AdminRoute(AuthenticatedRoute): - priority = 100 # NO E0005 — inherits int from BaseRoute (grandparent) - path = "/admin" # NO E0005 — inherits str from BaseRoute (grandparent) - - -class ApiRoute(AuthenticatedRoute): - method = "POST" # NO E0005 — inherits str from BaseRoute - timeout = 60.0 # NO E0005 — inherits float from BaseRoute - path = "/api" # NO E0005 — inherits str from BaseRoute - - -# --------------------------------------------------------------------------- -# Deep inheritance: annotation flows through the whole chain -# --------------------------------------------------------------------------- - - -class A: - tag: str = "a" - - -class B(A): - tag = "b" # NO E0005 — inherits from A - - -class C(B): - tag = "c" # NO E0005 — inherits from A through B - - -class D(C): - tag = "d" # NO E0005 — inherits from A through B -> C - - -# --------------------------------------------------------------------------- -# Multiple inheritance: annotation from ANY base suffices -# --------------------------------------------------------------------------- - - -class PriorityMixin: - priority: int = 0 - - -class Serializable: - pass - - -class PrioritizedItem(PriorityMixin, Serializable): - priority = 10 # NO E0005 — inherits from PriorityMixin - - -class WeightMixin: - weight: float = 1.0 - - -class WeightedItem(Serializable, WeightMixin): - weight = 5.0 # NO E0005 — inherits from WeightMixin (second base) - - -# --------------------------------------------------------------------------- -# Diamond inheritance: reachable through either path -# --------------------------------------------------------------------------- - - -class Root: - value: int = 0 - - -class Left(Root): - pass - - -class Right(Root): - pass - - -class Diamond(Left, Right): - value = 42 # NO E0005 — reachable through Left -> Root or Right -> Root - - -# --------------------------------------------------------------------------- -# Sibling classes independently overriding -# --------------------------------------------------------------------------- - - -class Animal: - sound = "..." - legs = 4 - - -class Dog(Animal): - sound = "woof" # NO E0005 - - -class Cat(Animal): - sound = "meow" # NO E0005 - - -class Snake(Animal): - legs = 0 # NO E0005 - sound = "hiss" # NO E0005 - - -# --------------------------------------------------------------------------- -# Annotation-only parent (no default): child still inherits the type -# --------------------------------------------------------------------------- - - -class AbstractHandler: - name: str - - -class ConcreteHandler(AbstractHandler): - name = "default" # NO E0005 — parent declared `name: str` - - -# --------------------------------------------------------------------------- -# Config pattern: production/staging overrides -# --------------------------------------------------------------------------- - - -class DatabaseConfig: - host = "localhost" - port = 5432 - pool_size = 10 - ssl = False - - -class ProductionDB(DatabaseConfig): - host = "db.prod.internal" - port = 5433 - ssl = True - pool_size = 50 - - -class StagingDB(DatabaseConfig): - host = "db.staging.internal" - pool_size = 5 - - -# --------------------------------------------------------------------------- -# Scalar literals in standalone classes — type is inferrable, NO E0005 -# --------------------------------------------------------------------------- - - -class Standalone: - value = 42 # NO E0005 — scalar literal, type is trivially `int` - - -class UnannotatedParent: - raw = 99 # NO E0005 — scalar literal, type is trivially `int` - - -class ChildOfUnannotated(UnannotatedParent): - raw = 100 # NO E0005 — scalar literal, type is trivially `int` - - -class UnrelatedToBaseRoute: - path = "/unrelated" # NO E0005 — scalar literal, type is trivially `str` - - -# --------------------------------------------------------------------------- -# Function parameters: annotation is required (no W0050 — params need types) -# --------------------------------------------------------------------------- - - -def greet(name: str, count: int = 1) -> str: # NO W0050 — params need annotations - return name * count - - -# --------------------------------------------------------------------------- -# Function return types: redundant when inferrable from body -# --------------------------------------------------------------------------- - - -def get_count() -> int: # W0050 — return type inferrable from `return 42` - return 42 - - -def get_name() -> str: # W0050 — return type inferrable from `return "hello"` - return "hello" - - -def get_flag() -> bool: # W0050 — return type inferrable from `return True` - return True - - -def get_rate() -> float: # W0050 — return type inferrable from `return 3.14` - return 3.14 - - -def get_data() -> bytes: # W0050 — return type inferrable from `return b"\x00"` - return b"\x00" - - -def get_nothing() -> None: # W0050 — return type inferrable from `return None` - return None - - -def implicit_none() -> None: # W0050 — no return statement implies None - pass - - -# --------------------------------------------------------------------------- -# Function return types that ADD information (no W0050) -# --------------------------------------------------------------------------- - - -def get_items() -> list[int]: # NO W0050 — collection type adds info - return [1, 2, 3] - - -def get_mapping() -> dict[str, int]: # NO W0050 — collection type adds info - return {"a": 1} - - -def widen_return() -> float: # NO W0050 — widening int to float - return 42 - - -def conditional_return(flag: bool) -> str: # NO W0050 — multiple return paths - if flag: - return "yes" - return "no" - - -# --------------------------------------------------------------------------- -# Local variables: redundant annotations -# --------------------------------------------------------------------------- - - -def local_scalars() -> None: - x: int = 10 # W0050 — obviously int - y: str = "world" # W0050 — obviously str - z: float = 2.71 # W0050 — obviously float - flag: bool = False # W0050 — obviously bool - raw: bytes = b"\xff" # W0050 — obviously bytes - nope: None = None # W0050 — obviously None - _ = (x, y, z, flag, raw, nope) - - -def local_non_redundant() -> None: - items: list[int] = [1, 2] # NO W0050 — collection type adds info - widened: float = 0 # NO W0050 — int widened to float - mapping: dict[str, bool] = {} # NO W0050 — empty collection needs type - _ = (items, widened, mapping) - - -# --------------------------------------------------------------------------- -# For-loop variables: redundant annotations -# --------------------------------------------------------------------------- - - -def loop_annotations() -> None: - total: int = 0 # W0050 — obviously int - for i in range(10): - total += i - _ = total - - -# --------------------------------------------------------------------------- -# Comprehension targets captured into annotated variables -# --------------------------------------------------------------------------- - - -def comprehension_annotations() -> None: - squares: list[int] = [x * x for x in range(5)] # NO W0050 — list[int] adds info - names: list[str] = [s.upper() for s in ["a", "b"]] # NO W0050 — list[str] adds info - _ = (squares, names) - - -# --------------------------------------------------------------------------- -# Lambda: return annotation not possible, but assignment annotation -# --------------------------------------------------------------------------- - -double = 2 # NO E0003 — scalar literal, type is trivially `int` -fn = lambda x: x * 2 # NO E0003 — not an unresolvable expression - - -# --------------------------------------------------------------------------- -# Property: redundant return annotations -# --------------------------------------------------------------------------- - - -class Circle: - def __init__(self, radius: float) -> None: # W0050 — __init__ always returns None - self._radius = radius - - @property - def radius(self) -> float: # NO W0050 — property return types are documentation - return self._radius - - @property - def area(self) -> float: # NO W0050 — computed, annotation documents interface - return 3.14159 * self._radius**2 - - @property - def name(self) -> str: # W0050 — trivially returns a literal - return "circle" - - @property - def is_unit(self) -> bool: # W0050 — trivially returns a comparison - return self._radius == 1.0 - - -# --------------------------------------------------------------------------- -# __init__ and __new__: always return None / cls (redundant) -# --------------------------------------------------------------------------- - - -class Widget: - def __init__(self) -> None: # W0050 — __init__ always returns None - self.value = 0 - - def __repr__(self) -> str: # W0050 — inferrable from return f"..." - return f"Widget({self.value})" - - def __str__(self) -> str: # W0050 — inferrable from return "..." - return "widget" - - def __len__(self) -> int: # W0050 — inferrable from return - return self.value - - def __bool__(self) -> bool: # W0050 — inferrable from return True/False - return self.value > 0 - - -# --------------------------------------------------------------------------- -# Staticmethod and classmethod -# --------------------------------------------------------------------------- - - -class Factory: - @staticmethod - def create_default() -> int: # W0050 — inferrable from `return 0` - return 0 - - @classmethod - def from_string( - cls, text: str - ) -> "Factory": # NO W0050 — cls return is not inferrable - return cls() - - -# --------------------------------------------------------------------------- -# Nested functions: same rules apply -# --------------------------------------------------------------------------- - - -def outer() -> None: - def inner_redundant() -> int: # W0050 — inferrable - return 99 - - def inner_needed() -> list[int]: # NO W0050 — collection type adds info - return [1, 2, 3] - - x: int = inner_redundant() # W0050 — return type known to be int - y = inner_needed() - _ = (x, y) - - -# --------------------------------------------------------------------------- -# Walrus operator (:=): annotated target -# --------------------------------------------------------------------------- - - -def walrus_examples() -> None: - if (n := 10) > 5: # NO W0050 — walrus can't carry annotation - _ = n - - -# --------------------------------------------------------------------------- -# Type alias assignments: NOT redundant (these define types, not values) -# --------------------------------------------------------------------------- - -from typing import TypeAlias - -Vector: TypeAlias = list[float] # NO W0050 — type alias definition -Matrix: TypeAlias = list[list[float]] # NO W0050 — type alias definition - - -# --------------------------------------------------------------------------- -# Annotated but no initializer (declaration-only): NOT redundant -# --------------------------------------------------------------------------- - - -class DeclarationOnly: - name: str # NO W0050 — no value, annotation is the declaration - age: int # NO W0050 — no value, annotation is the declaration - - -# --------------------------------------------------------------------------- -# Augmented assignment: annotation on first use, then augmented -# --------------------------------------------------------------------------- - - -def augmented_assign() -> None: - total: int = 0 # W0050 — obviously int - total += 10 - _ = total - - -# --------------------------------------------------------------------------- -# Global/nonlocal: annotation at module level, used in function -# --------------------------------------------------------------------------- - -_counter: int = 0 # W0050 — obviously int - - -def increment() -> None: - global _counter - _counter += 1 - - -# --------------------------------------------------------------------------- -# Dataclass-style: fields with explicit types -# --------------------------------------------------------------------------- - -from dataclasses import dataclass - - -@dataclass -class Point: - x: float # NO W0050 — dataclass field, annotation required - y: float # NO W0050 — dataclass field, annotation required - - -@dataclass -class LabeledPoint: - x: float # NO W0050 — dataclass field, annotation required - y: float # NO W0050 — dataclass field, annotation required - label: str = "origin" # NO W0050 — dataclass field, annotation required for default - - -# --------------------------------------------------------------------------- -# NamedTuple: annotations are part of the structure definition -# --------------------------------------------------------------------------- - -from typing import NamedTuple - - -class Coordinate(NamedTuple): - x: float # NO W0050 — NamedTuple field, annotation required - y: float # NO W0050 — NamedTuple field, annotation required - label: str = "point" # NO W0050 — NamedTuple field, annotation required - - -# --------------------------------------------------------------------------- -# TypedDict: annotations are the definition -# --------------------------------------------------------------------------- - -from typing import TypedDict - - -class UserDict(TypedDict): - name: str # NO W0050 — TypedDict field, annotation IS the definition - age: int # NO W0050 — TypedDict field, annotation IS the definition - - -# --------------------------------------------------------------------------- -# Constructor calls: annotation redundant when type matches constructor -# --------------------------------------------------------------------------- - - -def constructor_annotations() -> None: - x: int = int(42) # W0050 — int() returns int - y: str = str("hello") # W0050 — str() returns str - z: float = float(1.0) # W0050 — float() returns float - b: bool = bool(True) # W0050 — bool() returns bool - r: bytes = bytes(b"") # W0050 — bytes() returns bytes - lst: list = list() # W0050 — list() returns list - dct: dict = dict() # W0050 — dict() returns dict - st: set = set() # W0050 — set() returns set - _ = (x, y, z, b, r, lst, dct, st) - - -def constructor_non_redundant() -> None: - items: list[int] = list() # NO W0050 — parameterized type adds info - mapping: dict[str, int] = dict() # NO W0050 — parameterized type adds info - _ = (items, mapping) - - -# --------------------------------------------------------------------------- -# Cast and assertion patterns -# --------------------------------------------------------------------------- - -from typing import cast - - -def cast_patterns() -> None: - x: int = cast(int, some_value()) # NO W0050 — cast is explicit intent - _ = x - - -def some_value() -> object: - return 42 - - -# --------------------------------------------------------------------------- -# Multiple assignment targets -# --------------------------------------------------------------------------- - - -def multi_assign() -> None: - b = 10 - a: int = b # W0050 — b is already int - _ = (a, b) - - -# --------------------------------------------------------------------------- -# String literal types (forward references): NOT redundant -# --------------------------------------------------------------------------- - - -class Node: - def next(self) -> "Node": # NO W0050 — forward reference, not inferrable - return Node() - - -# --------------------------------------------------------------------------- -# Union types: NOT redundant -# --------------------------------------------------------------------------- - -from typing import Union, Optional - -maybe_int: Optional[int] = None # NO W0050 — Optional adds info beyond None -either: Union[int, str] = 42 # NO W0050 — Union adds info beyond int - - -# --------------------------------------------------------------------------- -# Final: annotation may be redundant but Final qualifier is not -# --------------------------------------------------------------------------- - -from typing import Final - -MAX_SIZE: Final[int] = 100 # W0050 — int is redundant (Final alone suffices) -MAX_NAME: Final = "limit" # NO W0050 — no redundant type, just Final - - -# --------------------------------------------------------------------------- -# Callable annotations -# --------------------------------------------------------------------------- - -from typing import Callable - - -def apply_func( - func: Callable[[int], int], value: int -) -> int: # NO W0050 — Callable needed - return func(value) - - -# --------------------------------------------------------------------------- -# Async functions: same rules apply -# --------------------------------------------------------------------------- - -import asyncio - - -async def async_redundant() -> int: # W0050 — inferrable from `return 42` - return 42 - - -async def async_needed() -> list[int]: # NO W0050 — collection type adds info - return [1, 2, 3] - - -async def async_none() -> None: # W0050 — async with no return implies None - await asyncio.sleep(0) - - -# --------------------------------------------------------------------------- -# Generator annotations -# --------------------------------------------------------------------------- - -from typing import Generator, Iterator - - -def gen_needed() -> Generator[int, None, None]: # NO W0050 — Generator type adds info - yield 1 - yield 2 - - -def iter_needed() -> Iterator[str]: # NO W0050 — Iterator type adds info - yield "a" - yield "b" - - -# --------------------------------------------------------------------------- -# Context managers -# --------------------------------------------------------------------------- - -from contextlib import contextmanager - - -@contextmanager -def managed_resource() -> Generator[ - str, None, None -]: # NO W0050 — Generator type needed - yield "resource" - - -# --------------------------------------------------------------------------- -# Overloaded functions: annotations are required -# --------------------------------------------------------------------------- - -from typing import overload - - -@overload -def process(x: int) -> int: # NO W0050 — overload signatures required - ... -@overload -def process(x: str) -> str: # NO W0050 — overload signatures required - ... -def process(x: int | str) -> int | str: - return x - - -# --------------------------------------------------------------------------- -# Protocol: annotations define the interface -# --------------------------------------------------------------------------- - -from typing import Protocol - - -class Drawable(Protocol): - def draw(self) -> None: # NO W0050 — Protocol method signature - ... - - x: int # NO W0050 — Protocol attribute declaration - - -# --------------------------------------------------------------------------- -# Abstract methods: annotations define the contract -# --------------------------------------------------------------------------- - -from abc import ABC, abstractmethod - - -class Shape(ABC): - @abstractmethod - def area(self) -> float: # NO W0050 — abstract method contract - ... - - @abstractmethod - def perimeter(self) -> float: # NO W0050 — abstract method contract - ... diff --git a/examples/tests/__init__.py b/examples/tests/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/examples/tests/test_data_pipeline.py b/examples/tests/test_data_pipeline.py deleted file mode 100644 index 3b959e3c6..000000000 --- a/examples/tests/test_data_pipeline.py +++ /dev/null @@ -1,73 +0,0 @@ -"""Tests for examples/data_pipeline.py — ETL data pipeline.""" - -import sys -from pathlib import Path - -sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) - -from data_pipeline import ( - BaseWriter, - Column, - ParquetWriter, - PartitionKey, - coerce_field, - detect_encoding, - read_source, -) - - -class TestCoerceField: - def test_str_to_int(self) -> None: - assert coerce_field("42", int) == 42 - - def test_int_to_str_renamed(self) -> None: - assert coerce_field(42, str) == "42" - - def test_str_to_float(self) -> None: - assert coerce_field("3.14", float) == 3.14 - - -class TestDetectEncoding: - def test_utf8_bom(self) -> None: - assert detect_encoding(b"\xef\xbb\xbfhello") == "utf-8-sig" - - def test_utf16_le_bom(self) -> None: - assert detect_encoding(b"\xff\xfehello") == "utf-16" - - def test_utf16_be_bom(self) -> None: - assert detect_encoding(b"\xfe\xffhello") == "utf-16" - - -class TestReadSource: - def test_returns_empty_list(self) -> None: - result = read_source("nonexistent.csv") - assert result == [] - - -class TestColumnHierarchy: - def test_column_fields(self) -> None: - col = Column() - col.name = "id" - col.dtype = "int" - col.nullable = False - assert col.name == "id" - - def test_partition_key_inherits(self) -> None: - assert issubclass(PartitionKey, Column) - - def test_partition_key_nullable_default(self) -> None: - pk = PartitionKey() - assert pk.nullable == 0 - - -class TestWriterHierarchy: - def test_base_writer_flush(self) -> None: - writer = BaseWriter() - assert writer.flush([b"a", b"b"]) == 2 - - def test_parquet_writer_flush(self) -> None: - writer = ParquetWriter() - assert writer.flush([b"a", b"b"]) == 4 - - def test_parquet_is_base(self) -> None: - assert issubclass(ParquetWriter, BaseWriter) diff --git a/examples/tests/test_finance.py b/examples/tests/test_finance.py deleted file mode 100644 index d2ac74616..000000000 --- a/examples/tests/test_finance.py +++ /dev/null @@ -1,97 +0,0 @@ -"""Tests for examples/finance.py — financial calculations.""" - -import math -import sys -from pathlib import Path - -sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) - -from finance import ( - BaseSettlement, - T2Settlement, - apply_slippage, - black_scholes, - kelly_criterion, - present_value, - round_to_tick, -) - - -class TestBlackScholes: - def test_returns_float(self) -> None: - result = black_scholes(100, 100, 1.0, 0.05, 0.2) - assert isinstance(result, float) - - def test_atm_option_positive(self) -> None: - result = black_scholes(100, 100, 1.0, 0.05, 0.2) - assert result > 0 - - def test_deep_itm_approaches_intrinsic(self) -> None: - result = black_scholes(200, 100, 0.01, 0.05, 0.2) - assert result > 90 - - -class TestPresentValue: - def test_single_cash_flow(self) -> None: - pv = present_value([100.0], 0.1) - assert pv == 100.0 - - def test_discounting_reduces_value(self) -> None: - pv = present_value([0, 100.0], 0.1) - assert pv < 100.0 - - def test_zero_discount_rate(self) -> None: - pv = present_value([10.0, 20.0, 30.0], 0.0) - assert math.isclose(pv, 60.0) - - -class TestKellyCriterion: - def test_fair_coin_positive_edge(self) -> None: - fraction = kelly_criterion(0.6, 1.0, 1.0) - assert fraction > 0 - - def test_losing_bet_negative(self) -> None: - fraction = kelly_criterion(0.3, 1.0, 1.0) - assert fraction < 0 - - def test_certain_win(self) -> None: - fraction = kelly_criterion(1.0, 1.0, 1.0) - assert math.isclose(fraction, 1.0) - - -class TestRoundToTick: - def test_exact_multiple(self) -> None: - assert round_to_tick(100.0, 5) == 100.0 - - def test_rounds_to_nearest(self) -> None: - result = round_to_tick(103.0, 5) - assert result == 105.0 - - -class TestApplySlippage: - def test_buy_increases_price(self) -> None: - result = apply_slippage("buy", 100.0, 10.0) - assert result is not None - assert result > 100.0 - - def test_sell_decreases_price(self) -> None: - result = apply_slippage("sell", 100.0, 10.0) - assert result is not None - assert result < 100.0 - - def test_unknown_side_returns_none(self) -> None: - result = apply_slippage("short", 100.0, 10.0) - assert result is None - - -class TestSettlement: - def test_base_settlement(self) -> None: - s = BaseSettlement() - assert s.settle(1000.0, "USD") == "1000.0 USD" - - def test_t2_settlement(self) -> None: - s = T2Settlement() - assert s.settle(1000.0, "USD") == "T+2: 1000.0 USD" - - def test_t2_is_base(self) -> None: - assert issubclass(T2Settlement, BaseSettlement) diff --git a/examples/tests/test_good.py b/examples/tests/test_good.py deleted file mode 100644 index 2f3c42334..000000000 --- a/examples/tests/test_good.py +++ /dev/null @@ -1,46 +0,0 @@ -"""Tests for examples/good.py — fully typed code.""" - -import sys -from pathlib import Path - -sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) - -from good import User, add, greet - - -def test_greet_returns_greeting() -> None: - assert greet("Alice") == "Hello Alice" - - -def test_greet_empty_name() -> None: - assert greet("") == "Hello " - - -def test_add_positive_numbers() -> None: - assert add(2, 3) == 5 - - -def test_add_negative_numbers() -> None: - assert add(-1, -4) == -5 - - -def test_add_zero() -> None: - assert add(0, 0) == 0 - - -class TestUser: - def test_init(self) -> None: - user = User("Bob", 30) - assert user.name == "Bob" - assert user.age == 30 - - def test_birthday_increments_age(self) -> None: - user = User("Carol", 25) - user.birthday() - assert user.age == 26 - - def test_multiple_birthdays(self) -> None: - user = User("Dave", 40) - for _ in range(5): - user.birthday() - assert user.age == 45 diff --git a/examples/weird_violations.py b/examples/weird_violations.py deleted file mode 100644 index e40cc8441..000000000 --- a/examples/weird_violations.py +++ /dev/null @@ -1,129 +0,0 @@ -""" -Weird and subtle violations — the cases that fool other type checkers. - -These are not contrived: every pattern appears in real codebases. -Basilisk catches all of them. - -Run: basilisk check examples/weird_violations.py -""" - -from __future__ import annotations - -from typing import Any, overload - - -# ── E0003: empty dict hiding inside a function default ─────────────────────── -# (Basilisk checks module-level assignments) -_cache = {} # BSK-0003: type of values unknown - - -# ── E0014: bool is a subtype of int in Python, but Basilisk still flags -# assigning a bool literal to a float field ──────────────────────────────── -ratio: float = True # assignment_compatibility: bool literal, not float - - -# ── E0014: negative int literal assigned to a str field ────────────────────── -sentinel: str = -1 # assignment_compatibility: int, not str - - -# ── E0017: attribute type flipped from mutable to immutable in child ───────── -class Config: - values: list[str] - max_size: int - - -class FrozenConfig(Config): - values: tuple[str, ...] = () # classes_override_2: tuple overrides list - max_size: str = "unlimited" # classes_override_2: str overrides int - - -# ── E0018: name used before assignment even though it looks like a constant ─── -def describe_algorithm() -> str: - return f"Using {ALGO_NAME} with seed {ALGO_SEED}" # names_undefined: ALGO_NAME not yet defined - - -ALGO_NAME: str = "DBSCAN" -ALGO_SEED: int = 42 - - -# ── E0019: exactly-one-path binding — the 'elif' still leaves a gap ────────── -def pick_strategy(score: float, mode: str) -> str: - if score > 0.9: - strategy = "aggressive" - elif mode == "safe": - strategy = "conservative" - # no else — if score <= 0.9 and mode != "safe", strategy is unbound - return strategy # names_unbound: strategy may be unbound - - -# ── E0019: augmented assignment in a try block ─────────────────────────────── -def sum_with_retry(values: list[int], retries: int) -> int: - if retries > 0: - result = 0 - for v in values: - result += v - return result # names_unbound: result may be unbound - - -# ── E0021: unannotated overload params look identical to the checker ───────── -# (differs only in return type — unannotated param means both have same signature) -@overload -def load(path) -> bytes: ... # BSK-0001: path untyped - - -@overload -def load(path) -> str: ... # BSK-0001 + overloads_consistency: duplicate - - -def load(path: str) -> bytes | str: - with open(path, "rb") as fh: - return fh.read() - - -# ── E0021: unannotated + Any together — Any is explicit, param is bare ──────── -@overload -def wrap(value) -> list[Any]: ... # BSK-0001: value untyped - - -@overload -def wrap(value) -> list[Any]: ... # BSK-0001 + overloads_consistency: duplicate - - -def wrap(value: Any) -> list[Any]: # returns_compatibility: Any without justification - return [value] - - -# ── E0022: list literal as dict key in a local dict ───────────────────────── -def make_tag_index() -> dict[list[str], float]: - return {["tag_a", "tag_b"]: 1.0} # dict_key_hashable: list literal as key - - -# ── E0023: match on an int with only two arms (0 and 1) ───────────────────── -def bool_from_db(raw: int) -> str: - match raw: - case 0: - return "false" - case 1: - return "true" - # match_exhaustiveness: 2, -1, 99 etc. fall through silently - - -# ── E0025: override buried inside a mixin chain ────────────────────────────── -class Serializable: - def to_json(self) -> str: - return "{}" - - -class Timestamped: - def to_json( - self, - ) -> str: # BSK-0025: no @override (inherits from Serializable via MRO) - return '{"ts": 0}' - - -# ── Combination: untyped + Any return + unhashable key ─────────────────────── -def batch_lookup(keys, db): # BSK-0001: keys, db untyped - results = {} - for key in keys: - results[key] = db.get(key) - return results # BSK-0002: no return type diff --git a/pyproject.toml b/pyproject.toml index a046221e0..a0883b37c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,11 +1,14 @@ -# PyPI packaging for the `basilisk` type checker. +# PyPI packaging for the `basilisk` CLI. # # This wraps the SAME native `basilisk` binary that ships via GitHub Releases, -# Homebrew and Scoop into a Python wheel, so `pip install basilisk-python` -# lands the executable on PATH. It exists so tools that manage type checkers -# through Python environments — notably the `python/typing` conformance suite, -# which installs every checker with `uv sync` — can pull Basilisk the same way -# they pull ty, pyrefly and zuban (all Rust binaries shipped as wheels). +# Homebrew and Scoop into a Python wheel, so the tools that manage Python +# environments can pull it the way they pull anything else. +# +# The binary is inert ([WITHDRAWAL-INERT]): it prints the withdrawal statement +# and exits 4. This wheel exists for exactly one more release — so an +# environment that already has Basilisk pinned receives the statement instead of +# a checker that produced incorrect results. Afterwards every release here is +# yanked and the project is unlisted (see delist/). # # The distribution name is `basilisk-python` because `basilisk` is already # taken on PyPI; the installed console script is still `basilisk`. @@ -21,21 +24,23 @@ build-backend = "maturin" [project] name = "basilisk-python" -description = "Basilisk — an open-source Python type checker and language server built in Rust: diagnostics, refactoring, formatting, integrated debugging, and profiling. Strictness is configured per rule, so a codebase can adopt type safety incrementally." +description = "Basilisk's type checker produced incorrect results. Basilisk is unlisted and is being rebuilt from the ground up as a new product." readme = "README-pypi.md" requires-python = ">=3.8" -license = "Apache-2.0 AND BSD-2-Clause AND BSD-3-Clause AND CDDL-1.0 AND CDLA-Permissive-2.0 AND ISC AND MIT AND MPL-2.0 AND Unicode-3.0 AND Unicode-DFS-2016 AND Zlib" -# PEP 639 applies this expression to the containing wheel. It therefore covers -# Basilisk's MIT code, the Apache-2.0/MIT Typeshed snapshot, and every license -# selected for the statically linked runtime recorded in NOTICES. Release builds -# replace this macOS expression with the target's locked expression. The exact -# legal files travel in `.dist-info/licenses/` rather than relying on defaults. +license = "Apache-2.0 AND MIT AND Unicode-3.0" +# PEP 639 applies this expression to the containing wheel. It covers Basilisk's +# own MIT code and every license selected for the statically linked runtime +# recorded in NOTICES. It is far shorter than it used to be because the binary +# is inert ([WITHDRAWAL-INERT]): no typeshed snapshot, no embedded formatter, no +# download runtime, so none of their licenses apply to what ships. Release +# builds replace this expression with the target's locked one. The exact legal +# files travel in `.dist-info/licenses/` rather than relying on defaults. license-files = ["LICENSE", "NOTICES", "THIRD-PARTY-LICENSES", "RUST-DEPENDENCY-LICENSES"] authors = [{ name = "Nimblesite", email = "cftools@nimblesite.co" }] dynamic = ["version"] -keywords = ["type-checker", "typing", "static-analysis", "lsp", "pep", "python"] +keywords = ["basilisk"] classifiers = [ - "Development Status :: 4 - Beta", + "Development Status :: 7 - Inactive", "Environment :: Console", "Intended Audience :: Developers", "Programming Language :: Python", @@ -81,8 +86,3 @@ BSK-0050 = "warning" BSK-0061 = "warning" BSK-0062 = "warning" -# The examples grade those same rules down to `warning`; that lives in -# examples/pyproject.toml, because folder-scoped configuration is the -# nearest-deciding table on the ancestor walk ([CHKARCH-CONFIG-MODEL]) — there -# is no per-path override table, and a table this file does not define is -# simply never read. diff --git a/runtime-license-manifest.json b/runtime-license-manifest.json index f4d810869..570001bec 100644 --- a/runtime-license-manifest.json +++ b/runtime-license-manifest.json @@ -1,6 +1,6 @@ { - "cargo_dependency_graph_sha256": "ac5d187729fd8be9be9f709d97c351e717615d9945bceeb1adb0c2c07b0ececd", - "licenses_sha256": "3219959622e8d671eff7deddc2376bc0b1ee481385c281dd9c8e7014ec5ebbf6", + "cargo_dependency_graph_sha256": "68e9e4661fd7248e6ce01bf81cd65269f9e501e20484bd250316c78965820998", + "licenses_sha256": "b3d9a53b33aaab4bdbbf1e6ed8f5288386ee5ac344f86fb44fde0bda05be2dc9", "targets": [ "x86_64-unknown-linux-gnu", "aarch64-unknown-linux-gnu", @@ -9,10 +9,10 @@ "aarch64-pc-windows-msvc" ], "wheel_license_expressions": { - "aarch64-apple-darwin": "Apache-2.0 AND BSD-2-Clause AND BSD-3-Clause AND CDDL-1.0 AND CDLA-Permissive-2.0 AND ISC AND MIT AND MPL-2.0 AND Unicode-3.0 AND Unicode-DFS-2016 AND Zlib", - "aarch64-pc-windows-msvc": "Apache-2.0 AND BSD-3-Clause AND CDDL-1.0 AND CDLA-Permissive-2.0 AND ISC AND MIT AND MPL-2.0 AND Unicode-3.0 AND Unicode-DFS-2016 AND Zlib", - "aarch64-unknown-linux-gnu": "Apache-2.0 AND BSD-3-Clause AND CDDL-1.0 AND CDLA-Permissive-2.0 AND ISC AND MIT AND MPL-2.0 AND Unicode-3.0 AND Unicode-DFS-2016 AND Zlib", - "x86_64-pc-windows-msvc": "Apache-2.0 AND BSD-3-Clause AND CDDL-1.0 AND CDLA-Permissive-2.0 AND ISC AND MIT AND MPL-2.0 AND Unicode-3.0 AND Unicode-DFS-2016 AND Zlib", - "x86_64-unknown-linux-gnu": "Apache-2.0 AND BSD-3-Clause AND CDDL-1.0 AND CDLA-Permissive-2.0 AND ISC AND MIT AND MPL-2.0 AND Unicode-3.0 AND Unicode-DFS-2016 AND Zlib" + "aarch64-apple-darwin": "Apache-2.0 AND MIT AND Unicode-3.0", + "aarch64-pc-windows-msvc": "Apache-2.0 AND MIT AND Unicode-3.0", + "aarch64-unknown-linux-gnu": "Apache-2.0 AND MIT AND Unicode-3.0", + "x86_64-pc-windows-msvc": "Apache-2.0 AND MIT AND Unicode-3.0", + "x86_64-unknown-linux-gnu": "Apache-2.0 AND MIT AND Unicode-3.0" } } diff --git a/screenshots/blog-post-styled.png b/screenshots/blog-post-styled.png deleted file mode 100644 index 7b637f743..000000000 Binary files a/screenshots/blog-post-styled.png and /dev/null differ diff --git a/screenshots/blog-styled.png b/screenshots/blog-styled.png deleted file mode 100644 index 683242dc9..000000000 Binary files a/screenshots/blog-styled.png and /dev/null differ diff --git a/scripts/build-zed-extension.sh b/scripts/build-zed-extension.sh deleted file mode 100755 index 842015fef..000000000 --- a/scripts/build-zed-extension.sh +++ /dev/null @@ -1,29 +0,0 @@ -#!/usr/bin/env bash -# build-zed-extension.sh -# -# Rebuilds the basilisk CLI binary that the Zed extension launches as the LSP. -# -# The Zed extension WASM is compiled by Zed itself when you do: -# Cmd+Shift+P -> "zed: install dev extension" -> select basilisk-zed/ -# -# DO NOT manually copy wasm files into Zed's directories. -# Zed converts raw wasm modules into wasm components — manual copies will fail. -# -# Usage: -# ./scripts/build-zed-extension.sh - -set -euo pipefail - -REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -ZED_DIR="$REPO_ROOT/basilisk-zed" - -echo "==> Building basilisk CLI (release)..." -cargo install --path "$REPO_ROOT/crates/basilisk-cli" --force -echo " Installed: $(which basilisk)" -echo "" -echo "==> CLI binary updated. Now reinstall the dev extension in Zed:" -echo "" -echo " Cmd+Shift+P -> 'zed: install dev extension'" -echo " Select: $ZED_DIR" -echo "" -echo " Zed will recompile the WASM and reload the extension." diff --git a/scripts/check_public_copy.py b/scripts/check_public_copy.py new file mode 100644 index 000000000..989d00d42 --- /dev/null +++ b/scripts/check_public_copy.py @@ -0,0 +1,191 @@ +#!/usr/bin/env python3 +"""Fail if any public surface says something [WITHDRAWAL-PROHIBITED] forbids. + +Implements [WITHDRAWAL-SURFACES]. `scripts/test_published_readmes.py` proves the +five generated storefront READMEs match the messaging spec; nothing proved +anything about the rest — the crate READMEs, the security policy, the package +manifests, the store descriptions, the website templates. Those are public too, +and a marketing sentence or a stale "shipping" claim in one of them contradicts +the statement just as loudly as one in a README. + + python3 scripts/check_public_copy.py # scan; non-zero on a hit + python3 scripts/check_public_copy.py --list # print the scanned surfaces + +Every rule below cites the spec bullet it enforces. Add a rule when the spec +gains a prohibition — never an exemption for a surface that trips one. +""" + +from __future__ import annotations + +import argparse +import re +import sys +from dataclasses import dataclass +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent +SPEC = "docs/specs/DOCS-WITHDRAWAL-MESSAGING-SPEC.md" + +# Everything a stranger can read without cloning: listings, storefronts, the +# site, and the files GitHub renders on the repository page. Internal specs, +# plans and the integrity audit are deliberately absent — they are the record, +# not marketing surfaces ([WITHDRAWAL-UNLIST]). +PUBLIC_SURFACES: tuple[str, ...] = ( + "README.md", + "README-pypi.md", + "CONTRIBUTING.md", + "SECURITY.md", + "pyproject.toml", + "crates/*/README.md", + "vscode-extension/README.md", + "vscode-extension/package.json", + "basilisk.nvim/README.md", + "basilisk.nvim/doc/basilisk.txt", + "basilisk-zed/README.md", + "basilisk-zed/extension.toml", + ".github/release-templates/*.tmpl", + "website/src/*.njk", + "website/src/_includes/**/*.njk", + "delist/README.md", + "book/README.md", +) + +APOLOGY = "christianfindlay.com/blog/basilisk-conformance-apology" + + +@dataclass(frozen=True) +class Rule: + """One prohibition, as a pattern plus the reason it exists.""" + + id: str + pattern: re.Pattern[str] + why: str + + +RULES: tuple[Rule, ...] = ( + Rule( + "measured-figure", + re.compile(r"\b\d{1,3}(?:\.\d+)?\s?%"), + "no conformance or benchmark figure, in any tense, caveated or archived", + ), + # A figure, not the word: the approved copy says "removed from the + # python/typing conformance results" and links PR #2330, so `conformance` + # and a bare number are both allowed. A percentage, a score, or an "N of M" + # is not. + Rule( + "conformance-score", + re.compile( + r"(?:conformance|benchmark|pass rate|score)[^.\n]{0,80}?" + r"\b\d{1,3}(?:\.\d+)?\s?(?:%|of\s+\d|/\s?\d)|" + r"\bscored?\b[^.\n]{0,40}?\d", + re.IGNORECASE, + ), + "no conformance or benchmark figure, in any tense, caveated or archived", + ), + Rule( + "install-instruction", + re.compile( + r"\b(pip|uv tool|uv|brew|scoop|cargo|npm|npx|pipx)\s+install\s+\S*basilisk", + re.IGNORECASE, + ), + "no install instructions", + ), + Rule( + "rule-count", + re.compile(r"\b\d+\+?\s+(rules|diagnostics|checks|lints)\b", re.IGNORECASE), + "no feature marketing or rule counts", + ), + Rule( + "feature-marketing", + re.compile( + r"\b(strict[- ]by[- ]default|blazing|blazingly|lightning[- ]fast|" + r"fastest|best[- ]in[- ]class|production[- ]ready|batteries[- ]included|" + r"drop[- ]in replacement|just works)\b", + re.IGNORECASE, + ), + "no feature marketing", + ), + Rule( + "scoping-reassurance", + re.compile( + r"\b(only a (few|handful)|small number of rules|" + r"(is|are|remains?) unaffected|safe to (keep )?us(e|ing)|" + r"keep using|still (safe|fine|works? fine))\b", + re.IGNORECASE, + ), + "no scoping reassurance — we cannot scope it, and saying so is the point", + ), + Rule( + "shipping-claim", + re.compile( + r"^\s*(Working|Complete|Shipped|Shipping|Stable|Ready)\s*[-—–:]", + re.IGNORECASE | re.MULTILINE, + ), + "no claim that something is shipped — nothing ships but the statement", + ), + Rule( + "quoted-apology", + re.compile(rf"^\s*>.*{re.escape(APOLOGY)}", re.MULTILINE), + "never quote the apology — link it, neutrally, and nothing more", + ), +) + + +def surfaces() -> list[Path]: + """Every public file, resolved and de-duplicated, in a stable order.""" + found: set[Path] = set() + for pattern in PUBLIC_SURFACES: + found.update(path for path in REPO_ROOT.glob(pattern) if path.is_file()) + return sorted(found) + + +def scan(path: Path) -> list[tuple[Rule, str]]: + """Every prohibition `path` trips, with the offending text.""" + text = path.read_text(encoding="utf-8") + hits: list[tuple[Rule, str]] = [] + for rule in RULES: + match = rule.pattern.search(text) + if match: + hits.append((rule, match.group(0).strip())) + return hits + + +def report(path: Path, hits: list[tuple[Rule, str]]) -> None: + """Print one surface's failures the way a reviewer needs to read them.""" + relative = path.relative_to(REPO_ROOT) + for rule, offending in hits: + print(f"error: {relative}: [{rule.id}] {rule.why}", file=sys.stderr) + print(f" matched: {offending!r}", file=sys.stderr) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--list", action="store_true", help="print the scanned surfaces and exit" + ) + args = parser.parse_args() + + paths = surfaces() + if args.list: + for path in paths: + print(path.relative_to(REPO_ROOT)) + return 0 + + failed = False + for path in paths: + hits = scan(path) + if hits: + report(path, hits) + failed = True + + if failed: + print( + f"\nThe prohibitions are {SPEC} [WITHDRAWAL-PROHIBITED].", file=sys.stderr + ) + return 1 + print(f"✓ {len(paths)} public surfaces carry no prohibited copy") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/gen_conformance_reference.py b/scripts/gen_conformance_reference.py deleted file mode 100644 index 80ba16ed3..000000000 --- a/scripts/gen_conformance_reference.py +++ /dev/null @@ -1,221 +0,0 @@ -#!/usr/bin/env python3 -# Implements [CHKARCH-CONFORMANCE]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md -"""Stamp the live conformance score + graded commit into the README and spec. - -Static docs (README.md, README.zh.md, the checker-architecture spec) quote the -conformance score and the exact `python/typing` commit it was measured against. -Those drift as the checker improves and `main` advances. This generator reads -`website/src/_data/conformance_report.json` — written by -`conformance/run_conformance.py` on every run from the REAL python/typing harness -output — and refreshes the quoted values in place, so the docs can never silently -contradict the self-measured number. - -It updates two kinds of spot, both render-safe (the markers are invisible HTML -comments, so they work mid-sentence, inside a list item, or inside a table cell): - - • inline markers `value` -> the value for NAME - • commit-tree URLs `github.com/python/typing/tree//conformance` -> the sha - -Usage: - python3 scripts/gen_conformance_reference.py # rewrite in place - python3 scripts/gen_conformance_reference.py --check # CI: fail if stale -""" - -from __future__ import annotations - -import json -import math -import re -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 / "docs" / "readme" / "README.src.md", - ROOT / "docs" / "readme" / "README.zh.src.md", - ROOT / "docs" / "specs" / "CHECKER-ARCHITECTURE-SPEC.md", -) - -# The checkers whose median cold time the README bench table quotes. Key is the -# CSV `_ms` column; the sentinel name is `bench` (e.g. -# `benchBasilisk`), stamped inline in the table cell so it never breaks the -# markdown table the way a standalone comment line would. -BENCH_TOOLS = ("basilisk", "pyright", "mypy", "ty", "pyrefly", "zuban") - -MARKER_RE = re.compile(r".*?", re.S) -TREE_SHA_RE = re.compile( - r"(github\.com/python/typing/tree/)[0-9a-fA-F]{7,40}(/conformance)" -) - - -def values(report: dict) -> dict[str, str]: - """The named values the markers may reference, from the score report.""" - score = report["score"] - upstream = report["upstream"] - return { - "score": f"{score['scorePct']}%", - "pass": str(score["pass"]), - "total": str(score["total"]), - "fp": str(score["falsePositives"]), - "missed": str(score["missed"]), - "caught": str(score["caught"]), - "short": upstream["shortSha"], - } - - -def _median_ms(nums: list[float]) -> int | None: - """Median of `nums`, rounded half-up to match the website's JS `Math.round`.""" - ordered = sorted(nums) - n = len(ordered) - if n == 0: - return None - mid = n // 2 - val = ordered[mid] if n % 2 else (ordered[mid - 1] + ordered[mid]) / 2 - return math.floor(val + 0.5) - - -def _primary_bench_csv() -> Path | None: - """The benchmark CSV the README quotes: the `.primary` pin first, then the - alphabetically-first machine. (`_data/benchmarks.js` additionally honors - $BASILISK_BENCH_PRIMARY and ranks unpinned CSVs by tool coverage; with the - committed `.primary` pin — the normal state — both resolve identically.)""" - pin = BENCH_STATUS_DIR / ".primary" - if pin.exists(): - csv = BENCH_STATUS_DIR / f"{pin.read_text(encoding='utf-8').strip()}.csv" - if csv.exists(): - return csv - csvs = sorted(BENCH_STATUS_DIR.glob("*.csv")) - return csvs[0] if csvs else None - - -def bench_values() -> dict[str, str]: - """Median cold check per tool + machine/count, read from the primary bench - CSV so the README table can never be a hand-typed figure. Empty when no CSV - exists (the markers are then left untouched, exactly like a missing score).""" - csv = _primary_bench_csv() - if csv is None: - return {} - cpu, header, rows = "", None, [] - for raw in csv.read_text(encoding="utf-8").splitlines(): - line = raw.strip() - if not line: - continue - if line.startswith("#"): - body = line[1:].strip() - if body.startswith("cpu:"): - cpu = body.split(":", 1)[1].strip() - continue - parts = line.split(",") - if header is None: - header = parts - else: - rows.append(parts) - if not header or not rows: - return {} - - col = { - name[:-3] if name.endswith("_ms") else name: i for i, name in enumerate(header) - } - - def median_for(tool: str) -> int | None: - i = col.get(tool) - if i is None: - return None - nums = [] - for r in rows: - if i < len(r) and r[i]: - try: - nums.append(float(r[i])) - except ValueError: - pass - return _median_ms(nums) - - vals: dict[str, str] = {} - for tool in BENCH_TOOLS: - m = median_for(tool) - if m is not None: - vals[f"bench{tool.capitalize()}"] = str(m) - warm = median_for("basilisk-warm") - if warm is not None: - vals["benchWarm"] = str(warm) - if cpu: - vals["benchMachine"] = cpu - vals["benchCount"] = str(len(rows)) - return vals - - -def stamp(text: str, vals: dict[str, str]) -> str: - """Refresh every inline marker and every commit-tree URL in `text`.""" - - def marker(match: re.Match[str]) -> str: - name = match.group("name") - value = vals.get(name) - if value is None: - return match.group(0) # unknown marker — leave it untouched - return f"{value}" - - text = MARKER_RE.sub(marker, text) - return TREE_SHA_RE.sub(lambda m: f"{m.group(1)}{vals['sha']}{m.group(2)}", text) - - -def main(argv: list[str]) -> int: - check = "--check" in argv - if not REPORT.exists(): - print( - f" ✗ {REPORT.relative_to(ROOT)} not found — run conformance/run_conformance.py first", - file=sys.stderr, - ) - return 1 - - report = json.loads(REPORT.read_text(encoding="utf-8")) - vals = values(report) - vals["sha"] = report["upstream"]["sha"] # full sha for the tree URLs only - vals.update(bench_values()) # median cold check per tool, from the primary CSV - - stale: list[Path] = [] - for path in TARGETS: - if not path.exists(): - continue - original = path.read_text(encoding="utf-8") - updated = stamp(original, vals) - if updated != original: - stale.append(path) - if not check: - path.write_text(updated, encoding="utf-8") - - if check: - if stale: - print( - " ✗ conformance docs are stale — run " - "scripts/gen_conformance_reference.py:", - file=sys.stderr, - ) - for path in stale: - print(f" - {path.relative_to(ROOT)}", file=sys.stderr) - return 1 - print(" conformance docs up to date.") - # 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:") - for path in stale: - print(f" - {path.relative_to(ROOT)}") - else: - print(" conformance docs already up to date.") - # 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__": - raise SystemExit(main(sys.argv[1:])) diff --git a/scripts/gen_readmes.py b/scripts/gen_readmes.py index 354e97de4..3c47a7632 100755 --- a/scripts/gen_readmes.py +++ b/scripts/gen_readmes.py @@ -2,12 +2,15 @@ # 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]). +Basilisk's front page is published to five storefronts — GitHub, the VS Code +Marketplace / Open VSX (one VSIX, one file), PyPI, the Zed extension registry, +and the Neovim plugin listing. They used to be hand-maintained files, so they +drifted ([README-PURPOSE]). Now +`docs/readme/README.src.md` 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]). The statement itself is +substituted from the messaging spec ([WITHDRAWAL-COPY]), so no storefront can be +edited into saying something the spec does not. Usage: python3 scripts/gen_readmes.py # rewrite the generated READMEs @@ -21,6 +24,8 @@ from dataclasses import dataclass from pathlib import Path +from gen_withdrawal_copy import copy_blocks + ROOT = Path(__file__).resolve().parents[1] SOURCE_DIR = ROOT / "docs" / "readme" @@ -40,7 +45,6 @@ class Target: key: str output: Path - alt_lang_href: str @dataclass(frozen=True) @@ -51,27 +55,19 @@ class Source: targets: tuple[Target, ...] -VSIX_README_EN = f"{REPO_BLOB}/vscode-extension/README.md" -VSIX_README_ZH = f"{REPO_BLOB}/vscode-extension/README.zh.md" - +# One language. The approved copy exists in English only +# ([WITHDRAWAL-COPY]); a Chinese README could only be an unapproved translation +# of a statement about being wrong, so the Chinese front pages are withdrawn +# rather than left carrying the old marketing. 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 - ), + Target("github", ROOT / "README.md"), + Target("vscode", ROOT / "vscode-extension" / "README.md"), + Target("pypi", ROOT / "README-pypi.md"), + Target("zed", ROOT / "basilisk-zed" / "README.md"), + Target("nvim", ROOT / "basilisk.nvim" / "README.md"), ), ), ) @@ -141,15 +137,34 @@ def html(match: re.Match[str]) -> str: return HTML_ATTR_RE.sub(html, MD_LINK_RE.sub(markdown, text)) +def withdrawal_tokens() -> dict[str, str]: + """`{{withdrawal:…}}` → the approved copy, as markdown. + + Implements [WITHDRAWAL-SURFACES]: a published README carries the statement, + and the statement has exactly one author — the messaging spec. Substituting + it here means a README cannot be edited into saying something else, and + `--check` fails the moment one is. + """ + copy = copy_blocks() + return { + "{{withdrawal:title}}": copy.title, + "{{withdrawal:line}}": copy.line, + "{{withdrawal:short}}": "\n\n".join(copy.short), + "{{withdrawal:action}}": "\n\n".join(copy.action), + "{{withdrawal:full}}": "\n\n".join(copy.full), + } + + 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]). + The three [README-RENDER] transforms, in the order the spec fixes. + `{{withdrawal:…}}` substitution is transform 2: the approved copy, identical + for every target. """ body = apply_variants(source_text, target.key) - body = body.replace("{{altLangHref}}", target.alt_lang_href) + for token, text in withdrawal_tokens().items(): + body = body.replace(token, text) if target.key != "github": body = absolutise_links(body) return GENERATED_BANNER.format(source=source_name) + body @@ -295,7 +310,7 @@ def main(argv: list[str]) -> int: 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" + " Edit docs/readme/README.src.md, then run:\n" " python3 scripts/gen_readmes.py", file=sys.stderr, ) diff --git a/scripts/gen_release_notes.py b/scripts/gen_release_notes.py index 19f4eaf43..1b80ee408 100755 --- a/scripts/gen_release_notes.py +++ b/scripts/gen_release_notes.py @@ -1,72 +1,56 @@ #!/usr/bin/env python3 -"""Generate the release-notes component block. Implements [LSPFMT-RELEASE-NOTES]. +"""Generate the body of the final release. -Usage: gen_release_notes.py BASILISK_BINARY RELEASE_VERSION [MANIFEST] +Implements [WITHDRAWAL-SURFACES]. A GitHub Release is a public surface, and this +one is the last: it carries the inert CLI to every installed copy. Auto-generated +"what's changed" notes would list commits under a heading that reads like a +product update, so the body is the approved statement instead — copied from +docs/specs/DOCS-WITHDRAWAL-MESSAGING-SPEC.md, never written here. -Enumerates every shipwright.json component plus the embedded Ruff formatter -version, read from the actual release binary — generated, never hand-typed, -so the notes cannot claim different formatter bytes from the build -(docs/specs/LSP-FORMATTING-SPEC.md#LSPFMT-RELEASE-NOTES). + python3 scripts/gen_release_notes.py v0.42.0 > release-notes.md """ from __future__ import annotations -import json -import re -import subprocess import sys from pathlib import Path +sys.path.insert(0, str(Path(__file__).resolve().parent)) -def embedded_ruff_version(binary: str) -> str: - """The `Ruff formatter: X` version the binary itself reports.""" - out = subprocess.run( - [binary, "--version"], - check=True, - capture_output=True, - text=True, - timeout=60, - ).stdout - match = re.search(r"^Ruff formatter: (\S+)$", out, re.MULTILINE) - if match is None: - msg = "binary --version did not report an embedded Ruff formatter line" - raise RuntimeError(msg) - return match.group(1) +from gen_withdrawal_copy import copy_blocks # noqa: E402 -def component_rows(manifest: dict, release_version: str) -> list[str]: - """One table row per shipwright.json component.""" - rows: list[str] = [] - for component in manifest["components"]: - declared = component.get("expectedVersion", "") - version = ( - release_version if declared == "${PRODUCT_VERSION}" else (declared or "—") - ) - rows.append(f"| `{component['id']}` | {component['kind']} | {version} |") - return rows +def notes(version: str) -> str: + """The release body: the statement, then what this build does.""" + copy = copy_blocks() + body = [f"# {copy.title}", ""] + for block in (copy.short, copy.action): + for paragraph in block: + body += [paragraph, ""] + body += [ + "## This release", + "", + f"`{version}` is the final Basilisk release. It exists to deliver the " + "statement above to installations that already exist:", + "", + "- The `basilisk` CLI is inert. Every invocation prints the statement to " + "stderr and exits `4`. It reads no file, starts no server, and checks nothing.", + "- The VS Code extension bundles no checker. It shows the statement and " + "contributes nothing else.", + "- The Neovim plugin starts no language server. It shows the statement.", + "", + "Every distribution channel is unlisted immediately after this release. " + "Earlier releases stay published: deleting them would destroy the record.", + "", + ] + return "\n".join(body) def main(argv: list[str]) -> int: - if len(argv) < 3: + if len(argv) != 2: print(__doc__, file=sys.stderr) return 2 - binary, release_version = argv[1], argv[2] - manifest_path = ( - Path(argv[3]) - if len(argv) > 3 - else Path(__file__).resolve().parent.parent / "shipwright.json" - ) - manifest = json.loads(manifest_path.read_text(encoding="utf-8")) - lines = [ - "## Components", - "", - "| Component | Kind | Version |", - "|---|---|---|", - *component_rows(manifest, release_version), - "", - f"Embedded Ruff formatter: `{embedded_ruff_version(binary)}`", - ] - print("\n".join(lines)) + print(notes(argv[1]), end="") return 0 diff --git a/scripts/gen_rules_reference.py b/scripts/gen_rules_reference.py deleted file mode 100644 index 170d27fff..000000000 --- a/scripts/gen_rules_reference.py +++ /dev/null @@ -1,636 +0,0 @@ -#!/usr/bin/env python3 -"""Generate the canonical diagnostic-code reference from the checker source. - -Single source of truth: the diagnostic-code header — and the doc-comment body -beneath it — on each rule module under crates/basilisk-checker/src/rules/. A -header is either an opt-in `//! BSK-E####: ` (`E`, `W`, or `I`) code -or a PEP-conformance `//! `code_name`: ` code (the conformance -rules are named after their python/typing conformance test, e.g. -``//! `protocols_explicit`: ...``). Both styles are extracted so every code the -CLI can emit gets a page. - -Usage: - python3 scripts/gen_rules_reference.py # print a Markdown table - python3 scripts/gen_rules_reference.py --json # emit code->summary JSON - python3 scripts/gen_rules_reference.py --data [OUT] # write the rich rules - # data Eleventy consumes - # (default: website/src/ - # _data/rules.json) - python3 scripts/gen_rules_reference.py --check FILE # verify FILE contains - # every current code - -This is the generator behind [WEBSITE-ERROR-PAGES-PURPOSE]: a landing page for -EVERY diagnostic code, built from the checker source so the pages can never drift -from the diagnostics the binary emits. -The `--data` output ([WEBSITE-ERROR-PAGES-DATA]) drives both the complete -reference table and the per-code /errors/BSK-XXXX/ pages on the website, so the -pages the CLI deep-links to (`see: https://www.basilisk-python.dev/errors/BSK-EXXXX`) -can never drift from the checker. The `--check` mode backs the CI drift guard -([WEBSITE-ERROR-PAGES-DRIFT]). Run it after adding or renaming a rule. See -docs/specs/WEBSITE-ERROR-PAGES-SPEC.md [WEBSITE-ERROR-PAGES]. -""" - -from __future__ import annotations - -import html -import json -import re -import sys -from csv import DictReader -from pathlib import Path - -ROOT = Path(__file__).resolve().parent.parent -RULES_DIR = ROOT / "crates" / "basilisk-checker" / "src" / "rules" -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 -# `opt_in_spec` to return `Some(..)`; core PEP-conformance rules leave it `None`. -# This reads the checker's real provenance signal (`Rule::opt_in_spec`, the single -# source of rule provenance per [CHKTAG-PROVENANCE]) — never the cosmetic `BSK-` -# code prefix, which [CHKTAG-BSK-PREFIX] declares semantically meaningless. -# `[^{]*` stops at the body brace, so a `Some(` in another fn can't false-match. -OPT_IN = re.compile(r"fn opt_in_spec\b[^{]*\{\s*Some\(") -# The free-form tags an opt-in rule declares (`tags: &["strictness", ..]`). These -# are the checker's own `OptInSpec.tags` — e.g. `strictness` marks the rules that -# make annotations mandatory beyond the spec. Non-greedy up to the first `tags:` -# inside the single opt_in_spec body; `TAG` pulls each quoted entry out. -OPT_IN_TAGS = re.compile( - r"fn opt_in_spec\b[^{]*\{\s*Some\([\s\S]*?tags:\s*&\[([^\]]*)\]" -) -TAG = re.compile(r'"([^"]+)"') - -# Coarse groups for filtering/badging on the website, derived from the rule's -# own tags — codes carry no severity class ([CHKARCH-DIAG-CODES]). -GROUP_BY_TAG = { - "strictness": "Missing Annotations", - "style": "Style", - "redundancy": "Redundancy", - "suppressions": "Suppressions", - "dependencies": "Dependencies", - "imports": "Imports", - "stubs": "Stubs", -} - - -def pep_categories() -> frozenset[str]: - """Read the canonical python/typing category vocabulary used by Basilisk. - - The checker validates the same CSV-backed vocabulary in [CHKTAG-TESTS]. - Reading it here keeps the website consumer on that source instead of - maintaining a parallel category list. - """ - with CONFORMANCE_STATUS.open(encoding="utf-8", newline="") as handle: - return frozenset( - row["category"] for row in DictReader(handle) if row.get("category") - ) - - -PEP_CATEGORIES = pep_categories() - - -def clean(text: str) -> str: - return re.sub(r"\s+", " ", text.strip().rstrip(".").strip()) - - -def is_bsk(code: str) -> bool: - return code.startswith("BSK-") - - -def scope_for(provenance: str) -> str: - # The command partition [CHKARCH-COMMANDS]: pep-tagged rules belong to - # `basilisk check` (always run); everything else to `basilisk analyze`. - return "check" if provenance == "pep" else "analyze" - - -def sort_key(code: str) -> tuple[int, int, str]: - # BSK codes first (numeric), then named conformance codes alphabetically. - if is_bsk(code): - return (0, int(code[4:]), "") - return (1, 0, code) - - -def group_for(code: str, free_form_tags: list[str]) -> str: - if not is_bsk(code): - # Named conformance rules span the broad type-system surface. - return "Type System" - for tag in free_form_tags: - if tag in GROUP_BY_TAG: - return GROUP_BY_TAG[tag] - 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, 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 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 - - -# [STUBRES-TYPESHED-WARN] / [STUBRES-TYPESHED-CONFIG]: the typeshed -# source-status advisories are Basilisk's OWN house diagnostics, emitted by the -# stub-resolution layer (crates/basilisk-stubs/src/typeshed/warning.rs) rather -# than a checker rule, so the file-scanning extractor below never sees them. -# They deep-link to their own /errors/ page and are graded like any rule -# via [tool.basilisk.rules] / [tool.basilisk.rule-tags], so they belong in the -# same generated reference as every other code. Documented here from the shared -# spec (docs/specs/CHECKER-STUB-RESOLUTION-SPEC.md#STUBRES-TYPESHED-WARN); the -# prose is kept in agreement with the Rust `message()` and the spec status table. -TYPESHED_STATUS_REFERENCES = [ - {"label": "python/typeshed", "url": "https://github.com/python/typeshed"}, - { - "label": "Basilisk configuration: typeshed source", - "url": "https://www.basilisk-python.dev/docs/configuration/", - }, -] - -TYPESHED_STATUS_SPECS = [ - { - "code": "typeshed_source_unpinned", - "summary": ( - "The active typeshed source is not pinned to an exact commit, so " - "type checks are not reproducible across machines and CI" - ), - "body": [ - ( - "text", - "Basilisk type-checks your code against `typeshed`, the " - "community's standard-library and third-party type stubs. Which " - "revision of typeshed is active decides which symbols and " - "signatures exist, so two machines resolving different typeshed " - "contents can disagree about whether the same code type-checks.", - ), - ( - "text", - "Basilisk bundles a vetted typeshed snapshot inside the binary " - "and serves it by default. A build-time snapshot is not a *user* " - "pin: upgrade Basilisk and the snapshot moves. When no " - "`typeshed-commit` is set — or when a custom `typeshed-path` " - "folder is used, whose contents can change on disk — Basilisk " - "raises this advisory to say the type-checking baseline is not " - "reproducible.", - ), - ( - "text", - "Pin an exact `python/typeshed` commit so every machine and CI " - "run resolves byte-identical stubs. A pin fails closed — " - "Basilisk never silently substitutes another commit:", - ), - ( - "code", - "toml", - '[tool.basilisk]\ntypeshed-commit = "…full 40-character SHA…"', - ), - ( - "text", - "This is an ordinary Basilisk diagnostic. Grade it like any rule " - "— raise it to an error in CI, or silence it once you have " - "accepted the unpinned default:", - ), - ( - "code", - "toml", - "[tool.basilisk.rules]\n" - '"typeshed_source_unpinned" = "error" # or "off" to silence', - ), - ( - "text", - "It is reported out of band — on the CLI's stderr banner, in the " - "editor's Server Info panel, and as MCP status — and never as a " - "Python diagnostic, so it can never affect conformance.", - ), - ], - "references": TYPESHED_STATUS_REFERENCES, - }, - { - "code": "typeshed_source_user_managed", - "summary": ( - "A custom typeshed folder is user-managed: you supply its license " - "and contents, so typeshed's license terms are not applied to it" - ), - "body": [ - ( - "text", - "When you point Basilisk at a custom `typeshed-path` folder, " - "Basilisk treats it as user-managed: you supply both its " - "contents and its license. Basilisk does not attach " - "`python/typeshed`'s license terms to a tree it did not vet.", - ), - ( - "text", - "This advisory makes that explicit so you never unintentionally " - "rely on a custom tree believing it carries typeshed's license, " - "or skip the pin and content verification that the bundled and " - "pinned sources enforce.", - ), - ( - "text", - "It composes with `typeshed_source_unpinned` — a custom folder " - "is both unpinned and user-managed. Grade it like any rule:", - ), - ( - "code", - "toml", - "[tool.basilisk.rules]\n" - '"typeshed_source_user_managed" = "warning" # or "off" to silence', - ), - ( - "text", - "It is reported out of band (CLI banner, Server Info, MCP " - "status), never as a Python diagnostic, so it can never affect " - "conformance.", - ), - ], - "references": TYPESHED_STATUS_REFERENCES, - }, - { - "code": "typeshed_source_license_changed", - "summary": ( - "The bundled typeshed's approved LICENSE/NOTICE changed and " - "activation was blocked pending review" - ), - "body": [ - ( - "text", - "Basilisk vets the LICENSE and NOTICE files of the typeshed " - "snapshot it bundles at build time and records their exact " - "identity. If those legal files no longer match what was " - "approved, Basilisk refuses to serve the stubs rather than " - "distribute content under unknown terms.", - ), - ( - "text", - "This condition is elevated: it defaults to `error`, and " - "analysis for the affected root does not run until it is " - "resolved. Update Basilisk to a build whose bundled typeshed " - "license is approved again.", - ), - ( - "text", - "Like any Basilisk diagnostic it can be graded, though lowering " - "it does not make the underlying license mismatch safe:", - ), - ( - "code", - "toml", - '[tool.basilisk.rules]\n"typeshed_source_license_changed" = "error"', - ), - ( - "text", - "It is reported out of band (CLI banner, an editor " - "`window/showMessage`, MCP status), never as a Python " - "diagnostic, so it can never affect conformance.", - ), - ], - "references": [ - { - "label": "python/typeshed LICENSE", - "url": "https://github.com/python/typeshed/blob/main/LICENSE", - }, - *TYPESHED_STATUS_REFERENCES, - ], - }, -] - - -def typeshed_status_records() -> list[dict]: - """The three typeshed source-status advisories as reference records. - - Implements [WEBSITE-ERROR-PAGES-PURPOSE] for the stub-resolution - advisories: they get the SAME /errors/ pages as every checker code, - built from a single description that agrees with the Rust `message()` and - the spec status table ([STUBRES-TYPESHED-WARN]). - """ - records: list[dict] = [] - for spec in TYPESHED_STATUS_SPECS: - body: list[dict] = [] - for block in spec["body"]: - if block[0] == "text": - body.append({"type": "text", "html": inline_html(block[1])}) - else: - body.append({"type": "code", "lang": block[1], "code": block[2]}) - summary = clean(spec["summary"]) - records.append( - { - "code": spec["code"], - "scope": "analyze", - "provenance": "basilisk", - "tags": ["basilisk", "stubs"], - "summary": summary, - "summaryHtml": inline_html(summary), - "body": body, - "group": "Stubs", - "docsUrl": f"{ERRORS_BASE_URL}/{spec['code']}", - "references": spec["references"], - } - ) - return records - - -ENDS_SENTENCE = (".", "!", ")", ":") -FENCE = re.compile(r"^```(\w*)\s*$") - - -def is_text_line(line: str) -> bool: - return line != "" and not FENCE.match(line) and not SPEC_REF.match(line) - - -def parse_body(doc_lines: list[str]) -> list[dict]: - """Turn the doc-comment lines beneath a header into typed blocks: text - paragraphs (safe inline HTML) and fenced code blocks (raw, escaped by the - template). The spec-reference line is dropped.""" - blocks: list[dict] = [] - paragraph: list[str] = [] - code: list[str] | None = None - lang = "python" - - def flush_paragraph() -> None: - nonlocal paragraph - if paragraph: - blocks.append({"type": "text", "html": inline_html(" ".join(paragraph))}) - paragraph = [] - - for line in doc_lines: - fence = FENCE.match(line) - if code is not None: - if fence: - blocks.append({"type": "code", "lang": lang, "code": "\n".join(code)}) - code = None - else: - code.append(line) - continue - if fence: - flush_paragraph() - code = [] - lang = fence.group(1) or "text" - continue - if line == "": - flush_paragraph() - continue - if SPEC_REF.match(line): - continue - paragraph.append(line) - flush_paragraph() - if code: # unterminated fence — keep the content rather than drop it - blocks.append({"type": "code", "lang": lang, "code": "\n".join(code)}) - return blocks - - -# Implements [WEBSITE-ERROR-PAGES-PURPOSE]: build one record per diagnostic code -# directly from the checker rule sources, so the generated /errors// pages -# can never drift from the diagnostics the binary actually emits. -def extract() -> list[dict]: - """One record per code, including its canonical checker tag set.""" - records: dict[str, dict] = {} - for path in sorted(RULES_DIR.rglob("*.rs")): - text = path.read_text(encoding="utf-8") - lines = text.splitlines() - file_docs_url = DOCS_URL.search(text) - # Provenance and opt-in tags come from the rule's own opt_in_spec, not - # its cosmetic code prefix. PEP category tags use the same canonical - # conformance CSV vocabulary validated by rule_tags.rs. - provenance = "basilisk" if OPT_IN.search(text) else "pep" - tags_match = OPT_IN_TAGS.search(text) - free_form_tags = TAG.findall(tags_match.group(1)) if tags_match else [] - for i, line in enumerate(lines): - m = HEADER.match(line.strip()) - if not m: - continue - code, summary = m.group(1).strip("`"), m.group(2) - if code in records: - continue - # The contiguous //! doc lines following the header line. - body_lines: list[str] = [] - for follow in lines[i + 1 :]: - doc = DOC.match(follow.strip()) - if doc is None: - break - body_lines.append(doc.group(1)) - # Stitch a summary that wrapped onto following doc lines (it ends - # without sentence-final punctuation) before they become body. - while ( - not summary.rstrip().endswith(ENDS_SENTENCE) - and body_lines - and is_text_line(body_lines[0]) - ): - summary = f"{summary} {body_lines.pop(0)}" - category = code.partition("_")[0] - tags = ( - ["basilisk", *free_form_tags] - if provenance == "basilisk" - else ["pep", *([category] if category in PEP_CATEGORIES else [])] - ) - records[code] = { - "code": code, - "scope": scope_for(provenance), - "provenance": provenance, - "tags": tags, - "summary": clean(summary), - "summaryHtml": inline_html(clean(summary)), - "body": parse_body(body_lines), - "group": group_for(code, free_form_tags), - "docsUrl": file_docs_url.group(1) - if file_docs_url - else f"{ERRORS_BASE_URL}/{code}", - "references": references_for(code, " ".join([summary, *body_lines])), - } - # The stub-resolution advisories live outside RULES_DIR (they are not checker - # rules) but earn the same /errors/ pages ([STUBRES-TYPESHED-WARN]). - for record in typeshed_status_records(): - records.setdefault(record["code"], record) - return [records[c] for c in sorted(records, key=sort_key)] - - -def to_markdown(records: list[dict]) -> str: - rows = ["| Code | Description |", "|---|---|"] - for r in records: - rows.append(f"| `{r['code']}` | {r['summary']} |") - return "\n".join(rows) - - -def main() -> int: - records = extract() - if "--json" in sys.argv: - print(json.dumps({r["code"]: r["summary"] for r in records}, indent=2)) - return 0 - if "--data" in sys.argv: - # [WEBSITE-ERROR-PAGES-DATA]: write website/src/_data/rules.json — one - # record per code (summary, body blocks, scope, group, docsUrl). - idx = sys.argv.index("--data") - out = Path(sys.argv[idx + 1]) if idx + 1 < len(sys.argv) else DEFAULT_DATA_OUT - out.write_text(json.dumps(records, indent=2) + "\n", encoding="utf-8") - check = sum(r["scope"] == "check" for r in records) - analyze = len(records) - check - print( - f"Wrote {len(records)} codes ({check} check-scope PEP rules, " - f"{analyze} analyze-scope Basilisk rules) -> {out}" - ) - return 0 - if "--check" in sys.argv: - # [WEBSITE-ERROR-PAGES-DRIFT]: assert FILE contains every current code so - # CI fails when a rule is added/renamed without regenerating rules.json. - target = Path(sys.argv[sys.argv.index("--check") + 1]).read_text( - encoding="utf-8" - ) - missing = [r["code"] for r in records if r["code"] not in target] - if missing: - print(f"MISSING {len(missing)} codes: {', '.join(missing)}") - return 1 - print(f"OK: all {len(records)} codes present") - return 0 - print(to_markdown(records)) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/gen_withdrawal_copy.py b/scripts/gen_withdrawal_copy.py new file mode 100644 index 000000000..2983ff082 --- /dev/null +++ b/scripts/gen_withdrawal_copy.py @@ -0,0 +1,281 @@ +#!/usr/bin/env python3 +"""Extract the approved withdrawal copy from the messaging spec into site data. + +Implements [WITHDRAWAL-COPY]. The single source of truth for everything Basilisk +says publicly is docs/specs/DOCS-WITHDRAWAL-MESSAGING-SPEC.md; this script lifts +its approved copy blocks out into website/src/_data/withdrawal.json so the site +renders the spec's words rather than a hand-typed copy of them. `copy_blocks()` +serves the same text as markdown to scripts/gen_readmes.py, so the site and every +published README are two renderings of one source. + + python3 scripts/gen_withdrawal_copy.py # write the data file + python3 scripts/gen_withdrawal_copy.py --check # fail if it has drifted + +Run --check in CI: the site must never say something the spec does not. +""" + +from __future__ import annotations + +import argparse +import html +import json +import re +import sys +from dataclasses import dataclass +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent +SPEC_PATH = REPO_ROOT / "docs/specs/DOCS-WITHDRAWAL-MESSAGING-SPEC.md" +DATA_PATH = REPO_ROOT / "website/src/_data/withdrawal.json" +# The CLI and every editor extension print the SAME bytes the spec authored: +# each reads a generated file rather than a hand-typed string +# ([WITHDRAWAL-INERT-TEXT]). +CLI_NOTICE_PATH = REPO_ROOT / "crates/basilisk-cli/src/withdrawal_notice.txt" +VSIX_NOTICE_PATH = REPO_ROOT / "vscode-extension/src/withdrawal-notice.ts" +NVIM_NOTICE_PATH = REPO_ROOT / "basilisk.nvim/lua/basilisk/notice.lua" +NVIM_DOC_PATH = REPO_ROOT / "basilisk.nvim/doc/basilisk.txt" +ZED_NOTICE_PATH = REPO_ROOT / "basilisk-zed/src/withdrawal_notice.txt" + +# The anchors naming each approved block in the spec. +ANCHOR_LINE = "{#WITHDRAWAL-COPY-LINE}" +ANCHOR_SHORT = "{#WITHDRAWAL-COPY-SHORT}" +ANCHOR_ACTION = "{#WITHDRAWAL-COPY-ACTION}" +ANCHOR_FULL = "{#WITHDRAWAL-COPY-FULL}" +ANCHOR_NOTICE = "{#WITHDRAWAL-INERT-TEXT}" + +CODE_RE = re.compile(r"`([^`]+)`") +LINK_RE = re.compile(r"\[([^\]]+)\]\(([^)]+)\)") +BOLD_RE = re.compile(r"\*\*([^*]+)\*\*") +ITALIC_RE = re.compile(r"\*([^*]+)\*") + + +class SpecError(RuntimeError): + """The spec is missing a block this script is required to publish.""" + + +def blockquote_after(lines: list[str], anchor: str) -> list[str]: + """Return the paragraphs of the blockquote following `anchor`. + + Paragraphs are joined to one line each: the spec is authored unwrapped, but a + blockquote may still carry several paragraphs separated by a bare `>`. + """ + try: + start = next(i for i, line in enumerate(lines) if anchor in line) + except StopIteration: + raise SpecError(f"{SPEC_PATH.name} has no {anchor} block") from None + + cursor = start + 1 + while cursor < len(lines) and not lines[cursor].strip(): + cursor += 1 + + quoted: list[str] = [] + while cursor < len(lines) and lines[cursor].startswith(">"): + quoted.append(lines[cursor].removeprefix(">").strip()) + cursor += 1 + + if not quoted: + raise SpecError(f"{anchor} in {SPEC_PATH.name} is not followed by a blockquote") + + # A bare `>` closes a paragraph; consecutive text lines join, so the block + # survives an editor re-wrapping the spec. + paragraphs: list[str] = [] + open_paragraph = False + for chunk in quoted: + if not chunk: + open_paragraph = False + elif open_paragraph: + paragraphs[-1] = f"{paragraphs[-1]} {chunk}" + else: + paragraphs.append(chunk) + open_paragraph = True + return paragraphs + + +def fenced_after(lines: list[str], anchor: str) -> str: + """Return the fenced code block following `anchor`, verbatim. + + This is the text the inert CLI and the extension print, so it is lifted + byte-for-byte: no wrapping, no markdown, no substitution. + """ + try: + start = next(i for i, line in enumerate(lines) if anchor in line) + except StopIteration: + raise SpecError(f"{SPEC_PATH.name} has no {anchor} block") from None + + opened = False + body: list[str] = [] + for line in lines[start + 1 :]: + if line.startswith("```"): + if opened: + return "\n".join(body) + "\n" + opened = True + elif opened: + body.append(line) + raise SpecError(f"{anchor} in {SPEC_PATH.name} has no closing code fence") + + +def to_html(markdown: str) -> str: + """Render the inline markdown the approved copy uses, and nothing else. + + Escaping runs first so the spec's text can never inject markup; the patterns + below then reintroduce exactly the four inline constructs the copy contains. + """ + text = html.escape(markdown, quote=False) + text = CODE_RE.sub(r"\1", text) + text = LINK_RE.sub(r'\1', text) + text = BOLD_RE.sub(r"\1", text) + return ITALIC_RE.sub(r"\1", text) + + +@dataclass(frozen=True) +class Copy: + """The approved blocks, as the markdown the spec authored. + + Each consumer renders this for its own medium: the site converts to HTML, + the published READMEs use the markdown unchanged. + """ + + line: str + title: str + short: tuple[str, ...] + action: tuple[str, ...] + full: tuple[str, ...] + + +def copy_blocks() -> Copy: + """Extract every approved block from the messaging spec.""" + lines = SPEC_PATH.read_text(encoding="utf-8").splitlines() + + one_line = blockquote_after(lines, ANCHOR_LINE) + if len(one_line) != 1: + raise SpecError(f"{ANCHOR_LINE} must be exactly one paragraph") + + full = blockquote_after(lines, ANCHOR_FULL) + if not full or not full[0].startswith("# "): + raise SpecError(f"{ANCHOR_FULL} must open with a level-1 heading") + + return Copy( + line=one_line[0], + title=full[0].removeprefix("# ").strip(), + short=tuple(blockquote_after(lines, ANCHOR_SHORT)), + action=tuple(blockquote_after(lines, ANCHOR_ACTION)), + full=tuple(full[1:]), + ) + + +def build() -> dict[str, object]: + """Assemble the site data payload from the spec's approved blocks.""" + copy = copy_blocks() + return { + "_generated": f"Generated from {SPEC_PATH.relative_to(REPO_ROOT)} " + "by scripts/gen_withdrawal_copy.py — DO NOT EDIT.", + "line": copy.line, + "title": copy.title, + "short": [to_html(p) for p in copy.short], + "action": [to_html(p) for p in copy.action], + "full": [to_html(p) for p in copy.full], + # The same block as markdown, for surfaces that are not HTML. llms.txt + # is read by machines: stripping the tags out of the HTML would drop + # the source links the copy carries, and rewriting the copy without + # them would be a fourth variant of the message. + "full_markdown": list(copy.full), + } + + +def notice_text() -> str: + """The exact bytes the inert CLI and the extension print.""" + return fenced_after( + SPEC_PATH.read_text(encoding="utf-8").splitlines(), ANCHOR_NOTICE + ) + + +def vsix_notice_module(notice: str) -> str: + """The notice as a TypeScript module the extension imports.""" + return ( + "// GENERATED FILE — DO NOT EDIT.\n" + "// Source: docs/specs/DOCS-WITHDRAWAL-MESSAGING-SPEC.md [WITHDRAWAL-INERT-TEXT]\n" + "// Regenerate: python3 scripts/gen_withdrawal_copy.py\n" + "/** The approved notice, verbatim. */\n" + f"export const WITHDRAWAL_NOTICE = {json.dumps(notice)};\n" + ) + + +def nvim_notice_module(notice: str) -> str: + """The notice as a Lua module the Neovim plugin requires.""" + return ( + "-- GENERATED FILE — DO NOT EDIT.\n" + "-- Source: docs/specs/DOCS-WITHDRAWAL-MESSAGING-SPEC.md [WITHDRAWAL-INERT-TEXT]\n" + "-- Regenerate: python3 scripts/gen_withdrawal_copy.py\n" + f"local text = {json.dumps(notice.rstrip(chr(10)))}\n" + "return {\n" + " text = text,\n" + ' lines = vim.split(text, "\\n", { plain = true }),\n' + "}\n" + ) + + +def nvim_help_doc(notice: str) -> str: + """`:help basilisk` — the statement, and nothing else.""" + return ( + "*basilisk.txt* Basilisk is unlisted\n" + "\n" + "GENERATED FILE — DO NOT EDIT. Source:\n" + "docs/specs/DOCS-WITHDRAWAL-MESSAGING-SPEC.md [WITHDRAWAL-INERT-TEXT]\n" + "\n" + "BASILISK *basilisk*\n" + "\n" + f"{notice}" + "\n" + "vim:tw=78:ts=8:ft=help:norl:\n" + ) + + +def outputs() -> dict[Path, str]: + """Every file generated from the spec, by path.""" + notice = notice_text() + return { + DATA_PATH: json.dumps(build(), indent=2, ensure_ascii=False) + "\n", + CLI_NOTICE_PATH: notice, + VSIX_NOTICE_PATH: vsix_notice_module(notice), + NVIM_NOTICE_PATH: nvim_notice_module(notice), + NVIM_DOC_PATH: nvim_help_doc(notice), + # Zed compiles to WASM, so the notice is `include_str!`d like the CLI's + # rather than escaped into a source literal. + ZED_NOTICE_PATH: notice, + } + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--check", + action="store_true", + help="verify the generated files match the spec instead of writing them", + ) + args = parser.parse_args() + + try: + generated = outputs() + except SpecError as error: + print(f"error: {error}", file=sys.stderr) + return 1 + + drifted = False + for path, payload in generated.items(): + relative = path.relative_to(REPO_ROOT) + if not args.check: + path.write_text(payload, encoding="utf-8") + print(f"wrote {relative}") + continue + if (path.read_text(encoding="utf-8") if path.exists() else "") != payload: + print( + f"error: {relative} has drifted from {SPEC_PATH.name}", file=sys.stderr + ) + drifted = True + if drifted: + print("Run: python3 scripts/gen_withdrawal_copy.py", file=sys.stderr) + return 1 if drifted else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/render-zed-mirror.sh b/scripts/render-zed-mirror.sh index 8cdd4a8be..fc00c7193 100755 --- a/scripts/render-zed-mirror.sh +++ b/scripts/render-zed-mirror.sh @@ -1,28 +1,28 @@ #!/usr/bin/env bash # Render a self-contained, version-stamped Zed extension tree for the -# Nimblesite/basilisk-zed mirror. Implements [ZED-DIST] / [ZED-MIRROR]; +# Nimblesite/basilisk-zed mirror. Implements [ZED-MIRROR]; # see docs/specs/ZED-SPEC.md#ZED-MIRROR. # -# Why a render step exists. The in-repo basilisk-zed/ crate (a) carries the -# 0.0.0-PLACEHOLDER version that is stamped only during CI, and (b) depends on -# basilisk-common via a *workspace* path (`../crates/basilisk-common`). The Zed -# extension registry (zed-industries/extensions) pins a commit and compiles the -# extension to WASM *standalone*, with no monorepo around it, so it can neither -# pin `main` (placeholder version) nor build it (unresolvable path dep). This -# script produces a tree that the registry can build on its own: +# Why a render step exists. The in-repo basilisk-zed/ crate carries the +# 0.0.0-PLACEHOLDER version that is stamped only during CI, and inherits its +# [lints] from the workspace. The Zed extension registry +# (zed-industries/extensions) pins a commit and compiles the extension to WASM +# *standalone*, with no monorepo around it, so it can neither pin `main` +# (placeholder version) nor resolve workspace inheritance. This script produces +# a tree that the registry can build on its own: # -# * vendors basilisk-common (zero-dep, WASM-safe) under vendor/basilisk-common -# * rewrites the extension manifest's path dep to the vendored copy # * makes the mirror dir its own workspace root (empty [workspace] table) so # cargo does not search upward for a parent workspace -# * stamps the release version into Cargo.toml + extension.toml + the -# vendored crate +# * stamps the release version into Cargo.toml + extension.toml # * drops workspace-only [lints] inheritance (no parent workspace to inherit # from in the mirror — lint strictness is enforced by the monorepo `zed` CI # job, not by the distribution render) # * omits committed build artifacts (extension.wasm, dist/, stale Cargo.lock); # the publish job regenerates Cargo.lock via the standalone WASM build gate # +# There is no vendoring step any more: the extension states that Basilisk is +# unlisted and does nothing else, so `zed_extension_api` is its only dependency. +# # Usage: # scripts/render-zed-mirror.sh [version] # GITHUB_REF_NAME=v0.1.0 scripts/render-zed-mirror.sh out/ @@ -32,20 +32,14 @@ set -euo pipefail readonly PLACEHOLDER="0.0.0-PLACEHOLDER" # Curated set copied verbatim from basilisk-zed/ into the mirror root. Anything -# not listed here (extension.wasm, dist/, Cargo.lock, tests/) is intentionally -# excluded — the registry needs only the manifest, sources, and language assets. +# not listed here (extension.wasm, dist/, stale Cargo.lock) is intentionally +# excluded — the registry needs only the manifest and the sources. readonly COPY_ITEMS=( "extension.toml" "Cargo.toml" "README.md" - # README.md links to the Chinese translation with a *relative* href, so the - # mirror must carry it too or the published landing page has a dead link. - "README.zh.md" "LICENSE" "src" - "themes" - "debug_adapter_schemas" - "images" ) resolve_version() { @@ -73,25 +67,6 @@ repo_root() { cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd } -# Read a `key = "value"` field from the [workspace.package] table of the root -# Cargo.toml so the vendored crate's concrete metadata stays in lockstep with -# the workspace rather than hardcoding duplicate values here. -ws_package_field() { - local key="$1" file="$2" - awk -v key="$key" ' - /^\[workspace\.package\]/ { in_section = 1; next } - /^\[/ { in_section = 0 } - in_section && $1 == key { - # Strip everything up to the first quote and the trailing quote. - line = $0 - sub(/^[^"]*"/, "", line) - sub(/".*$/, "", line) - print line - exit - } - ' "$file" -} - # Drop a whole [lints] table from a Cargo.toml emitted on stdout. The mirror has # no parent workspace, so `[lints]\nworkspace = true` would fail to resolve. strip_lints_table() { @@ -130,54 +105,16 @@ copy_extension_tree() { local item for item in "${COPY_ITEMS[@]}"; do if [[ -e "$src/$item" ]]; then - # -L dereferences symlinks so the mirror is self-contained: e.g. - # images/zed-screenshot.png links into website/, which does not exist - # in the standalone tree — copy the real file, not a dangling link. cp -RL "$src/$item" "$dest/$item" fi done } -# Vendor basilisk-common as a standalone crate: concrete metadata (from the -# workspace package table) replaces every `*.workspace = true` inheritance, and -# the [lints] table is dropped. -vendor_common() { - local root="$1" dest="$2" version="$3" - local out="$dest/vendor/basilisk-common" - mkdir -p "$dest/vendor" - cp -R "$root/crates/basilisk-common" "$out" - rm -rf "$out/target" - - local edition repository rust_version license - edition="$(ws_package_field "edition" "$root/Cargo.toml")" - repository="$(ws_package_field "repository" "$root/Cargo.toml")" - rust_version="$(ws_package_field "rust-version" "$root/Cargo.toml")" - license="$(ws_package_field "license" "$root/Cargo.toml")" - - # Resolve the non-version workspace-inherited metadata to concrete literals. - # The version is stamped structurally below (not via sed) per §3.3. - local manifest="$out/Cargo.toml" - sed -i.bak \ - -e "s|^edition\.workspace = true|edition = \"${edition}\"|" \ - -e "s|^license\.workspace = true|license = \"${license}\"|" \ - -e "s|^repository\.workspace = true|repository = \"${repository}\"|" \ - -e "s|^rust-version\.workspace = true|rust-version = \"${rust_version}\"|" \ - "$manifest" - rm -f "${manifest}.bak" - stamp_toml_version "$manifest" "$version" - strip_lints_table < "$manifest" > "${manifest}.stripped" - mv "${manifest}.stripped" "$manifest" -} - -# Rewrite the extension manifest: vendored path dep, no workspace lints, an -# explicit empty [workspace] so the mirror dir is its own root, stamped version. +# Rewrite the extension manifest: no workspace lints, an explicit empty +# [workspace] so the mirror dir is its own root, stamped version. render_extension_manifest() { local dest="$1" version="$2" local manifest="$dest/Cargo.toml" - sed -i.bak \ - -e 's|path = "../crates/basilisk-common"|path = "vendor/basilisk-common"|' \ - "$manifest" - rm -f "${manifest}.bak" strip_lints_table < "$manifest" > "${manifest}.stripped" mv "${manifest}.stripped" "$manifest" stamp_toml_version "$manifest" "$version" @@ -205,7 +142,6 @@ main() { echo "Rendering Zed mirror (version=${version}) -> ${dest}" clear_dest "$dest" copy_extension_tree "$root/basilisk-zed" "$dest" - vendor_common "$root" "$dest" "$version" render_extension_manifest "$dest" "$version" stamp_toml_version "$dest/extension.toml" "$version" diff --git a/scripts/stamp-version.sh b/scripts/stamp-version.sh index 89bbfb8ec..430093151 100755 --- a/scripts/stamp-version.sh +++ b/scripts/stamp-version.sh @@ -19,13 +19,19 @@ readonly PLACEHOLDER="0.0.0-PLACEHOLDER" # Files that take the full SemVer including any pre-release suffix # (e.g. `0.1.0-alpha`). Git tags, Cargo, our own binaries, the shipwright -# contract, the website data, and the GitHub release all carry this. +# contract, and the GitHub release all carry this. +# +# `website/src/_data/site.json` used to be here. It is gone: the site is one +# statement page and displays no version, so its metadata is now derived in +# `site.js` from the generated withdrawal copy. A carrier listed here that does +# not exist is fatal (`stamp_file` exits 2), and this script is the FIRST step +# of the build, release, vsix and pypi-wheels jobs — leaving it listed failed +# every one of them, so no release could ship at all. readonly FILES=( "Cargo.toml" "basilisk-zed/Cargo.toml" "basilisk-zed/extension.toml" "shipwright.json" - "website/src/_data/site.json" ) # Files that take a Marketplace-legal MAJOR.MINOR.PATCH only. The VS diff --git a/scripts/test-nvim.sh b/scripts/test-nvim.sh index b7acbec7a..843c27a0e 100755 --- a/scripts/test-nvim.sh +++ b/scripts/test-nvim.sh @@ -1,8 +1,13 @@ #!/usr/bin/env bash -# Run Neovim extension real LSP e2e and screenshot regression tests. +# Run the Neovim plugin specs. # -# Requires: nvim 0.11+, basilisk binary. -# Set BASILISK_BIN to override the binary path. +# The plugin is a notice ([WITHDRAWAL-SURFACES]): it starts no language server +# and no debug adapter, so this harness needs no `basilisk` binary, no debugpy, +# and no LSP/DAP/screenshot suites. What is left is plenary and one spec +# directory, gated on PARSED results exactly as before — every spec file must +# run AND summarise with zero failures. +# +# Requires: nvim 0.11+. # # Usage: # ./scripts/test-nvim.sh @@ -13,56 +18,19 @@ REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" source "$REPO_ROOT/scripts/common.sh" cd "$REPO_ROOT" -# Find or build the basilisk binary. When no explicit binary is provided, build -# a fresh non-coverage CLI so local `make ci` does not reuse test-rust's -# coverage-instrumented target/ci artifact. -if [[ -z "${BASILISK_BIN:-}" ]]; then - header "Building basilisk binary" - cargo build --profile ci --bin basilisk - BASILISK_BIN="$REPO_ROOT/target/ci/basilisk" -else - BASILISK_BIN=$(find_basilisk_bin) || { - echo -e "${RED}${BOLD}FATAL: configured basilisk binary not found.${RESET}" - exit 1 - } -fi -if [[ ! -x "$BASILISK_BIN" ]]; then - echo -e "${RED}${BOLD}FATAL: basilisk binary not found.${RESET}" +if ! command -v nvim &>/dev/null; then + echo -e "${RED}${BOLD}FATAL: nvim not found. Install it: brew install neovim${RESET}" >&2 exit 1 fi -export BASILISK_EXECUTABLE_PATH="$BASILISK_BIN" -ok "basilisk binary: $BASILISK_BIN" - -# ── Dependencies ────────────────────────────────────────────────────────────── - -header "Checking dependencies" - -if ! command -v pytest &>/dev/null; then - echo -e "${RED}${BOLD}FATAL: pytest not found. Install it: pip install pytest${RESET}" - exit 1 -fi -ok "pytest: $(pytest --version 2>&1 | head -1)" - -# 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" -# Test plugins live in /tmp (and are restored from the CI cache), so a -# directory existing proves nothing: macOS's /tmp reaper deletes stale FILES and -# leaves the empty directory tree behind, and a cache restore can be partial the -# same way. A hollow checkout fails far away from here — plenary's -# `:PlenaryBustedDirectory` simply does not exist and every spec is "not an -# editor command". So each plugin is validated by a file it MUST provide and -# re-cloned when that file is missing. +# Test plugins live in /tmp (and are restored from the CI cache), so a directory +# existing proves nothing: macOS's /tmp reaper deletes stale FILES and leaves the +# empty tree behind, and a cache restore can be partial the same way. A hollow +# checkout fails far away from here — `:PlenaryBustedDirectory` simply does not +# exist and every spec is "not an editor command". So the plugin is validated by +# a file it MUST provide and re-cloned when that file is missing. ensure_plugin() { local dir="$1" proof="$2" repo="$3" if [[ -f "$dir/$proof" ]]; then @@ -81,117 +49,28 @@ ensure_plugin() { ensure_plugin /tmp/plenary.nvim plugin/plenary.vim \ https://github.com/nvim-lua/plenary.nvim -ensure_plugin /tmp/nvim-dap plugin/dap.lua \ - https://github.com/mfussenegger/nvim-dap -ensure_plugin /tmp/mini.nvim lua/mini/test.lua \ - https://github.com/echasnovski/mini.nvim - -# ── 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. - # - # Gate on PARSED results, not the nvim exit code: under `make ci`'s parallel - # `-j3` load the PlenaryBustedDirectory parent can exit non-zero on teardown - # even when every test passed. assert_plenary_pass requires that every spec - # file ran AND summarised with zero failures/errors/tracebacks — strictly - # stronger than trusting the process exit. See common.sh - # [LSPTEST-EDITOR-SPECIFIC-INTEGRATION-NEOVIM-E2E-GATE]. - expected_specs="$(find tests/lsp -name '*_spec.lua' | wc -l | tr -d ' ')" - lsp_out="$(mktemp)" - - # Per-file timeout: plenary's default is 50s, and the heaviest spec - # (coverage_boost_spec.lua) legitimately needs ~51s against the - # coverage-instrumented LSP binary — the child gets SIGTERMed mid-summary - # and the run fails on "15/16 spec files produced a summary" with zero - # actual test failures. Neovim NIGHTLY (the CI forward-compat matrix leg) - # runs every spec ~3× slower than 0.11, pushing the heaviest file - # (profiler_spec.lua, ~32s on 0.11) to ~2.5min — at 120s plenary SIGTERMed - # it mid-run, its buffered output was lost, and the run mis-read as a - # footer flake. 300s keeps the gate strict (every spec must still - # summarise clean) without truncating slow-but-passing files on either - # matrix leg; a genuinely hung child is still reaped, backstopped by the - # job-level timeout-minutes. - # - # Bounded retry (2 attempts): re-run ONLY when plenary_outcome reports a - # `flake` — every test passed but a spec dropped its per-file `Success:` - # footer under `-j3` load (a batch-mode flush race, not a test failure). A - # real failure (`fail`) breaks out immediately with no retry, so the gate is - # never weakened; assert_plenary_pass below is still the authoritative check. - max_attempts=2 - for attempt in $(seq 1 "$max_attempts"); do - [[ "$attempt" -gt 1 ]] && warn "Neovim LSP e2e: footer flush race on attempt $((attempt - 1)) (all tests passed) — retrying (${attempt}/${max_attempts})" - set +e - LUACOV=1 nvim --headless -u tests/minimal_init.lua \ - -c "PlenaryBustedDirectory tests/lsp {minimal_init = 'tests/minimal_init.lua', sequential = true, timeout = 300000}" 2>&1 \ - | tee "$lsp_out" - nvim_rc=${PIPESTATUS[0]} - set -e - if [[ "$nvim_rc" -ne 0 ]]; then - warn "nvim exited ${nvim_rc} after the LSP suite — validating against parsed results (teardown exit is not authoritative)" - fi - outcome="$(plenary_outcome "$lsp_out" "$expected_specs")" - # Retry only a pure flush-race flake, and only while attempts remain. - [[ "$outcome" == "flake" && "$attempt" -lt "$max_attempts" ]] || break - rm -f luacov.stats.out luacov.report.out - done - - if ! assert_plenary_pass "$lsp_out" "$expected_specs" "Neovim LSP e2e tests"; then - rm -f "$lsp_out" - exit 1 - fi - rm -f "$lsp_out" - ok "Neovim LSP e2e tests passed" - - # Screenshot tests are visual regressions, not coverage inputs. Running - # them with LUACOV can replace LSP coverage stats with screenshot-only data. - nvim --headless -u tests/minimal_init.lua \ - -l tests/ui/run_screenshots.lua 2>&1 - ok "Neovim screenshot regression tests passed" -else - warn "nvim not found — skipping Neovim extension tests" +# Remove stale luacov data so coverage reflects this run only. +rm -f luacov.stats.out luacov.report.out + +header "Neovim extension — plugin specs" +expected_specs="$(find tests/basilisk -name '*_spec.lua' | wc -l | tr -d ' ')" +spec_out="$(mktemp)" +set +e +LUACOV=1 nvim --headless -u tests/minimal_init.lua \ + -c "PlenaryBustedDirectory tests/basilisk {minimal_init = 'tests/minimal_init.lua', sequential = true, timeout = 300000}" 2>&1 \ + | tee "$spec_out" +nvim_rc=${PIPESTATUS[0]} +set -e +if [[ "$nvim_rc" -ne 0 ]]; then + warn "nvim exited ${nvim_rc} after the suite — validating against parsed results (teardown exit is not authoritative)" +fi +if ! assert_plenary_pass "$spec_out" "$expected_specs" "Neovim plugin tests"; then + rm -f "$spec_out" + exit 1 fi +rm -f "$spec_out" +ok "Neovim plugin tests passed" # ── Coverage threshold (local only — skipped on CI) ────────────────────────── # luacov records absolute paths which don't match include patterns across @@ -199,41 +78,39 @@ fi if [[ -n "${CI:-}" ]]; then echo -e " ${YELLOW:-}⊘ neovim: coverage check skipped on CI${RESET}" -else - header "Neovim extension — coverage threshold" - TEST_COVERAGE_NVIM="$(coverage_threshold_for nvim)" + exit 0 +fi - LUACOV=1 nvim --headless -u tests/minimal_init.lua \ - -l tests/run_coverage.lua 2>&1 - ok "Neovim coverage exerciser passed" +header "Neovim extension — coverage threshold" +TEST_COVERAGE_NVIM="$(coverage_threshold_for nvim)" - if [[ ! -f luacov.stats.out ]]; then - echo -e " ${RED}${BOLD}✗ neovim: no luacov stats — coverage collection is broken. FAIL${RESET}" - exit 1 - fi +LUACOV=1 nvim --headless -u tests/minimal_init.lua -l tests/run_coverage.lua 2>&1 +ok "Neovim coverage exerciser passed" - # Generate coverage report. - nvim --headless --noplugin -l tests/generate_report.lua 2>&1 +if [[ ! -f luacov.stats.out ]]; then + echo -e " ${RED}${BOLD}✗ neovim: no luacov stats — coverage collection is broken. FAIL${RESET}" + exit 1 +fi - if [[ ! -f luacov.report.out ]]; then - echo -e " ${RED}${BOLD}✗ neovim: coverage report generation failed. FAIL${RESET}" - exit 1 - fi +nvim --headless --noplugin -l tests/generate_report.lua 2>&1 - # Show summary section. - echo " luacov report summary:" - awk '/^=+$/{s=1} s{print " "$0}' luacov.report.out | tail -20 +if [[ ! -f luacov.report.out ]]; then + echo -e " ${RED}${BOLD}✗ neovim: coverage report generation failed. FAIL${RESET}" + exit 1 +fi - # Parse the Total line from the summary: "Total 977 217 81.83%" - nvim_pct=$(awk '/^Total/ { gsub(/%/, "", $NF); printf "%d", $NF }' luacov.report.out) - if [[ -z "$nvim_pct" || "$nvim_pct" -eq 0 ]]; then - echo -e " ${RED}${BOLD}✗ neovim: could not parse coverage from luacov report. FAIL${RESET}" - exit 1 - fi +echo " luacov report summary:" +awk '/^=+$/{s=1} s{print " "$0}' luacov.report.out | tail -20 - if [[ "$nvim_pct" -lt "$TEST_COVERAGE_NVIM" ]]; then - echo -e " ${RED}✗ neovim: ${nvim_pct}% < ${TEST_COVERAGE_NVIM}% threshold — FAIL${RESET}" - exit 1 - fi - echo -e " ${GREEN}✓ neovim: ${nvim_pct}% ≥ ${TEST_COVERAGE_NVIM}% threshold${RESET}" +# Parse the Total line from the summary: "Total 977 217 81.83%" +nvim_pct=$(awk '/^Total/ { gsub(/%/, "", $NF); printf "%d", $NF }' luacov.report.out) +if [[ -z "$nvim_pct" || "$nvim_pct" -eq 0 ]]; then + echo -e " ${RED}${BOLD}✗ neovim: could not parse coverage from luacov report. FAIL${RESET}" + exit 1 +fi + +if [[ "$nvim_pct" -lt "$TEST_COVERAGE_NVIM" ]]; then + echo -e " ${RED}✗ neovim: ${nvim_pct}% < ${TEST_COVERAGE_NVIM}% threshold — FAIL${RESET}" + exit 1 fi +echo -e " ${GREEN}✓ neovim: ${nvim_pct}% ≥ ${TEST_COVERAGE_NVIM}% threshold${RESET}" diff --git a/scripts/test-rust.sh b/scripts/test-rust.sh index b5e68d09c..02d3ec28f 100755 --- a/scripts/test-rust.sh +++ b/scripts/test-rust.sh @@ -75,14 +75,10 @@ rustup component add llvm-tools-preview 2>/dev/null || true header "Running tests with coverage instrumentation" cargo llvm-cov clean --workspace -# Build the CLEAN release binary the conformance GATE scores — freshly built from -# THIS checkout's source, un-instrumented, byte-for-byte what ships. Built BEFORE -# the llvm-cov env is sourced so NO coverage flags touch it. Coverage for the -# checker/resolver paths the suite exercises comes from a SEPARATE instrumented -# pass further down. The gate must score what ships — never an instrumented build, -# never a prior (PyPI) release. See [CHKARCH-CONFORMANCE]. -header "Freshly building the CLEAN release basilisk binary for the conformance gate" -cargo build --release --bin basilisk +# NO clean release build here any more. It existed only to give the conformance +# GATE an un-instrumented binary to score, and that gate is commented out below +# because the measurement cannot run. Building a second full release copy of a +# binary nothing reads cost ~5 minutes of every run. eval "$(cargo llvm-cov show-env --export-prefix)" diff --git a/scripts/test_check_public_copy.py b/scripts/test_check_public_copy.py new file mode 100644 index 000000000..c76d9baed --- /dev/null +++ b/scripts/test_check_public_copy.py @@ -0,0 +1,102 @@ +#!/usr/bin/env python3 +"""The public-copy scan catches what it claims to, and covers every storefront. + +Implements [WITHDRAWAL-SURFACES]. A scan that matches nothing passes silently +and proves nothing, so each rule is exercised against text it must reject and +against the approved copy it must accept. + + python3 -m pytest scripts/test_check_public_copy.py +""" + +from __future__ import annotations + +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from check_public_copy import REPO_ROOT, RULES, scan, surfaces # noqa: E402 + +# One example per rule id: text that surface must never carry again. +OFFENDING = { + "measured-figure": "Basilisk passed 99.7% of the suite.", + "conformance-score": "Its conformance score was 412 of 500.", + "install-instruction": "Get started: `pip install basilisk-python`.", + "rule-count": "Ships with 340 rules covering the typing specification.", + "feature-marketing": "A strict-by-default Python type checker.", + "scoping-reassurance": "Only a few rules are affected; the rest is fine.", + "shipping-claim": "## Status\n\nWorking — powers the editor integration.", + "quoted-apology": ( + "> I got this wrong.\n" + "> https://www.christianfindlay.com/blog/basilisk-conformance-apology" + ), +} + + +class Rules(unittest.TestCase): + def test_every_rule_has_an_example_and_rejects_it(self): + self.assertEqual( + sorted(OFFENDING), sorted(rule.id for rule in RULES), "rules and examples" + ) + for rule in RULES: + with self.subTest(rule=rule.id): + self.assertRegex(OFFENDING[rule.id], rule.pattern) + + def test_no_rule_fires_on_a_different_rules_example(self): + # Overlapping patterns would make a failure report point at the wrong + # prohibition, which is worse than not catching it at all. + for rule in RULES: + for other_id, text in OFFENDING.items(): + if other_id == rule.id: + continue + with self.subTest(rule=rule.id, text=other_id): + self.assertIsNone(rule.pattern.search(text)) + + def test_the_approved_copy_trips_nothing(self): + # The statement names the python/typing conformance results and links + # PR #2330. Both must survive the scan: a rule that cannot tell a + # source link from a score would force the copy to be watered down. + readme = REPO_ROOT / "README.md" + self.assertEqual(scan(readme), []) + self.assertIn("conformance results", readme.read_text(encoding="utf-8")) + + +class Coverage(unittest.TestCase): + def test_every_storefront_is_scanned(self): + scanned = {path.relative_to(REPO_ROOT).as_posix() for path in surfaces()} + for required in ( + "README.md", + "README-pypi.md", + "vscode-extension/README.md", + "vscode-extension/package.json", + "basilisk-zed/README.md", + "basilisk-zed/extension.toml", + "basilisk.nvim/README.md", + "pyproject.toml", + ".github/release-templates/basilisk.rb.tmpl", + ".github/release-templates/basilisk.json.tmpl", + ): + with self.subTest(surface=required): + self.assertIn(required, scanned) + + def test_every_crate_readme_is_scanned(self): + scanned = {path.relative_to(REPO_ROOT).as_posix() for path in surfaces()} + on_disk = { + path.relative_to(REPO_ROOT).as_posix() + for path in (REPO_ROOT / "crates").glob("*/README.md") + } + self.assertTrue(on_disk) + self.assertTrue(on_disk <= scanned, on_disk - scanned) + + def test_the_repository_is_clean(self): + offenders = { + path.relative_to(REPO_ROOT).as_posix(): scan(path) + for path in surfaces() + if scan(path) + } + self.assertEqual(offenders, {}) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/test_published_readmes.py b/scripts/test_published_readmes.py new file mode 100644 index 000000000..e39e1ecb1 --- /dev/null +++ b/scripts/test_published_readmes.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python3 +"""The published READMEs carry the statement and nothing it forbids. + +Implements [WITHDRAWAL-SURFACES]. Every storefront front page — GitHub, the VSIX +on Marketplace and Open VSX, PyPI, Zed, Neovim — is generated from +docs/readme/README.src.md with the statement substituted from the messaging spec +([WITHDRAWAL-COPY]). `gen_readmes.py --check` proves they match their source; +these tests prove the source still says the right thing, and that no hand-authored +part of it reintroduces something [WITHDRAWAL-PROHIBITED] bars. + + python3 -m pytest scripts/test_published_readmes.py +""" + +from __future__ import annotations + +import re +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from gen_readmes import SOURCES # noqa: E402 +from gen_withdrawal_copy import copy_blocks # noqa: E402 + +REPO_ROOT = Path(__file__).resolve().parents[1] + +PUBLISHED = tuple(target.output for source in SOURCES for target in source.targets) + +APOLOGY = "https://www.christianfindlay.com/blog/basilisk-conformance-apology" + +# Each pattern is something a front page must never say again. Anchored on the +# rendered markdown, so a link, a badge, or a code fence all count. +FORBIDDEN = ( + ("a conformance or pass-rate figure", re.compile(r"\d+(\.\d+)?\s*%")), + ( + "install instructions", + re.compile(r"\b(pip|pipx|uv tool|brew|scoop|npm)\s+install\b", re.I), + ), + ("an editor install link", re.compile(r"vscode:extension", re.I)), + ( + "a marketplace or package listing link", + re.compile(r"marketplace\.visualstudio\.com|open-vsx\.org|pypi\.org", re.I), + ), + ( + "a competitor comparison", + re.compile(r"\b(pyright|mypy|pyrefly|zuban|pylance)\b", re.I), + ), + ("a benchmark claim", re.compile(r"\bbenchmark|\bfastest\b", re.I)), + ("a rule catalogue", re.compile(r"\bBSK-\d{4}\b")), + ("a `basilisk` invocation", re.compile(r"\bbasilisk (check|analyze|fix|lsp)\b")), +) + + +class PublishedReadmes(unittest.TestCase): + """Every storefront front page, as it will be published.""" + + def setUp(self) -> None: + self.readmes = {path: path.read_text(encoding="utf-8") for path in PUBLISHED} + self.assertTrue(self.readmes, "no published README targets are declared") + + def test_every_readme_opens_with_the_statement(self) -> None: + copy = copy_blocks() + for path, text in self.readmes.items(): + with self.subTest(readme=path.relative_to(REPO_ROOT)): + self.assertIn(f"# {copy.title}", text) + for paragraph in copy.full: + self.assertIn(paragraph, text) + + def test_every_readme_tells_the_reader_what_to_do(self) -> None: + # The action block is the only part that asks something of the reader, + # so it is the part most likely to be trimmed for length. + copy = copy_blocks() + for path, text in self.readmes.items(): + with self.subTest(readme=path.relative_to(REPO_ROOT)): + for paragraph in copy.action: + self.assertIn(paragraph, text) + self.assertIn("Remove Basilisk from your pipeline", text) + + def test_every_readme_links_the_apology_without_quoting_it(self) -> None: + for path, text in self.readmes.items(): + with self.subTest(readme=path.relative_to(REPO_ROOT)): + self.assertIn(APOLOGY, text) + self.assertNotRegex(text, r"I (was|am) (wrong|sorry)|in my own words") + + def test_no_readme_says_anything_prohibited(self) -> None: + for path, text in self.readmes.items(): + for label, pattern in FORBIDDEN: + with self.subTest(readme=path.relative_to(REPO_ROOT), forbidden=label): + self.assertIsNone( + pattern.search(text), + f"{path.relative_to(REPO_ROOT)} contains {label}", + ) + + def test_no_readme_shows_a_product_image(self) -> None: + # Screenshots are release evidence for a product that is being delisted; + # a marketing image beside a withdrawal notice reads as still selling. + for path, text in self.readmes.items(): + with self.subTest(readme=path.relative_to(REPO_ROOT)): + self.assertNotRegex(text, r"!\[[^\]]*\]\(|&2; exit 1; } + +entries="$(unzip -Z1 "$vsix")" + +fail=0 +note() { echo "::error::$vsix $1" >&2; fail=1; } + +# Any executable or vendored runtime. `bin/` held the per-platform `basilisk` +# binary; `bundled/` held debugpy. +while IFS= read -r entry; do + case "$entry" in + extension/bin/*|extension/bundled/*) + note "ships a runtime artifact: $entry" ;; + *basilisk-profiler-helper*|*basilisk.exe|extension/basilisk) + note "ships a binary: $entry" ;; + esac +done <<< "$entries" + +# The compiled client for the withdrawn features. Only the notice and its +# generated copy may be present. +compiled="$(grep -E '^extension/out/.*\.js$' <<< "$entries" || true)" +while IFS= read -r entry; do + [ -z "$entry" ] && continue + case "$entry" in + extension/out/extension.js|extension/out/withdrawal-notice.js) ;; + *) note "ships a compiled module that is not the notice: $entry" ;; + esac +done <<< "$compiled" + +# The notice itself must be there — an empty package would "pass" every check +# above while telling the user nothing. +grep -qx 'extension/out/extension.js' <<< "$entries" || + note "is missing extension/out/extension.js" +# vsce lowercases the readme entry, so match without case. +grep -qix 'extension/readme.md' <<< "$entries" || + note "is missing the README" +grep -qx 'extension/LICENSE.txt' <<< "$entries" || + note "is missing extension/LICENSE.txt" + +if [ "$fail" -ne 0 ]; then + echo "::error::the VSIX must ship the notice and nothing else" >&2 + exit 1 +fi +echo "✓ $vsix ships the notice and no type checker" diff --git a/scripts/verify_release_attribution.py b/scripts/verify_release_attribution.py index 821fce449..45d59a9d1 100644 --- a/scripts/verify_release_attribution.py +++ b/scripts/verify_release_attribution.py @@ -213,13 +213,18 @@ def _verify_release_package_metadata(repo_root: Path) -> None: raise ValueError( "first-party workspace crates leaked into the dependency carrier" ) + # A spot-check that the carrier really carries license TEXT, not just a + # crate list. The set is small because the shipped graph is small: the + # binary is inert ([WITHDRAWAL-INERT]) and links Shipwright and its serde + # stack, nothing else. The crates that used to appear here — the typeshed + # download runtime, the embedded formatter, their transitive graph — are not + # linked in any more, and listing licenses for code that does not ship would + # be a claim about the binary that is not true. The exact carrier is still + # pinned by `licenses_sha256` above; this only proves it is not a stub. for required_notice in ( - "Copyright (c) 2015, Nick Fitzgerald", - "Copyright (c) 2013, Julien Schmidt", + "Apache License", + "MIT License", "UNICODE LICENSE V3", - "COMMON DEVELOPMENT AND DISTRIBUTION LICENSE Version 1.0", - "Mozilla Public License Version 2.0", - "(C) 2024 Trifecta Tech Foundation", ): _require_text(runtime_text, required_notice, "RUST-DEPENDENCY-LICENSES") diff --git a/shipwright.json b/shipwright.json index a3f538d3e..d3bca0020 100644 --- a/shipwright.json +++ b/shipwright.json @@ -10,80 +10,23 @@ "components": [ { "id": "basilisk", - "kind": "lsp", + "kind": "cli", "language": "rust", "binaryName": "basilisk", "expectedVersion": "${PRODUCT_VERSION}", "platforms": ["darwin-arm64", "linux-x64", "linux-arm64", "win32-x64", "win32-arm64"], - "bundled": { - "bundlePath": "bin/${platform}/${binaryName}${exe}", - "perPlatformArtifact": true - }, - "sources": ["user-setting", "bundled"], - "userSetting": "basilisk.executablePath", + "sources": ["path", "pkgmgr", "github-release"], "githubRelease": { "repo": "Nimblesite/Basilisk", "assetPattern": "basilisk-${version}-${platform}.tar.gz", "checksum": true }, - "verifyStartup": true, - "versionCheckStrategy": "version-flag", - "required": true - }, - { - "id": "basilisk-profiler-helper", - "kind": "tool", - "language": "rust", - "binaryName": "basilisk-profiler-helper", - "expectedVersion": "${PRODUCT_VERSION}", - "platforms": ["darwin-arm64"], - "bundled": { - "bundlePath": "bin/${platform}/${binaryName}${exe}", - "perPlatformArtifact": true - }, - "sources": ["user-setting", "bundled"], - "userSetting": "basilisk.binaries.basilisk-profiler-helper", "verifyStartup": false, "versionCheckStrategy": "version-flag", - "required": false - }, - { - "id": "debugpy", - "kind": "asset", - "platforms": ["all"], - "bundled": { - "bundlePath": "bundled/debugpy", - "perPlatformArtifact": false - }, - "asset": { - "source": "pip:debugpy==1.8.21", - "target": "bundled/debugpy", - "bundle": true, - "contentHash": true, - "sha256": "b1e37d333663c8851516a47364ef473da127f9caebe4417e6df6f5825a7e9a92", - "downloadOnFirstUse": false - }, - "verifyStartup": false, - "required": false - }, - { - "id": "basilisk-zed", - "kind": "extension-zed", - "language": "rust", - "platforms": ["all"] + "required": true } ], "hosts": { - "vscode": { - "artifact": "vsix-per-platform", - "activationVerifies": ["basilisk"], - "onMismatch": "error" - }, - "zed": { - "artifact": "zed-wasm", - "activationVerifies": ["basilisk"], - "onMismatch": "error" - }, "cli": { "artifact": "archive", "activationVerifies": ["basilisk"], diff --git a/vscode-extension/.vscode-test.mjs b/vscode-extension/.vscode-test.mjs index dc0a3352f..2bb3e73c2 100644 --- a/vscode-extension/.vscode-test.mjs +++ b/vscode-extension/.vscode-test.mjs @@ -1,16 +1,10 @@ import { defineConfig } from '@vscode/test-cli'; import crypto from 'crypto'; -import fs from 'fs'; import os from 'os'; import path from 'path'; import { fileURLToPath } from 'url'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const defaultWorkspace = path.join(__dirname, 'test-fixtures', 'workspace'); -const screenshotWorkspace = process.env.BASILISK_SCREENSHOT_WORKSPACE; -const workspaceFolder = process.env.BASILISK_SCREENSHOTS && screenshotWorkspace - ? path.resolve(screenshotWorkspace) - : defaultWorkspace; // VS Code listens on a Unix socket inside the user-data dir; macOS caps // AF_UNIX socket paths at 104 bytes ("IPC handle longer than 103 chars"). @@ -24,103 +18,41 @@ const userDataDir = defaultUserDataDir.length > 80 ) : defaultUserDataDir; -// [VSIX-REALWORLD-WIRING]: one config per pinned real-world repo (see -// docs/specs/VSIX-REAL-WORLD-SPEC.md). Each opens the fetched tree (staged by -// scripts/fetch-real-world-repos.mjs — wired as `pretest`) as the workspace -// and selects its corpus entry via BSK_REAL_WORLD_REPO. Skipped in the -// screenshots flow, which drives vscode-test directly without the corpus. -const corpus = JSON.parse( - fs.readFileSync(path.join(__dirname, 'test-fixtures', 'real-world-corpus.json'), 'utf8'), -); -const realWorldTests = process.env.BASILISK_SCREENSHOTS - ? [] - : corpus.repos.map((repo) => ({ - label: `real-world-${repo.name}`, - files: 'out/test/real-world/**/*.test.js', - workspaceFolder: path.join(__dirname, '.real-world', repo.name), - launchArgs: ['--disable-extensions', '--user-data-dir', userDataDir], - env: { BSK_REAL_WORLD_REPO: repo.name }, - srcDir: __dirname, - // Analysis of a whole real repo is the slowest thing the suite waits - // on; per-test timeouts inside the suite scale off the corpus budgets, - // so this outer timeout only needs to exceed the largest of them. - mocha: { - bail: true, - reporter: 'list', - timeout: 600_000, - }, - })); - +// One suite. The extension is a notice ([WITHDRAWAL-SURFACES]): there is no +// language server to pre-warm, no workspace to analyse, and no real-world +// corpus to run against, so the whole configuration is the suite itself. export default defineConfig({ tests: [{ label: 'workspace-suite', files: 'out/test/suite/**/*.test.js', - // Open the test-fixtures/workspace so the LSP server gets a rootUri. - // This enables whole-module analysis tests that write Python files to the - // workspace root without opening them in the editor. - workspaceFolder, + workspaceFolder: path.join(__dirname, 'test-fixtures', 'workspace'), launchArgs: [ '--disable-extensions', '--user-data-dir', userDataDir, - // [VSIX-EDITOR-SCREENSHOTS-PIPELINE]: when capturing website - // screenshots, expose the headed VS Code window over CDP so the - // watcher (screenshot-watcher.mjs) can grab it. No effect normally. - ...(process.env.BASILISK_SCREENSHOTS - ? [`--remote-debugging-port=${process.env.BASILISK_SCREENSHOT_CDP_PORT ?? '9229'}`] - : []), ], // Coverage: tell c8 where compiled sources live. Without this, // @vscode/test-cli defaults to 'src' (TypeScript sources), so // include patterns like 'out/**/*.js' resolve against src/ and // find nothing. srcDir: __dirname, - // Mocha config — @vscode/test-cli creates its own Mocha instance and - // ignores src/test/suite/index.ts's Mocha config. This is the ONLY - // place Mocha config is honoured when running `npm test`. - // `require` runs once per test process — used to pre-warm the LSP. - // Timeout sized for slow debug-integration tests that spawn debugpy - // and step through real Python code on CI runners. mocha: { - // Fail fast by DEFAULT: a local run or the Linux CI job should stop - // at the first failure rather than spend minutes on a verdict that - // is already decided. - // - // `BSK_TEST_BAIL=0` opts out, and the Windows CI job sets it, - // because there the economics invert: the suite itself takes ~30s - // but sits behind a ~20min cold `cargo build`. Bailing there saves - // half a minute of testing and costs a FULL REBUILD for every - // failure it hid — the first two Windows runs each surfaced exactly - // one win32 defect and hid the next behind it. One run that names - // every failure is strictly cheaper ([VSIX-CI-PLATFORM-COVERAGE]). + ui: 'tdd', + // Fail fast by DEFAULT: a local run or CI should stop at the first + // failure rather than spend time on a verdict already decided. + // `BSK_TEST_BAIL=0` opts out. bail: process.env.BSK_TEST_BAIL !== '0', reporter: 'list', timeout: 45_000, - require: './out/test/suite/index.js', ...(process.env.BSK_TEST_GREP ? { grep: process.env.BSK_TEST_GREP } : {}), }, - }, ...realWorldTests], + }], coverage: { includeAll: false, // @vscode/test-cli sets report.exclude.relativePath = false, which // makes test-exclude match against absolute paths. Patterns must // start with **/ so minimatch can match any prefix. include: ['**/out/**/*.js'], - exclude: [ - '**/out/test/**', - // Panel/webview command modules are validated by E2E contract tests, - // but their callback-heavy UI branches are not a stable line - // coverage signal under the VS Code extension host. - '**/out/coverage-decorations.js', - '**/out/info-panel.js', - '**/out/memory-dashboard.js', - '**/out/memory-decorations.js', - '**/out/memory-profiler.js', - '**/out/memory-ref-graph.js', - '**/out/module-explorer.js', - '**/out/profiler.js', - '**/out/profiler-flamegraph-html.js', - '**/out/test-explorer.js', - ], + exclude: ['**/out/test/**'], reporter: ['text', 'lcov'], }, }); diff --git a/vscode-extension/.vscodeignore b/vscode-extension/.vscodeignore index 943196179..e6d71df9d 100644 --- a/vscode-extension/.vscodeignore +++ b/vscode-extension/.vscodeignore @@ -7,7 +7,7 @@ coverage/** test-fixtures/** src/** -node_modules/.package-lock.json +node_modules/** out/test/** tsconfig.json .eslintrc* @@ -19,3 +19,16 @@ scripts/** .gitignore package-lock.json *.vsix +resources/** +# The extension is a notice ([WITHDRAWAL-SURFACES]). It ships no checker, so +# these must never appear in the package even if a stale working tree still +# holds them: `bin/` was the bundled `basilisk` binary and `bundled/` was the +# vendored debugger. Belt and braces beside the packaging change itself — +# shipping the type checker again is the one failure that must be impossible. +bin/** +bundled/** +shipwright.json +NOTICES +THIRD-PARTY-LICENSES +RUST-DEPENDENCY-LICENSES +VSCODE-DEPENDENCY-LICENSES diff --git a/vscode-extension/README.md b/vscode-extension/README.md index 365f8f46b..f563fd940 100644 --- a/vscode-extension/README.md +++ b/vscode-extension/README.md @@ -1,208 +1,38 @@ -

- Basilisk -

- -

Basilisk

- -

English · 简体中文

- -

- An open-source Python type checker and language server, built in Rust.
- One extension for the whole workflow — diagnostics, autocomplete, refactoring, formatting, debugging, and profiling — driven by a single bundled binary. -

- -> **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  •  - Refactoring  •  - GitHub -

- -

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

- -> ## ⚠️ Do not use Basilisk's type checker in your pipeline -> -> **The type checker still contains code that isn't doing real type checking, and -> it is not yet trustworthy.** Some rules decide from the way code is *spelled* -> rather than what it means, so they can be wrong in both directions — a false -> error on correct code, or silence where there is a genuine bug. Until the audit -> below is finished, don't gate CI on `basilisk check`, don't block a merge with -> it, and don't read a clean run as a clean codebase. -> -> The rest of Basilisk — language server, refactoring, formatting, debugging, -> profiling — does not depend on those rules and is unaffected. - -## Restoring trust: audit, delete, and lean on a checker that works - -We withdrew our former conformance claim and our benchmark figures, and asked to be -[removed from the official `python/typing` results](https://github.com/python/typing/blob/main/conformance/results/results.html). -The cause was checker logic fitted to the contents of conformance test files -instead of implementing the typing specification generally: rules that matched -the *spelling* of code rather than its meaning. Rename an import or reformat a -file and the answer changed. A score produced that way is not evidence. - -**This was a mistake and a failure to verify.** Our process treated the score as -the goal, matching text raises a score faster than real analysis does, and we -published without ever asking whether a rule still held when the same program was -spelled differently. Basilisk's author has published a -[personal account and apology](https://www.christianfindlay.com/blog/basilisk-conformance-apology). - -**So we are auditing every rule and deleting the ones that don't do real type -checking.** Not rewriting them, not patching them, not marking them TODO — -deleting them, with a failing test left behind so the gap is visible instead of -hidden. A rule stays only if it decides from the resolved syntax tree and gives -the same answer when the code is spelled differently. - -**Where a rule can't be made reliable in a straightforward way, we will depend on -a different, established type checker rather than ship our own unreliable version -of it.** An answer from an engine that has earned trust is worth more to you than -a Basilisk-branded one that hasn't. No replacement figure gets published until it -survives off-suite and mutation testing. - -That means Basilisk gets **smaller** before it gets better. Expect fewer rules, -fewer diagnostics, and a lower conformance number. We will report each drop -rather than avoid it. What is left will be code that is honest about what it -does — nothing else. - -### Basilisk is much more than a type checker - -Type checking is one part of it. The rest is a complete Python workflow in a -single Rust binary — language server, refactoring, formatting, integrated -debugging, profiling, and the editor extensions — and none of it rests on the -rules under audit. That is what we are sharpening while the audit runs: make the -parts that are genuinely useful solid, and remove anything that could hand you a -misleading result. The point of getting smaller is to end up with a tool you can -believe. - -[Read the full correction →](https://www.basilisk-python.dev/docs/conformance/)  •  -[Integrity audit →](https://github.com/Nimblesite/Basilisk/blob/main/docs/CONFORMANCE-INTEGRITY-AUDIT.md) - -## What you get - -One extension covers the whole Python workflow. A single bundled Rust binary -drives it — no Node.js, no npm, no `pip install`: - -- **Diagnostics as you type** — incremental analysis powered by [Salsa](https://github.com/salsa-rs/salsa) -- **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](https://github.com/microsoft/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 - -Strictness is configured **per rule**, never by a mode: the unconfigured default -enables the typing-spec rule set, and each rule can be graded down to -`warning`/`info` so a codebase can adopt type safety incrementally. Every -diagnostic carries a `help`, a `note`, and a link to a per-rule explainer, so a -red squiggle tells you *why*. - -## 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 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/). +# Basilisk is unlisted -## Development +> **You are reading the Basilisk extension listing** for VS Code, Cursor, Windsurf, and every VS Code fork. -```sh -cargo build # build all crates -cargo test # run all tests -cargo clippy # lint (zero warnings policy) -cargo fmt # format -``` +**Basilisk's type checker was producing incorrect results.** Rules decided from the way code was *spelled* rather than what it meant, so they could be wrong in both directions — a false error on correct code, or silence on a real bug. -Rust 1.87+ required. +**We asked for Basilisk to be removed from the `python/typing` conformance results, and it has been removed** ([python/typing#2330](https://github.com/python/typing/pull/2330)). That score did not demonstrate correctness. -## Contributing +**We cannot tell you how much of the checker this affects.** The code responsible is not isolated to a known set of rules. We will not estimate. That uncertainty is the reason for everything below. -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)). +**A code-quality tool that does not produce correct results is worse than useless.** Basilisk is being unlisted everywhere it was published — the VS Code Marketplace, Open VSX, the Zed registry, PyPI, the Homebrew tap, and the Scoop bucket — and the type checker is inert. Remove it from your pipeline; it checks nothing, and every invocation fails rather than reporting a clean run. -## Acknowledgments +**We are not fixing Basilisk's type checker code. We are rebuilding from the ground up as a new product.** It will ship only what can be trusted. That most likely will not include type checking. Nothing is relisted until it has been rebuilt from components we can vouch for. If type checking ever returns, it will be externally audited before release. + +Basilisk's author has published a full public account: [an apology](https://www.christianfindlay.com/blog/basilisk-conformance-apology). + +## What to do now -Basilisk builds on the open-source community — with thanks to: +**Remove Basilisk from your pipeline, your pre-commit hooks, and your editor.** Uninstall the CLI and the extension. -- **[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. +The type checker is inert: it checks nothing, and every invocation fails. It prints this statement and exits non-zero, so a build that still calls it fails loudly rather than reporting a clean run. Do not treat that failure as a finding about your code. -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. +**Treat every result Basilisk gave you as unverified.** A clean run was never evidence that your code was clean, and an error it reported may never have been real. + +Every distribution channel is being unlisted. Nothing will be relisted until it has been rebuilt from components we can vouch for. + +## Acknowledgments ---- +Basilisk is built on [Ruff](https://github.com/astral-sh/ruff) by [Astral](https://astral.sh/), whose parser, AST, and formatter crates it embeds (MIT), and on standard-library type stubs from [typeshed](https://github.com/python/typeshed) (Apache-2.0, with MIT-licensed parts). Neither project is responsible for how Basilisk used them. Full component list 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). ## License -Basilisk source code is MIT licensed. Binary distributions also contain -third-party components under the licenses shipped beside each artifact. +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 deleted file mode 100644 index 7f115e815..000000000 --- a/vscode-extension/README.zh.md +++ /dev/null @@ -1,193 +0,0 @@ - -

- Basilisk -

- -

Basilisk

- -

English · 简体中文

- -

- 用 Rust 打造的开源 Python 类型检查器与语言服务器。
- 一个扩展覆盖整套工作流 —— 诊断、自动补全、重构、格式化、调试与性能分析 —— 全部由单一捆绑的二进制文件驱动。 -

- -> **你正在阅读 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 -

- -

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

- -> ## ⚠️ 请勿在流水线中使用 Basilisk 的类型检查器 -> -> **类型检查器中仍然存在没有做真正类型检查的代码,它目前还不值得信任。** 有些规则 -> 依据的是代码的**写法**而不是含义,因此两个方向上都可能出错 —— 既可能对正确的代码 -> 报出虚假错误,也可能对真实的缺陷保持沉默。在下文所述的审计完成之前,请不要用 -> `basilisk check` 作为 CI 的门禁,不要用它拦截合并,也不要把一次干净的运行结果当作 -> 代码库是干净的。 -> -> Basilisk 的其余部分 —— 语言服务器、重构、格式化、调试、性能分析 —— 并不依赖这些 -> 规则,因此不受影响。 - -## 重建信任:审计、删除,并倚重真正可靠的检查器 - -我们撤回了此前的一致性宣称与基准测试数字,并主动请求 -[从官方 `python/typing` 结果中移除](https://github.com/python/typing/blob/main/conformance/results/results.html)。 -原因是检查器中存在针对一致性测试文件内容而写的逻辑,而不是对类型规范的通用实现: -那些规则匹配的是代码的**写法**,而不是代码的含义。改一个导入别名或重新格式化文件, -结论就会变。这样得出的分数并不能作为证据。 - -**这是一个错误、一次验证上的失职。** 我们的流程把分数当成了目标,而匹配文本比真正做 -分析更快地提高分数;我们在发布之前,始终没有问过这样一个问题 —— 同一个程序换一种 -写法时,这条规则是否依然成立。Basilisk 作者已发表 -[个人说明与致歉](https://www.christianfindlay.com/blog/basilisk-conformance-apology)。 - -**因此,我们正在逐条审计规则,并删除那些没有做真正类型检查的规则。** 不是重写,不是 -打补丁,也不是标一个 TODO —— 是删除,并留下一个失败的测试,让这个缺口可见而不是被 -掩盖。一条规则只有在依据已解析的语法树做判断、并且在代码换一种写法时给出相同结论的 -情况下,才会保留。 - -**如果一条规则无法以直截了当的方式做到可靠,我们会转而依赖另一个成熟的类型检查器, -而不是端出我们自己那份不可靠的实现。** 一个已经赢得信任的引擎给出的答案,对你而言 -比一个挂着 Basilisk 名号却没有赢得信任的答案更有价值。在通过套件之外的用例与变异 -测试之前,我们不会发布任何替代数字。 - -这意味着 Basilisk 会**先变小,再变好**。规则会更少,诊断会更少,一致性数字也会更低。 -每一次下降我们都会如实报告,而不是设法回避。留下来的,将是对自己所做之事诚实的代码 -—— 仅此而已。 - -### Basilisk 远不只是一个类型检查器 - -类型检查只是其中一部分。其余部分是装在单个 Rust 二进制文件里的完整 Python 工作流 -—— 语言服务器、重构、格式化、集成调试、性能分析,以及各个编辑器扩展 —— 它们都不 -建立在正在接受审计的规则之上。这正是我们在审计期间着力打磨的地方:把真正有用的部分 -做扎实,并移除任何可能给出误导性结果的东西。变小的意义,是最终得到一个你可以信赖的 -工具。 - -[阅读完整更正 →](https://www.basilisk-python.dev/zh/docs/conformance/)  •  -[完整性审计 →](https://github.com/Nimblesite/Basilisk/blob/main/docs/CONFORMANCE-INTEGRITY-AUDIT.md) - -## 你能得到什么 - -一个扩展即可覆盖整套 Python 工作流。一切由单一捆绑的 Rust 二进制文件驱动 —— -无需 Node.js、无需 npm、无需 `pip install`: - -- **随输入实时诊断** —— 由 [Salsa](https://github.com/salsa-rs/salsa) 提供增量分析 -- **自动补全、悬停信息、跳转到定义、查找引用、重命名** -- **重构代码操作** —— 提取、内联、移动符号、整理导入 -- **集成调试** —— 按 F5 即可通过捆绑的 [debugpy](https://github.com/microsoft/debugpy) 调试;无需额外扩展 -- **集成性能分析** —— CPU 热力图、火焰图,以及带泄漏检测的内存面板 -- **活动面板** —— 模块树与逐模块的类型健康度覆盖率,并可切换功能开关 -- 内置 **Inlay hints** 与 **Ruff** 格式化/导入整理 -- **来自 [typeshed](https://github.com/python/typeshed) 的标准库类型** —— 完整的 `stdlib/` 快照已编译进二进制文件,因此悬停与诊断在离线且零配置的情况下依然可用 - -严格程度按**规则**配置,而不是靠模式切换:未配置的默认值即启用类型规范规则集, -每条规则都可以降级为 `warning`/`info`,让代码库能够渐进地采用类型安全。每条诊断 -都附带 `help`、`note` 以及指向每条规则详解页的链接,因此一条红色波浪线总能告诉你 -*为什么*。 - -## 安装 - -**编辑器扩展** —— 从 [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/ # 一次检查整个目录 -``` - -供 CI 与工具使用的机器可读输出: - -```sh -basilisk check path/to/your_code.py --output json --color never -``` - -这两条命令读取的是按来源划分的同一套规则宇宙([`CHKARCH-COMMANDS`](https://github.com/Nimblesite/Basilisk/blob/main/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](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) 既定规则下的技术执行)。 - -## 致谢 - -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 许可证。二进制发行物还包含第三方组件;其许可证 -随每个发行物一并提供。 - -由 [NIMBLESITE PTY LTD](https://www.nimblesite.co) 构建。 diff --git a/vscode-extension/RUST-DEPENDENCY-LICENSES b/vscode-extension/RUST-DEPENDENCY-LICENSES deleted file mode 100644 index 455886f3b..000000000 --- a/vscode-extension/RUST-DEPENDENCY-LICENSES +++ /dev/null @@ -1,2283 +0,0 @@ -Basilisk — Rust Runtime Dependency Licenses -============================================ - -Generated from Cargo.lock with cargo-about for the complete dependency union of -the five supported release targets. Regenerate with: - - cargo about generate scripts/runtime-licenses.hbs --locked --fail \ - --manifest-path crates/basilisk-cli/Cargo.toml \ - --output-file RUST-DEPENDENCY-LICENSES - -Components ----------- -addr2line 0.26.1 - Source: https://github.com/gimli-rs/addr2line - License: Apache-2.0 OR MIT -adler2 2.0.1 - Source: https://github.com/oyvindln/adler2 - License: 0BSD OR MIT OR Apache-2.0 -ahash 0.8.12 - Source: https://github.com/tkaitchuck/ahash - License: MIT OR Apache-2.0 -aho-corasick 1.1.4 - Source: https://github.com/BurntSushi/aho-corasick - License: Unlicense OR MIT -allocator-api2 0.2.21 - Source: https://github.com/zakarumych/allocator-api2 - License: MIT OR Apache-2.0 -anstream 1.0.0 - Source: https://github.com/rust-cli/anstyle.git - License: MIT OR Apache-2.0 -anstyle 1.0.13 - Source: https://github.com/rust-cli/anstyle.git - License: MIT OR Apache-2.0 -anstyle-parse 1.0.0 - Source: https://github.com/rust-cli/anstyle.git - License: MIT OR Apache-2.0 -anstyle-query 1.1.5 - Source: https://github.com/rust-cli/anstyle.git - License: MIT OR Apache-2.0 -anstyle-wincon 3.0.11 - Source: https://github.com/rust-cli/anstyle.git - License: MIT OR Apache-2.0 -anyhow 1.0.102 - Source: https://github.com/dtolnay/anyhow - License: MIT OR Apache-2.0 -arc-swap 1.9.2 - Source: https://github.com/vorner/arc-swap - License: MIT OR Apache-2.0 -arrayvec 0.7.6 - Source: https://github.com/bluss/arrayvec - License: MIT OR Apache-2.0 -async-trait 0.1.89 - Source: https://github.com/dtolnay/async-trait - License: MIT OR Apache-2.0 -attribute-derive 0.10.5 - Source: https://github.com/ModProg/attribute-derive - License: MIT OR Apache-2.0 -attribute-derive-macro 0.10.5 - Source: https://github.com/ModProg/attribute-derive - License: MIT -auto_impl 1.3.0 - Source: https://github.com/auto-impl-rs/auto_impl/ - License: MIT OR Apache-2.0 -base64 0.22.1 - Source: https://github.com/marshallpierce/rust-base64 - License: MIT OR Apache-2.0 -bitflags 1.3.2 - Source: https://github.com/bitflags/bitflags - License: MIT OR Apache-2.0 -bitflags 2.11.0 - Source: https://github.com/bitflags/bitflags - License: MIT OR Apache-2.0 -block-buffer 0.12.1 - Source: https://github.com/RustCrypto/utils - License: MIT OR Apache-2.0 -block2 0.6.2 - Source: https://github.com/madsmtm/objc2 - License: MIT -boxcar 0.2.14 - Source: https://github.com/ibraheemdev/boxcar - License: MIT -bstr 1.12.1 - Source: https://github.com/BurntSushi/bstr - License: MIT OR Apache-2.0 -bumpalo 3.20.2 - Source: https://github.com/fitzgen/bumpalo - License: MIT OR Apache-2.0 -bytemuck 1.25.0 - Source: https://github.com/Lokathor/bytemuck - License: Zlib OR Apache-2.0 OR MIT -bytes 1.11.1 - Source: https://github.com/tokio-rs/bytes - License: MIT -camino 1.2.4 - Source: https://github.com/camino-rs/camino - License: MIT OR Apache-2.0 -castaway 0.2.4 - Source: https://github.com/sagebind/castaway - License: MIT -cfg-if 1.0.4 - Source: https://github.com/rust-lang/cfg-if - License: MIT OR Apache-2.0 -chacha20 0.10.1 - Source: https://github.com/RustCrypto/stream-ciphers - License: MIT OR Apache-2.0 -char_str 0.0.2 - Source: https://github.com/astral-sh/char_str - License: MIT -chrono 0.4.44 - Source: https://github.com/chronotope/chrono - License: MIT OR Apache-2.0 -clap 4.6.1 - Source: https://github.com/clap-rs/clap - License: MIT OR Apache-2.0 -clap_builder 4.6.0 - Source: https://github.com/clap-rs/clap - License: MIT OR Apache-2.0 -clap_complete 4.6.5 - Source: https://github.com/clap-rs/clap - License: MIT OR Apache-2.0 -clap_derive 4.6.1 - Source: https://github.com/clap-rs/clap - License: MIT OR Apache-2.0 -clap_lex 1.0.0 - Source: https://github.com/clap-rs/clap - License: MIT OR Apache-2.0 -collection_literals 1.0.3 - Source: https://github.com/staedoix/collection_literals - License: MIT -colorchoice 1.0.4 - Source: https://github.com/rust-cli/anstyle.git - License: MIT OR Apache-2.0 -colored 3.1.1 - Source: https://github.com/mackwic/colored - License: MPL-2.0 -compact_str 0.10.0 - Source: https://github.com/ParkMyCar/compact_str - License: MIT -console 0.16.3 - Source: https://github.com/console-rs/console - License: MIT -const-oid 0.10.2 - Source: https://github.com/RustCrypto/formats - License: Apache-2.0 OR MIT -core-foundation-sys 0.8.7 - Source: https://github.com/servo/core-foundation-rs - License: MIT OR Apache-2.0 -countme 3.0.1 - Source: https://github.com/matklad/countme - License: MIT OR Apache-2.0 -cpp_demangle 0.5.1 - Source: https://github.com/gimli-rs/cpp_demangle - License: MIT OR Apache-2.0 -cpufeatures 0.3.0 - Source: https://github.com/RustCrypto/utils - License: MIT OR Apache-2.0 -crc32fast 1.5.0 - Source: https://github.com/srijs/rust-crc32fast - License: MIT OR Apache-2.0 -crossbeam-channel 0.5.15 - Source: https://github.com/crossbeam-rs/crossbeam - License: MIT OR Apache-2.0 -crossbeam-deque 0.8.6 - Source: https://github.com/crossbeam-rs/crossbeam - License: MIT OR Apache-2.0 -crossbeam-epoch 0.9.20 - Source: https://github.com/crossbeam-rs/crossbeam - License: MIT OR Apache-2.0 -crossbeam-queue 0.3.12 - Source: https://github.com/crossbeam-rs/crossbeam - License: MIT OR Apache-2.0 -crossbeam-utils 0.8.21 - Source: https://github.com/crossbeam-rs/crossbeam - License: MIT OR Apache-2.0 -crypto-common 0.2.2 - Source: https://github.com/RustCrypto/traits - License: MIT OR Apache-2.0 -ctrlc 3.5.2 - Source: https://github.com/Detegr/rust-ctrlc.git - License: MIT OR Apache-2.0 -dashmap 5.5.3 - Source: https://github.com/xacrimon/dashmap - License: MIT -dashmap 6.2.1 - Source: https://github.com/xacrimon/dashmap - License: MIT -data-encoding 2.10.0 - Source: https://github.com/ia0/data-encoding - License: MIT -derive-where 1.6.0 - Source: https://github.com/ModProg/derive-where - License: MIT OR Apache-2.0 -digest 0.11.3 - Source: https://github.com/RustCrypto/traits - License: MIT OR Apache-2.0 -dispatch2 0.3.1 - Source: https://github.com/madsmtm/objc2 - License: Zlib OR Apache-2.0 OR MIT -displaydoc 0.2.5 - Source: https://github.com/yaahc/displaydoc - License: MIT OR Apache-2.0 -drop_bomb 0.1.5 - Source: https://github.com/matklad/drop_bomb - License: MIT OR Apache-2.0 -dunce 1.0.5 - Source: https://gitlab.com/kornelski/dunce - License: CC0-1.0 OR MIT-0 OR Apache-2.0 -either 1.15.0 - Source: https://github.com/rayon-rs/either - License: MIT OR Apache-2.0 -encode_unicode 1.0.0 - Source: https://github.com/tormol/encode_unicode - License: Apache-2.0 OR MIT -env_filter 1.0.1 - Source: https://github.com/rust-cli/env_logger - License: MIT OR Apache-2.0 -env_logger 0.11.10 - Source: https://github.com/rust-cli/env_logger - License: MIT OR Apache-2.0 -equivalent 1.0.2 - Source: https://github.com/indexmap-rs/equivalent - License: Apache-2.0 OR MIT -errno 0.3.14 - Source: https://github.com/lambda-fairy/rust-errno - License: MIT OR Apache-2.0 -fallible-iterator 0.3.0 - Source: https://github.com/sfackler/rust-fallible-iterator - License: MIT OR Apache-2.0 -fastrand 2.3.0 - Source: https://github.com/smol-rs/fastrand - License: Apache-2.0 OR MIT -filetime 0.2.29 - Source: https://github.com/alexcrichton/filetime - License: MIT OR Apache-2.0 -flate2 1.1.9 - Source: https://github.com/rust-lang/flate2-rs - License: MIT OR Apache-2.0 -foldhash 0.2.0 - Source: https://github.com/orlp/foldhash - License: Zlib -form_urlencoded 1.2.2 - Source: https://github.com/servo/rust-url - License: MIT OR Apache-2.0 -futures 0.3.32 - Source: https://github.com/rust-lang/futures-rs - License: MIT OR Apache-2.0 -futures-channel 0.3.33 - Source: https://github.com/rust-lang/futures-rs - License: MIT OR Apache-2.0 -futures-core 0.3.33 - Source: https://github.com/rust-lang/futures-rs - License: MIT OR Apache-2.0 -futures-io 0.3.33 - Source: https://github.com/rust-lang/futures-rs - License: MIT OR Apache-2.0 -futures-macro 0.3.33 - Source: https://github.com/rust-lang/futures-rs - License: MIT OR Apache-2.0 -futures-sink 0.3.33 - Source: https://github.com/rust-lang/futures-rs - License: MIT OR Apache-2.0 -futures-task 0.3.33 - Source: https://github.com/rust-lang/futures-rs - License: MIT OR Apache-2.0 -futures-util 0.3.33 - Source: https://github.com/rust-lang/futures-rs - License: MIT OR Apache-2.0 -get-size-derive2 0.10.3 - Source: https://github.com/bircni/get-size2/tree/main/crates/get-size-derive2 - License: MIT OR Apache-2.0 -get-size2 0.10.3 - Source: https://github.com/bircni/get-size2 - License: MIT OR Apache-2.0 -getrandom 0.2.17 - Source: https://github.com/rust-random/getrandom - License: MIT OR Apache-2.0 -getrandom 0.3.4 - Source: https://github.com/rust-random/getrandom - License: MIT OR Apache-2.0 -getrandom 0.4.2 - Source: https://github.com/rust-random/getrandom - License: MIT OR Apache-2.0 -gimli 0.33.0 - Source: https://github.com/gimli-rs/gimli - License: MIT OR Apache-2.0 -glob 0.3.3 - Source: https://github.com/rust-lang/glob - License: MIT OR Apache-2.0 -globset 0.4.18 - Source: https://github.com/BurntSushi/ripgrep/tree/master/crates/globset - License: Unlicense OR MIT -goblin 0.10.5 - Source: https://github.com/m4b/goblin - License: MIT -hashbrown 0.14.5 - Source: https://github.com/rust-lang/hashbrown - License: MIT OR Apache-2.0 -hashbrown 0.17.1 - Source: https://github.com/rust-lang/hashbrown - License: MIT OR Apache-2.0 -hashlink 0.12.0 - Source: https://github.com/djc/hashlink - License: MIT OR Apache-2.0 -heck 0.5.0 - Source: https://github.com/withoutboats/heck - License: MIT OR Apache-2.0 -http 1.4.0 - Source: https://github.com/hyperium/http - License: MIT OR Apache-2.0 -httparse 1.10.1 - Source: https://github.com/seanmonstar/httparse - License: MIT OR Apache-2.0 -hybrid-array 0.4.13 - Source: https://github.com/RustCrypto/hybrid-array - License: MIT OR Apache-2.0 -iana-time-zone 0.1.65 - Source: https://github.com/strawlab/iana-time-zone - License: MIT OR Apache-2.0 -icu_collections 2.1.1 - Source: https://github.com/unicode-org/icu4x - License: Unicode-3.0 -icu_locale_core 2.1.1 - Source: https://github.com/unicode-org/icu4x - License: Unicode-3.0 -icu_normalizer 2.1.1 - Source: https://github.com/unicode-org/icu4x - License: Unicode-3.0 -icu_normalizer_data 2.1.1 - Source: https://github.com/unicode-org/icu4x - License: Unicode-3.0 -icu_properties 2.1.2 - Source: https://github.com/unicode-org/icu4x - License: Unicode-3.0 -icu_properties_data 2.1.2 - Source: https://github.com/unicode-org/icu4x - License: Unicode-3.0 -icu_provider 2.1.1 - Source: https://github.com/unicode-org/icu4x - License: Unicode-3.0 -idna 1.1.0 - Source: https://github.com/servo/rust-url/ - License: MIT OR Apache-2.0 -idna_adapter 1.2.1 - Source: https://github.com/hsivonen/idna_adapter - License: Apache-2.0 OR MIT -indexmap 2.14.0 - Source: https://github.com/indexmap-rs/indexmap - License: Apache-2.0 OR MIT -indicatif 0.18.4 - Source: https://github.com/console-rs/indicatif - License: MIT -inferno 0.12.8 - Source: https://github.com/jonhoo/inferno.git - License: CDDL-1.0 -interpolator 0.5.0 - Source: https://github.com/ModProg/interpolator - License: MIT OR Apache-2.0 -intrusive-collections 0.10.2 - Source: https://github.com/Amanieu/intrusive-rs - License: MIT OR Apache-2.0 -inventory 0.3.24 - Source: https://github.com/dtolnay/inventory - License: MIT OR Apache-2.0 -is-macro 0.3.7 - Source: https://github.com/dudykr/ddbase.git - License: Apache-2.0 -is_terminal_polyfill 1.70.2 - Source: https://github.com/polyfill-rs/is_terminal_polyfill - License: MIT OR Apache-2.0 -itertools 0.15.0 - Source: https://github.com/rust-itertools/itertools - License: MIT OR Apache-2.0 -itoa 1.0.17 - Source: https://github.com/dtolnay/itoa - License: MIT OR Apache-2.0 -jiff 0.2.23 - Source: https://github.com/BurntSushi/jiff - License: Unlicense OR MIT -lazy_static 1.5.0 - Source: https://github.com/rust-lang-nursery/lazy-static.rs - License: MIT OR Apache-2.0 -libc 0.2.182 - Source: https://github.com/rust-lang/libc - License: MIT OR Apache-2.0 -libm 0.2.16 - Source: https://github.com/rust-lang/compiler-builtins - License: MIT -libproc 0.14.11 - Source: https://github.com/andrewdavidmackenzie/libproc-rs - License: MIT -linux-raw-sys 0.12.1 - Source: https://github.com/sunfishcode/linux-raw-sys - License: Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT -litemap 0.8.1 - Source: https://github.com/unicode-org/icu4x - License: Unicode-3.0 -lock_api 0.4.14 - Source: https://github.com/Amanieu/parking_lot - License: MIT OR Apache-2.0 -log 0.4.29 - Source: https://github.com/rust-lang/log - License: MIT OR Apache-2.0 -lru 0.17.0 - Source: https://github.com/jeromefroe/lru-rs.git - License: MIT -lsp-types 0.94.1 - Source: https://github.com/gluon-lang/lsp-types - License: MIT -mach 0.3.2 - Source: https://github.com/fitzgen/mach - License: BSD-2-Clause -mach2 0.4.3 - Source: https://github.com/JohnTitor/mach2 - License: BSD-2-Clause OR MIT OR Apache-2.0 -mach_o_sys 0.1.1 - Source: https://github.com/fitzgen/mach_o_sys - License: Apache-2.0 OR MIT -manyhow 0.11.4 - Source: https://github.com/ModProg/manyhow - License: MIT OR Apache-2.0 -manyhow-macros 0.11.4 - Source: https://github.com/ModProg/manyhow - License: MIT OR Apache-2.0 -matchers 0.2.0 - Source: https://github.com/hawkw/matchers - License: MIT -matchit 0.9.2 - Source: https://github.com/ibraheemdev/matchit - License: MIT AND BSD-3-Clause -memchr 2.8.0 - Source: https://github.com/BurntSushi/memchr - License: Unlicense OR MIT -memmap2 0.9.10 - Source: https://github.com/RazrFalcon/memmap2-rs - License: MIT OR Apache-2.0 -memoffset 0.9.1 - Source: https://github.com/Gilnaa/memoffset - License: MIT -miniz_oxide 0.8.9 - Source: https://github.com/Frommi/miniz_oxide/tree/master/miniz_oxide - License: MIT OR Zlib OR Apache-2.0 -mio 1.1.1 - Source: https://github.com/tokio-rs/mio - License: MIT -nix 0.31.2 - Source: https://github.com/nix-rust/nix - License: MIT -ntapi 0.4.3 - Source: https://github.com/MSxDOS/ntapi - License: Apache-2.0 OR MIT -nu-ansi-term 0.50.3 - Source: https://github.com/nushell/nu-ansi-term - License: MIT -num-format 0.4.4 - Source: https://github.com/bcmyers/num-format - License: MIT OR Apache-2.0 -num-traits 0.2.19 - Source: https://github.com/rust-num/num-traits - License: MIT OR Apache-2.0 -objc2 0.6.4 - Source: https://github.com/madsmtm/objc2 - License: MIT -objc2-core-foundation 0.3.2 - Source: https://github.com/madsmtm/objc2 - License: Zlib OR Apache-2.0 OR MIT -objc2-encode 4.1.0 - Source: https://github.com/madsmtm/objc2 - License: MIT -objc2-foundation 0.3.2 - Source: https://github.com/madsmtm/objc2 - License: MIT -objc2-io-kit 0.3.2 - Source: https://github.com/madsmtm/objc2 - License: Zlib OR Apache-2.0 OR MIT -objc2-open-directory 0.3.2 - Source: https://github.com/madsmtm/objc2 - License: Zlib OR Apache-2.0 OR MIT -object 0.39.1 - Source: https://github.com/gimli-rs/object - License: Apache-2.0 OR MIT -once_cell 1.21.4 - Source: https://github.com/matklad/once_cell - License: MIT OR Apache-2.0 -once_cell_polyfill 1.70.2 - Source: https://github.com/polyfill-rs/once_cell_polyfill - License: MIT OR Apache-2.0 -ordermap 1.2.0 - Source: https://github.com/indexmap-rs/ordermap - License: Apache-2.0 OR MIT -page_size 0.6.0 - Source: https://github.com/Elzair/page_size_rs - License: MIT OR Apache-2.0 -parking_lot 0.12.5 - Source: https://github.com/Amanieu/parking_lot - License: MIT OR Apache-2.0 -parking_lot_core 0.9.12 - Source: https://github.com/Amanieu/parking_lot - License: MIT OR Apache-2.0 -path-slash 0.2.1 - Source: https://github.com/rhysd/path-slash - License: MIT -pathdiff 0.2.3 - Source: https://github.com/Manishearth/pathdiff - License: MIT OR Apache-2.0 -percent-encoding 2.3.2 - Source: https://github.com/servo/rust-url/ - License: MIT OR Apache-2.0 -phf 0.11.3 - Source: https://github.com/rust-phf/rust-phf - License: MIT -phf_shared 0.11.3 - Source: https://github.com/rust-phf/rust-phf - License: MIT -pin-project 1.1.11 - Source: https://github.com/taiki-e/pin-project - License: Apache-2.0 OR MIT -pin-project-internal 1.1.11 - Source: https://github.com/taiki-e/pin-project - License: Apache-2.0 OR MIT -pin-project-lite 0.2.17 - Source: https://github.com/taiki-e/pin-project-lite - License: Apache-2.0 OR MIT -plain 0.2.3 - Source: https://github.com/randomites/plain - License: MIT OR Apache-2.0 -portable-atomic 1.13.1 - Source: https://github.com/taiki-e/portable-atomic - License: Apache-2.0 OR MIT -potential_utf 0.1.4 - Source: https://github.com/unicode-org/icu4x - License: Unicode-3.0 -ppv-lite86 0.2.21 - Source: https://github.com/cryptocorrosion/cryptocorrosion - License: MIT OR Apache-2.0 -proc-macro-utils 0.10.0 - Source: https://github.com/ModProg/proc-macro-utils - License: MIT OR Apache-2.0 -proc-macro2 1.0.107 - Source: https://github.com/dtolnay/proc-macro2 - License: MIT OR Apache-2.0 -proc-maps 0.4.0 - Source: https://github.com/rbspy/proc-maps - License: MIT -py-spy 0.4.2 - Source: https://github.com/benfred/py-spy - License: MIT -quick-xml 0.41.0 - Source: https://github.com/tafia/quick-xml - License: MIT -quote 1.0.47 - Source: https://github.com/dtolnay/quote - License: MIT OR Apache-2.0 -quote-use 0.8.4 - Source: https://github.com/ModProg/quote-use - License: MIT -quote-use-macros 0.8.4 - Source: https://github.com/ModProg/quote-use - License: MIT -rand 0.9.4 - Source: https://github.com/rust-random/rand - License: MIT OR Apache-2.0 -rand 0.10.2 - Source: https://github.com/rust-random/rand - License: MIT OR Apache-2.0 -rand_chacha 0.9.0 - Source: https://github.com/rust-random/rand - License: MIT OR Apache-2.0 -rand_core 0.9.5 - Source: https://github.com/rust-random/rand - License: MIT OR Apache-2.0 -rand_core 0.10.1 - Source: https://github.com/rust-random/rand_core - License: MIT OR Apache-2.0 -rand_distr 0.5.1 - Source: https://github.com/rust-random/rand_distr - License: MIT OR Apache-2.0 -rayon 1.12.0 - Source: https://github.com/rayon-rs/rayon - License: MIT OR Apache-2.0 -rayon-core 1.13.0 - Source: https://github.com/rayon-rs/rayon - License: MIT OR Apache-2.0 -read-process-memory 0.1.6 - Source: https://github.com/rbspy/read-process-memory - License: MIT -regex 1.12.3 - Source: https://github.com/rust-lang/regex - License: MIT OR Apache-2.0 -regex-automata 0.4.14 - Source: https://github.com/rust-lang/regex - License: MIT OR Apache-2.0 -regex-syntax 0.8.10 - Source: https://github.com/rust-lang/regex - License: MIT OR Apache-2.0 -remoteprocess 0.5.2 - Source: https://github.com/benfred/remoteprocess - License: MIT -rgb 0.8.53 - Source: https://github.com/kornelski/rust-rgb - License: MIT -ring 0.17.14 - Source: https://github.com/briansmith/ring - License: Apache-2.0 AND ISC -ruff_annotate_snippets 0.0.7 - Source: https://github.com/astral-sh/ruff - License: MIT OR Apache-2.0 -ruff_cache 0.0.7 - Source: https://github.com/astral-sh/ruff - License: MIT -ruff_db 0.0.7 - Source: https://github.com/astral-sh/ruff - License: MIT -ruff_diagnostics 0.0.7 - Source: https://github.com/astral-sh/ruff - License: MIT -ruff_formatter 0.0.7 - Source: https://github.com/astral-sh/ruff - License: MIT -ruff_macros 0.0.7 - Source: https://github.com/astral-sh/ruff - License: MIT -ruff_memory_usage 0.0.7 - Source: https://github.com/astral-sh/ruff - License: MIT -ruff_notebook 0.0.7 - Source: https://github.com/astral-sh/ruff - License: MIT -ruff_python_ast 0.0.7 - Source: https://github.com/astral-sh/ruff - License: MIT -ruff_python_formatter 0.0.7 - Source: https://github.com/astral-sh/ruff - License: MIT -ruff_python_parser 0.0.7 - Source: https://github.com/astral-sh/ruff - License: MIT -ruff_python_stdlib 0.0.7 - Source: https://github.com/astral-sh/ruff - License: MIT -ruff_python_trivia 0.0.7 - Source: https://github.com/astral-sh/ruff - License: MIT -ruff_source_file 0.0.7 - Source: https://github.com/astral-sh/ruff - License: MIT -ruff_text_size 0.0.7 - Source: https://github.com/astral-sh/ruff - License: MIT -rustc-demangle 0.1.27 - Source: https://github.com/rust-lang/rustc-demangle - License: MIT OR Apache-2.0 -rustc-hash 2.1.1 - Source: https://github.com/rust-lang/rustc-hash - License: Apache-2.0 OR MIT -rustix 1.1.4 - Source: https://github.com/bytecodealliance/rustix - License: Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT -rustls 0.23.43 - Source: https://github.com/rustls/rustls - License: Apache-2.0 OR MIT OR ISC -rustls-pki-types 1.15.1 - Source: https://github.com/rustls/pki-types - License: MIT OR Apache-2.0 -rustls-webpki 0.103.13 - Source: https://github.com/rustls/webpki - License: ISC -rustversion 1.0.22 - Source: https://github.com/dtolnay/rustversion - License: MIT OR Apache-2.0 -ruzstd 0.8.2 - Source: https://github.com/KillingSpark/zstd-rs - License: MIT -ryu 1.0.23 - Source: https://github.com/dtolnay/ryu - License: Apache-2.0 OR BSL-1.0 -salsa 0.28.1 - Source: https://github.com/salsa-rs/salsa - License: Apache-2.0 OR MIT -salsa-macro-rules 0.28.1 - Source: https://github.com/salsa-rs/salsa - License: Apache-2.0 OR MIT -salsa-macros 0.28.1 - Source: https://github.com/salsa-rs/salsa - License: Apache-2.0 OR MIT -same-file 1.0.6 - Source: https://github.com/BurntSushi/same-file - License: Unlicense OR MIT -scopeguard 1.2.0 - Source: https://github.com/bluss/scopeguard - License: MIT OR Apache-2.0 -scroll 0.13.0 - Source: https://github.com/m4b/scroll - License: MIT -scroll_derive 0.13.1 - Source: https://github.com/m4b/scroll - License: MIT -seahash 4.1.0 - Source: https://gitlab.redox-os.org/redox-os/seahash - License: MIT -serde 1.0.229 - Source: https://github.com/serde-rs/serde - License: MIT OR Apache-2.0 -serde_core 1.0.229 - Source: https://github.com/serde-rs/serde - License: MIT OR Apache-2.0 -serde_derive 1.0.229 - Source: https://github.com/serde-rs/serde - License: MIT OR Apache-2.0 -serde_json 1.0.151 - Source: https://github.com/serde-rs/json - License: MIT OR Apache-2.0 -serde_repr 0.1.20 - Source: https://github.com/dtolnay/serde-repr - License: MIT OR Apache-2.0 -serde_spanned 1.1.1 - Source: https://github.com/toml-rs/toml - License: MIT OR Apache-2.0 -sha1 0.11.0 - Source: https://github.com/RustCrypto/hashes - License: MIT OR Apache-2.0 -sha2 0.11.0 - Source: https://github.com/RustCrypto/hashes - License: MIT OR Apache-2.0 -sharded-slab 0.1.7 - Source: https://github.com/hawkw/sharded-slab - License: MIT -shipwright 0.10.0 - Source: https://github.com/Nimblesite/Shipwright - License: MIT -shipwright-manifest 0.10.0 - Source: https://github.com/Nimblesite/Shipwright - License: MIT -signal-hook-registry 1.4.8 - Source: https://github.com/vorner/signal-hook - License: MIT OR Apache-2.0 -simd-adler32 0.3.8 - Source: https://github.com/mcountryman/simd-adler32 - License: MIT -similar 3.1.1 - Source: https://github.com/mitsuhiko/similar - License: Apache-2.0 -siphasher 1.0.2 - Source: https://github.com/jedisct1/rust-siphash - License: MIT OR Apache-2.0 -slab 0.4.12 - Source: https://github.com/tokio-rs/slab - License: MIT -smallvec 1.15.1 - Source: https://github.com/servo/rust-smallvec - License: MIT OR Apache-2.0 -socket2 0.6.2 - Source: https://github.com/rust-lang/socket2 - License: MIT OR Apache-2.0 -stable_deref_trait 1.2.1 - Source: https://github.com/storyyeller/stable_deref_trait - License: MIT OR Apache-2.0 -static_assertions 1.1.0 - Source: https://github.com/nvzqz/static-assertions-rs - License: MIT OR Apache-2.0 -str_stack 0.1.0 - Source: https://github.com/Stebalien/str_stack - License: MIT OR Apache-2.0 -strsim 0.11.1 - Source: https://github.com/rapidfuzz/strsim-rs - License: MIT -subtle 2.6.1 - Source: https://github.com/dalek-cryptography/subtle - License: BSD-3-Clause -supports-hyperlinks 3.2.0 - Source: https://github.com/zkat/supports-hyperlinks - License: Apache-2.0 -syn 2.0.119 - Source: https://github.com/dtolnay/syn - License: MIT OR Apache-2.0 -syn 3.0.3 - Source: https://github.com/dtolnay/syn - License: MIT OR Apache-2.0 -synstructure 0.13.2 - Source: https://github.com/mystor/synstructure - License: MIT -sysinfo 0.39.6 - Source: https://github.com/GuillaumeGomez/sysinfo - License: MIT -tempfile 3.27.0 - Source: https://github.com/Stebalien/tempfile - License: MIT OR Apache-2.0 -terminal_size 0.4.4 - Source: https://github.com/eminence/terminal-size - License: MIT OR Apache-2.0 -termios 0.3.3 - Source: https://github.com/dcuddeback/termios-rs - License: MIT -thin-vec 0.2.18 - Source: https://github.com/mozilla/thin-vec - License: MIT OR Apache-2.0 -thiserror 2.0.19 - Source: https://github.com/dtolnay/thiserror - License: MIT OR Apache-2.0 -thiserror-impl 2.0.19 - Source: https://github.com/dtolnay/thiserror - License: MIT OR Apache-2.0 -thread_local 1.1.9 - Source: https://github.com/Amanieu/thread_local-rs - License: MIT OR Apache-2.0 -tinystr 0.8.2 - Source: https://github.com/unicode-org/icu4x - License: Unicode-3.0 -tinyvec 1.10.0 - Source: https://github.com/Lokathor/tinyvec - License: Zlib OR Apache-2.0 OR MIT -tinyvec_macros 0.1.1 - Source: https://github.com/Soveu/tinyvec_macros - License: MIT OR Apache-2.0 OR Zlib -tokio 1.50.0 - Source: https://github.com/tokio-rs/tokio - License: MIT -tokio-macros 2.6.0 - Source: https://github.com/tokio-rs/tokio - License: MIT -tokio-tungstenite 0.30.0 - Source: https://github.com/snapview/tokio-tungstenite - License: MIT -tokio-util 0.7.18 - Source: https://github.com/tokio-rs/tokio - License: MIT -toml 1.1.3+spec-1.1.0 - Source: https://github.com/toml-rs/toml - License: MIT OR Apache-2.0 -toml_datetime 1.1.1+spec-1.1.0 - Source: https://github.com/toml-rs/toml - License: MIT OR Apache-2.0 -toml_edit 0.25.13+spec-1.1.0 - Source: https://github.com/toml-rs/toml - License: MIT OR Apache-2.0 -toml_parser 1.1.2+spec-1.1.0 - Source: https://github.com/toml-rs/toml - License: MIT OR Apache-2.0 -toml_writer 1.1.2+spec-1.1.0 - Source: https://github.com/toml-rs/toml - License: MIT OR Apache-2.0 -tower 0.4.13 - Source: https://github.com/tower-rs/tower - License: MIT -tower-layer 0.3.3 - Source: https://github.com/tower-rs/tower - License: MIT -tower-lsp 0.20.0 - Source: https://github.com/ebkalderon/tower-lsp - License: MIT OR Apache-2.0 -tower-lsp-macros 0.9.0 - Source: https://github.com/ebkalderon/tower-lsp - License: MIT OR Apache-2.0 -tower-service 0.3.3 - Source: https://github.com/tower-rs/tower - License: MIT -tracing 0.1.44 - Source: https://github.com/tokio-rs/tracing - License: MIT -tracing-attributes 0.1.31 - Source: https://github.com/tokio-rs/tracing - License: MIT -tracing-core 0.1.36 - Source: https://github.com/tokio-rs/tracing - License: MIT -tracing-log 0.2.0 - Source: https://github.com/tokio-rs/tracing - License: MIT -tracing-subscriber 0.3.23 - Source: https://github.com/tokio-rs/tracing - License: MIT -tungstenite 0.30.0 - Source: https://github.com/snapview/tungstenite-rs - License: MIT OR Apache-2.0 -twox-hash 2.1.2 - Source: https://github.com/shepmaster/twox-hash - License: MIT -ty_static 0.0.7 - Source: https://github.com/astral-sh/ruff - License: MIT -typed-arena 2.0.2 - Source: https://github.com/SimonSapin/rust-typed-arena - License: MIT -typed-path 0.12.3 - Source: https://github.com/chipsenkbeil/typed-path - License: MIT OR Apache-2.0 -typeid 1.0.3 - Source: https://github.com/dtolnay/typeid - License: MIT OR Apache-2.0 -typenum 1.20.1 - Source: https://github.com/paholg/typenum - License: MIT OR Apache-2.0 -unicode-ident 1.0.24 - Source: https://github.com/dtolnay/unicode-ident - License: (MIT OR Apache-2.0) AND Unicode-3.0 -unicode-normalization 0.1.25 - Source: https://github.com/unicode-rs/unicode-normalization - License: MIT OR Apache-2.0 -unicode-width 0.2.2 - Source: https://github.com/unicode-rs/unicode-width - License: MIT OR Apache-2.0 -unicode_names2 1.3.0 - Source: https://github.com/progval/unicode_names2 - License: (MIT OR Apache-2.0) AND Unicode-DFS-2016 -unit-prefix 0.5.2 - Source: https://codeberg.org/commons-rs/unit-prefix - License: MIT -untrusted 0.9.0 - Source: https://github.com/briansmith/untrusted - License: ISC -ureq 3.3.0 - Source: https://github.com/algesten/ureq - License: MIT OR Apache-2.0 -ureq-proto 0.6.0 - Source: https://github.com/algesten/ureq-proto - License: MIT OR Apache-2.0 -url 2.5.8 - Source: https://github.com/servo/rust-url - License: MIT OR Apache-2.0 -utf8-zero 0.8.1 - Source: https://github.com/algesten/utf8-zero - License: MIT OR Apache-2.0 -utf8_iter 1.0.4 - Source: https://github.com/hsivonen/utf8_iter - License: Apache-2.0 OR MIT -utf8parse 0.2.2 - Source: https://github.com/alacritty/vte - License: Apache-2.0 OR MIT -uuid 1.23.4 - Source: https://github.com/uuid-rs/uuid - License: Apache-2.0 OR MIT -walkdir 2.5.0 - Source: https://github.com/BurntSushi/walkdir - License: Unlicense OR MIT -webpki-roots 1.0.9 - Source: https://github.com/rustls/webpki-roots - License: CDLA-Permissive-2.0 -winapi 0.3.9 - Source: https://github.com/retep998/winapi-rs - License: MIT OR Apache-2.0 -winapi-util 0.1.11 - Source: https://github.com/BurntSushi/winapi-util - License: Unlicense OR MIT -windows 0.62.2 - Source: https://github.com/microsoft/windows-rs - License: MIT OR Apache-2.0 -windows-collections 0.3.2 - Source: https://github.com/microsoft/windows-rs - License: MIT OR Apache-2.0 -windows-core 0.62.2 - Source: https://github.com/microsoft/windows-rs - License: MIT OR Apache-2.0 -windows-future 0.3.2 - Source: https://github.com/microsoft/windows-rs - License: MIT OR Apache-2.0 -windows-implement 0.60.2 - Source: https://github.com/microsoft/windows-rs - License: MIT OR Apache-2.0 -windows-interface 0.59.3 - Source: https://github.com/microsoft/windows-rs - License: MIT OR Apache-2.0 -windows-link 0.2.1 - Source: https://github.com/microsoft/windows-rs - License: MIT OR Apache-2.0 -windows-numerics 0.3.1 - Source: https://github.com/microsoft/windows-rs - License: MIT OR Apache-2.0 -windows-result 0.4.1 - Source: https://github.com/microsoft/windows-rs - License: MIT OR Apache-2.0 -windows-strings 0.5.1 - Source: https://github.com/microsoft/windows-rs - License: MIT OR Apache-2.0 -windows-sys 0.52.0 - Source: https://github.com/microsoft/windows-rs - License: MIT OR Apache-2.0 -windows-sys 0.60.2 - Source: https://github.com/microsoft/windows-rs - License: MIT OR Apache-2.0 -windows-sys 0.61.2 - Source: https://github.com/microsoft/windows-rs - License: MIT OR Apache-2.0 -windows-targets 0.52.6 - Source: https://github.com/microsoft/windows-rs - License: MIT OR Apache-2.0 -windows-targets 0.53.5 - Source: https://github.com/microsoft/windows-rs - License: MIT OR Apache-2.0 -windows-threading 0.2.1 - Source: https://github.com/microsoft/windows-rs - License: MIT OR Apache-2.0 -windows_aarch64_msvc 0.52.6 - Source: https://github.com/microsoft/windows-rs - License: MIT OR Apache-2.0 -windows_aarch64_msvc 0.53.1 - Source: https://github.com/microsoft/windows-rs - License: MIT OR Apache-2.0 -windows_x86_64_gnu 0.52.6 - Source: https://github.com/microsoft/windows-rs - License: MIT OR Apache-2.0 -windows_x86_64_gnu 0.53.1 - Source: https://github.com/microsoft/windows-rs - License: MIT OR Apache-2.0 -windows_x86_64_msvc 0.52.6 - Source: https://github.com/microsoft/windows-rs - License: MIT OR Apache-2.0 -windows_x86_64_msvc 0.53.1 - Source: https://github.com/microsoft/windows-rs - License: MIT OR Apache-2.0 -winnow 1.0.3 - Source: https://github.com/winnow-rs/winnow - License: MIT -writeable 0.6.2 - Source: https://github.com/unicode-org/icu4x - License: Unicode-3.0 -yoke 0.8.1 - Source: https://github.com/unicode-org/icu4x - License: Unicode-3.0 -yoke-derive 0.8.1 - Source: https://github.com/unicode-org/icu4x - License: Unicode-3.0 -zerocopy 0.8.40 - Source: https://github.com/google/zerocopy - License: BSD-2-Clause OR Apache-2.0 OR MIT -zerofrom 0.1.6 - Source: https://github.com/unicode-org/icu4x - License: Unicode-3.0 -zerofrom-derive 0.1.6 - Source: https://github.com/unicode-org/icu4x - License: Unicode-3.0 -zeroize 1.9.0 - Source: https://github.com/RustCrypto/utils - License: Apache-2.0 OR MIT -zerotrie 0.2.3 - Source: https://github.com/unicode-org/icu4x - License: Unicode-3.0 -zerovec 0.11.5 - Source: https://github.com/unicode-org/icu4x - License: Unicode-3.0 -zerovec-derive 0.11.2 - Source: https://github.com/unicode-org/icu4x - License: Unicode-3.0 -zip 8.6.0 - Source: https://github.com/zip-rs/zip2 - License: MIT -zlib-rs 0.6.5 - Source: https://github.com/trifectatechfoundation/zlib-rs - License: Zlib -zmij 1.0.21 - Source: https://github.com/dtolnay/zmij - License: MIT -zopfli 0.8.3 - Source: https://github.com/zopfli-rs/zopfli - License: Apache-2.0 - -License texts and notices -------------------------- -=============================================================================== -Apache License 2.0 -=============================================================================== - - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright [yyyy] [name of copyright owner] - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. -=============================================================================== -MIT License -=============================================================================== -Copyright (c) 2014 Carl Lerche and other MIO contributors - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. - -=============================================================================== -Unicode License v3 -=============================================================================== -UNICODE LICENSE V3 - -COPYRIGHT AND PERMISSION NOTICE - -Copyright © 1991-2023 Unicode, Inc. - -NOTICE TO USER: Carefully read the following legal agreement. BY -DOWNLOADING, INSTALLING, COPYING OR OTHERWISE USING DATA FILES, AND/OR -SOFTWARE, YOU UNEQUIVOCALLY ACCEPT, AND AGREE TO BE BOUND BY, ALL OF THE -TERMS AND CONDITIONS OF THIS AGREEMENT. IF YOU DO NOT AGREE, DO NOT -DOWNLOAD, INSTALL, COPY, DISTRIBUTE OR USE THE DATA FILES OR SOFTWARE. - -Permission is hereby granted, free of charge, to any person obtaining a -copy of data files and any associated documentation (the "Data Files") or -software and any associated documentation (the "Software") to deal in the -Data Files or Software without restriction, including without limitation -the rights to use, copy, modify, merge, publish, distribute, and/or sell -copies of the Data Files or Software, and to permit persons to whom the -Data Files or Software are furnished to do so, provided that either (a) -this copyright and permission notice appear with all copies of the Data -Files or Software, or (b) this copyright and permission notice appear in -associated Documentation. - -THE DATA FILES AND SOFTWARE ARE PROVIDED "AS IS", WITHOUT WARRANTY OF ANY -KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF -THIRD PARTY RIGHTS. - -IN NO EVENT SHALL THE COPYRIGHT HOLDER OR HOLDERS INCLUDED IN THIS NOTICE -BE LIABLE FOR ANY CLAIM, OR ANY SPECIAL INDIRECT OR CONSEQUENTIAL DAMAGES, -OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, -WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, -ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THE DATA -FILES OR SOFTWARE. - -Except as contained in this notice, the name of a copyright holder shall -not be used in advertising or otherwise to promote the sale, use or other -dealings in these Data Files or Software without prior written -authorization of the copyright holder. - -=============================================================================== -ISC License -=============================================================================== -// Copyright 2015-2016 Brian Smith. -// -// Permission to use, copy, modify, and/or distribute this software for any -// purpose with or without fee is hereby granted, provided that the above -// copyright notice and this permission notice appear in all copies. -// -// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHORS DISCLAIM ALL WARRANTIES -// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF -// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR -// ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES -// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN -// ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF -// OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. - -=============================================================================== -BSD 3-Clause "New" or "Revised" License -=============================================================================== -BSD 3-Clause License - -Copyright (c) 2013, Julien Schmidt -All rights reserved. - -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are met: - -1. Redistributions of source code must retain the above copyright notice, this - list of conditions and the following disclaimer. - -2. Redistributions in binary form must reproduce the above copyright notice, - this list of conditions and the following disclaimer in the documentation - and/or other materials provided with the distribution. - -3. Neither the name of the copyright holder nor the names of its - contributors may be used to endorse or promote products derived from - this software without specific prior written permission. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" -AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE -IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE -FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL -DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR -SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER -CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, -OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - -=============================================================================== -zlib License -=============================================================================== -(C) 2024 Trifecta Tech Foundation - -This software is provided 'as-is', without any express or implied -warranty. In no event will the authors be held liable for any damages -arising from the use of this software. - -Permission is granted to anyone to use this software for any purpose, -including commercial applications, and to alter it and redistribute it -freely, subject to the following restrictions: - -1. The origin of this software must not be misrepresented; you must not - claim that you wrote the original software. If you use this software - in a product, an acknowledgment in the product documentation would be - appreciated but is not required. - -2. Altered source versions must be plainly marked as such, and must not be - misrepresented as being the original software. - -3. This notice may not be removed or altered from any source distribution. - -=============================================================================== -BSD 2-Clause "Simplified" License -=============================================================================== -Copyright (c) 2015, Nick Fitzgerald -All rights reserved. - -Redistribution and use in source and binary forms, with or without modification, -are permitted provided that the following conditions are met: - -1. Redistributions of source code must retain the above copyright notice, this - list of conditions and the following disclaimer. - -2. Redistributions in binary form must reproduce the above copyright notice, - this list of conditions and the following disclaimer in the documentation - and/or other materials provided with the distribution. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND -ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED -WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR -ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES -(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; -LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON -ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS -SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - -=============================================================================== -Common Development and Distribution License 1.0 -=============================================================================== -Unless otherwise noted, all files in this distribution are released -under the Common Development and Distribution License (CDDL). -Exceptions are noted within the associated source files. - --------------------------------------------------------------------- - - -COMMON DEVELOPMENT AND DISTRIBUTION LICENSE Version 1.0 - -1. Definitions. - - 1.1. "Contributor" means each individual or entity that creates - or contributes to the creation of Modifications. - - 1.2. "Contributor Version" means the combination of the Original - Software, prior Modifications used by a Contributor (if any), - and the Modifications made by that particular Contributor. - - 1.3. "Covered Software" means (a) the Original Software, or (b) - Modifications, or (c) the combination of files containing - Original Software with files containing Modifications, in - each case including portions thereof. - - 1.4. "Executable" means the Covered Software in any form other - than Source Code. - - 1.5. "Initial Developer" means the individual or entity that first - makes Original Software available under this License. - - 1.6. "Larger Work" means a work which combines Covered Software or - portions thereof with code not governed by the terms of this - License. - - 1.7. "License" means this document. - - 1.8. "Licensable" means having the right to grant, to the maximum - extent possible, whether at the time of the initial grant or - subsequently acquired, any and all of the rights conveyed - herein. - - 1.9. "Modifications" means the Source Code and Executable form of - any of the following: - - A. Any file that results from an addition to, deletion from or - modification of the contents of a file containing Original - Software or previous Modifications; - - B. Any new file that contains any part of the Original - Software or previous Modifications; or - - C. Any new file that is contributed or otherwise made - available under the terms of this License. - - 1.10. "Original Software" means the Source Code and Executable - form of computer software code that is originally released - under this License. - - 1.11. "Patent Claims" means any patent claim(s), now owned or - hereafter acquired, including without limitation, method, - process, and apparatus claims, in any patent Licensable by - grantor. - - 1.12. "Source Code" means (a) the common form of computer software - code in which modifications are made and (b) associated - documentation included in or with such code. - - 1.13. "You" (or "Your") means an individual or a legal entity - exercising rights under, and complying with all of the terms - of, this License. For legal entities, "You" includes any - entity which controls, is controlled by, or is under common - control with You. For purposes of this definition, - "control" means (a) the power, direct or indirect, to cause - the direction or management of such entity, whether by - contract or otherwise, or (b) ownership of more than fifty - percent (50%) of the outstanding shares or beneficial - ownership of such entity. - -2. License Grants. - - 2.1. The Initial Developer Grant. - - Conditioned upon Your compliance with Section 3.1 below and - subject to third party intellectual property claims, the Initial - Developer hereby grants You a world-wide, royalty-free, - non-exclusive license: - - (a) under intellectual property rights (other than patent or - trademark) Licensable by Initial Developer, to use, - reproduce, modify, display, perform, sublicense and - distribute the Original Software (or portions thereof), - with or without Modifications, and/or as part of a Larger - Work; and - - (b) under Patent Claims infringed by the making, using or - selling of Original Software, to make, have made, use, - practice, sell, and offer for sale, and/or otherwise - dispose of the Original Software (or portions thereof). - - (c) The licenses granted in Sections 2.1(a) and (b) are - effective on the date Initial Developer first distributes - or otherwise makes the Original Software available to a - third party under the terms of this License. - - (d) Notwithstanding Section 2.1(b) above, no patent license is - granted: (1) for code that You delete from the Original - Software, or (2) for infringements caused by: (i) the - modification of the Original Software, or (ii) the - combination of the Original Software with other software - or devices. - - 2.2. Contributor Grant. - - Conditioned upon Your compliance with Section 3.1 below and - subject to third party intellectual property claims, each - Contributor hereby grants You a world-wide, royalty-free, - non-exclusive license: - - (a) under intellectual property rights (other than patent or - trademark) Licensable by Contributor to use, reproduce, - modify, display, perform, sublicense and distribute the - Modifications created by such Contributor (or portions - thereof), either on an unmodified basis, with other - Modifications, as Covered Software and/or as part of a - Larger Work; and - - (b) under Patent Claims infringed by the making, using, or - selling of Modifications made by that Contributor either - alone and/or in combination with its Contributor Version - (or portions of such combination), to make, use, sell, - offer for sale, have made, and/or otherwise dispose of: - (1) Modifications made by that Contributor (or portions - thereof); and (2) the combination of Modifications made by - that Contributor with its Contributor Version (or portions - of such combination). - - (c) The licenses granted in Sections 2.2(a) and 2.2(b) are - effective on the date Contributor first distributes or - otherwise makes the Modifications available to a third - party. - - (d) Notwithstanding Section 2.2(b) above, no patent license is - granted: (1) for any code that Contributor has deleted - from the Contributor Version; (2) for infringements caused - by: (i) third party modifications of Contributor Version, - or (ii) the combination of Modifications made by that - Contributor with other software (except as part of the - Contributor Version) or other devices; or (3) under Patent - Claims infringed by Covered Software in the absence of - Modifications made by that Contributor. - -3. Distribution Obligations. - - 3.1. Availability of Source Code. - - Any Covered Software that You distribute or otherwise make - available in Executable form must also be made available in Source - Code form and that Source Code form must be distributed only under - the terms of this License. You must include a copy of this - License with every copy of the Source Code form of the Covered - Software You distribute or otherwise make available. You must - inform recipients of any such Covered Software in Executable form - as to how they can obtain such Covered Software in Source Code - form in a reasonable manner on or through a medium customarily - used for software exchange. - - 3.2. Modifications. - - The Modifications that You create or to which You contribute are - governed by the terms of this License. You represent that You - believe Your Modifications are Your original creation(s) and/or - You have sufficient rights to grant the rights conveyed by this - License. - - 3.3. Required Notices. - - You must include a notice in each of Your Modifications that - identifies You as the Contributor of the Modification. You may - not remove or alter any copyright, patent or trademark notices - contained within the Covered Software, or any notices of licensing - or any descriptive text giving attribution to any Contributor or - the Initial Developer. - - 3.4. Application of Additional Terms. - - You may not offer or impose any terms on any Covered Software in - Source Code form that alters or restricts the applicable version - of this License or the recipients' rights hereunder. You may - choose to offer, and to charge a fee for, warranty, support, - indemnity or liability obligations to one or more recipients of - Covered Software. However, you may do so only on Your own behalf, - and not on behalf of the Initial Developer or any Contributor. - You must make it absolutely clear that any such warranty, support, - indemnity or liability obligation is offered by You alone, and You - hereby agree to indemnify the Initial Developer and every - Contributor for any liability incurred by the Initial Developer or - such Contributor as a result of warranty, support, indemnity or - liability terms You offer. - - 3.5. Distribution of Executable Versions. - - You may distribute the Executable form of the Covered Software - under the terms of this License or under the terms of a license of - Your choice, which may contain terms different from this License, - provided that You are in compliance with the terms of this License - and that the license for the Executable form does not attempt to - limit or alter the recipient's rights in the Source Code form from - the rights set forth in this License. If You distribute the - Covered Software in Executable form under a different license, You - must make it absolutely clear that any terms which differ from - this License are offered by You alone, not by the Initial - Developer or Contributor. You hereby agree to indemnify the - Initial Developer and every Contributor for any liability incurred - by the Initial Developer or such Contributor as a result of any - such terms You offer. - - 3.6. Larger Works. - - You may create a Larger Work by combining Covered Software with - other code not governed by the terms of this License and - distribute the Larger Work as a single product. In such a case, - You must make sure the requirements of this License are fulfilled - for the Covered Software. - -4. Versions of the License. - - 4.1. New Versions. - - Sun Microsystems, Inc. is the initial license steward and may - publish revised and/or new versions of this License from time to - time. Each version will be given a distinguishing version number. - Except as provided in Section 4.3, no one other than the license - steward has the right to modify this License. - - 4.2. Effect of New Versions. - - You may always continue to use, distribute or otherwise make the - Covered Software available under the terms of the version of the - License under which You originally received the Covered Software. - If the Initial Developer includes a notice in the Original - Software prohibiting it from being distributed or otherwise made - available under any subsequent version of the License, You must - distribute and make the Covered Software available under the terms - of the version of the License under which You originally received - the Covered Software. Otherwise, You may also choose to use, - distribute or otherwise make the Covered Software available under - the terms of any subsequent version of the License published by - the license steward. - - 4.3. Modified Versions. - - When You are an Initial Developer and You want to create a new - license for Your Original Software, You may create and use a - modified version of this License if You: (a) rename the license - and remove any references to the name of the license steward - (except to note that the license differs from this License); and - (b) otherwise make it clear that the license contains terms which - differ from this License. - -5. DISCLAIMER OF WARRANTY. - - COVERED SOFTWARE IS PROVIDED UNDER THIS LICENSE ON AN "AS IS" - BASIS, WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, - INCLUDING, WITHOUT LIMITATION, WARRANTIES THAT THE COVERED - SOFTWARE IS FREE OF DEFECTS, MERCHANTABLE, FIT FOR A PARTICULAR - PURPOSE OR NON-INFRINGING. THE ENTIRE RISK AS TO THE QUALITY AND - PERFORMANCE OF THE COVERED SOFTWARE IS WITH YOU. SHOULD ANY - COVERED SOFTWARE PROVE DEFECTIVE IN ANY RESPECT, YOU (NOT THE - INITIAL DEVELOPER OR ANY OTHER CONTRIBUTOR) ASSUME THE COST OF ANY - NECESSARY SERVICING, REPAIR OR CORRECTION. THIS DISCLAIMER OF - WARRANTY CONSTITUTES AN ESSENTIAL PART OF THIS LICENSE. NO USE OF - ANY COVERED SOFTWARE IS AUTHORIZED HEREUNDER EXCEPT UNDER THIS - DISCLAIMER. - -6. TERMINATION. - - 6.1. This License and the rights granted hereunder will terminate - automatically if You fail to comply with terms herein and fail to - cure such breach within 30 days of becoming aware of the breach. - Provisions which, by their nature, must remain in effect beyond - the termination of this License shall survive. - - 6.2. If You assert a patent infringement claim (excluding - declaratory judgment actions) against Initial Developer or a - Contributor (the Initial Developer or Contributor against whom You - assert such claim is referred to as "Participant") alleging that - the Participant Software (meaning the Contributor Version where - the Participant is a Contributor or the Original Software where - the Participant is the Initial Developer) directly or indirectly - infringes any patent, then any and all rights granted directly or - indirectly to You by such Participant, the Initial Developer (if - the Initial Developer is not the Participant) and all Contributors - under Sections 2.1 and/or 2.2 of this License shall, upon 60 days - notice from Participant terminate prospectively and automatically - at the expiration of such 60 day notice period, unless if within - such 60 day period You withdraw Your claim with respect to the - Participant Software against such Participant either unilaterally - or pursuant to a written agreement with Participant. - - 6.3. In the event of termination under Sections 6.1 or 6.2 above, - all end user licenses that have been validly granted by You or any - distributor hereunder prior to termination (excluding licenses - granted to You by any distributor) shall survive termination. - -7. LIMITATION OF LIABILITY. - - UNDER NO CIRCUMSTANCES AND UNDER NO LEGAL THEORY, WHETHER TORT - (INCLUDING NEGLIGENCE), CONTRACT, OR OTHERWISE, SHALL YOU, THE - INITIAL DEVELOPER, ANY OTHER CONTRIBUTOR, OR ANY DISTRIBUTOR OF - COVERED SOFTWARE, OR ANY SUPPLIER OF ANY OF SUCH PARTIES, BE - LIABLE TO ANY PERSON FOR ANY INDIRECT, SPECIAL, INCIDENTAL, OR - CONSEQUENTIAL DAMAGES OF ANY CHARACTER INCLUDING, WITHOUT - LIMITATION, DAMAGES FOR LOST PROFITS, LOSS OF GOODWILL, WORK - STOPPAGE, COMPUTER FAILURE OR MALFUNCTION, OR ANY AND ALL OTHER - COMMERCIAL DAMAGES OR LOSSES, EVEN IF SUCH PARTY SHALL HAVE BEEN - INFORMED OF THE POSSIBILITY OF SUCH DAMAGES. THIS LIMITATION OF - LIABILITY SHALL NOT APPLY TO LIABILITY FOR DEATH OR PERSONAL - INJURY RESULTING FROM SUCH PARTY'S NEGLIGENCE TO THE EXTENT - APPLICABLE LAW PROHIBITS SUCH LIMITATION. SOME JURISDICTIONS DO - NOT ALLOW THE EXCLUSION OR LIMITATION OF INCIDENTAL OR - CONSEQUENTIAL DAMAGES, SO THIS EXCLUSION AND LIMITATION MAY NOT - APPLY TO YOU. - -8. U.S. GOVERNMENT END USERS. - - The Covered Software is a "commercial item," as that term is - defined in 48 C.F.R. 2.101 (Oct. 1995), consisting of "commercial - computer software" (as that term is defined at 48 - C.F.R. 252.227-7014(a)(1)) and "commercial computer software - documentation" as such terms are used in 48 C.F.R. 12.212 - (Sept. 1995). Consistent with 48 C.F.R. 12.212 and 48 - C.F.R. 227.7202-1 through 227.7202-4 (June 1995), all - U.S. Government End Users acquire Covered Software with only those - rights set forth herein. This U.S. Government Rights clause is in - lieu of, and supersedes, any other FAR, DFAR, or other clause or - provision that addresses Government rights in computer software - under this License. - -9. MISCELLANEOUS. - - This License represents the complete agreement concerning subject - matter hereof. If any provision of this License is held to be - unenforceable, such provision shall be reformed only to the extent - necessary to make it enforceable. This License shall be governed - by the law of the jurisdiction specified in a notice contained - within the Original Software (except to the extent applicable law, - if any, provides otherwise), excluding such jurisdiction's - conflict-of-law provisions. Any litigation relating to this - License shall be subject to the jurisdiction of the courts located - in the jurisdiction and venue specified in a notice contained - within the Original Software, with the losing party responsible - for costs, including, without limitation, court costs and - reasonable attorneys' fees and expenses. The application of the - United Nations Convention on Contracts for the International Sale - of Goods is expressly excluded. Any law or regulation which - provides that the language of a contract shall be construed - against the drafter shall not apply to this License. You agree - that You alone are responsible for compliance with the United - States export administration regulations (and the export control - laws and regulation of any other countries) when You use, - distribute or otherwise make available any Covered Software. - -10. RESPONSIBILITY FOR CLAIMS. - - As between Initial Developer and the Contributors, each party is - responsible for claims and damages arising, directly or - indirectly, out of its utilization of rights under this License - and You agree to work with Initial Developer and Contributors to - distribute such responsibility on an equitable basis. Nothing - herein is intended or shall be deemed to constitute any admission - of liability. - --------------------------------------------------------------------- - -NOTICE PURSUANT TO SECTION 9 OF THE COMMON DEVELOPMENT AND -DISTRIBUTION LICENSE (CDDL) - -For Covered Software in this distribution, this License shall -be governed by the laws of the State of California (excluding -conflict-of-law provisions). - -Any litigation relating to this License shall be subject to the -jurisdiction of the Federal Courts of the Northern District of -California and the state courts of the State of California, with -venue lying in Santa Clara County, California. - -=============================================================================== -Community Data License Agreement Permissive 2.0 -=============================================================================== -# Community Data License Agreement - Permissive - Version 2.0 - -This is the Community Data License Agreement - Permissive, Version -2.0 (the "agreement"). Data Provider(s) and Data Recipient(s) agree -as follows: - -## 1. Provision of the Data - -1.1. A Data Recipient may use, modify, and share the Data made -available by Data Provider(s) under this agreement if that Data -Recipient follows the terms of this agreement. - -1.2. This agreement does not impose any restriction on a Data -Recipient's use, modification, or sharing of any portions of the -Data that are in the public domain or that may be used, modified, -or shared under any other legal exception or limitation. - -## 2. Conditions for Sharing Data - -2.1. A Data Recipient may share Data, with or without modifications, so -long as the Data Recipient makes available the text of this agreement -with the shared Data. - -## 3. No Restrictions on Results - -3.1. This agreement does not impose any restriction or obligations -with respect to the use, modification, or sharing of Results. - -## 4. No Warranty; Limitation of Liability - -4.1. All Data Recipients receive the Data subject to the following -terms: - -THE DATA IS PROVIDED ON AN "AS IS" BASIS, WITHOUT REPRESENTATIONS, -WARRANTIES OR CONDITIONS OF ANY KIND, EITHER EXPRESS OR IMPLIED -INCLUDING, WITHOUT LIMITATION, ANY WARRANTIES OR CONDITIONS OF TITLE, -NON-INFRINGEMENT, MERCHANTABILITY OR FITNESS FOR A PARTICULAR PURPOSE. - -NO DATA PROVIDER SHALL HAVE ANY LIABILITY FOR ANY DIRECT, INDIRECT, -INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING -WITHOUT LIMITATION LOST PROFITS), HOWEVER CAUSED AND ON ANY THEORY OF -LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING -NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE DATA OR RESULTS, -EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. - -## 5. Definitions - -5.1. "Data" means the material received by a Data Recipient under -this agreement. - -5.2. "Data Provider" means any person who is the source of Data -provided under this agreement and in reliance on a Data Recipient's -agreement to its terms. - -5.3. "Data Recipient" means any person who receives Data directly -or indirectly from a Data Provider and agrees to the terms of this -agreement. - -5.4. "Results" means any outcome obtained by computational analysis -of Data, including for example machine learning models and models' -insights. - -=============================================================================== -Mozilla Public License 2.0 -=============================================================================== -Mozilla Public License Version 2.0 -================================== - -1. Definitions --------------- - -1.1. "Contributor" - means each individual or legal entity that creates, contributes to - the creation of, or owns Covered Software. - -1.2. "Contributor Version" - means the combination of the Contributions of others (if any) used - by a Contributor and that particular Contributor's Contribution. - -1.3. "Contribution" - means Covered Software of a particular Contributor. - -1.4. "Covered Software" - means Source Code Form to which the initial Contributor has attached - the notice in Exhibit A, the Executable Form of such Source Code - Form, and Modifications of such Source Code Form, in each case - including portions thereof. - -1.5. "Incompatible With Secondary Licenses" - means - - (a) that the initial Contributor has attached the notice described - in Exhibit B to the Covered Software; or - - (b) that the Covered Software was made available under the terms of - version 1.1 or earlier of the License, but not also under the - terms of a Secondary License. - -1.6. "Executable Form" - means any form of the work other than Source Code Form. - -1.7. "Larger Work" - means a work that combines Covered Software with other material, in - a separate file or files, that is not Covered Software. - -1.8. "License" - means this document. - -1.9. "Licensable" - means having the right to grant, to the maximum extent possible, - whether at the time of the initial grant or subsequently, any and - all of the rights conveyed by this License. - -1.10. "Modifications" - means any of the following: - - (a) any file in Source Code Form that results from an addition to, - deletion from, or modification of the contents of Covered - Software; or - - (b) any new file in Source Code Form that contains any Covered - Software. - -1.11. "Patent Claims" of a Contributor - means any patent claim(s), including without limitation, method, - process, and apparatus claims, in any patent Licensable by such - Contributor that would be infringed, but for the grant of the - License, by the making, using, selling, offering for sale, having - made, import, or transfer of either its Contributions or its - Contributor Version. - -1.12. "Secondary License" - means either the GNU General Public License, Version 2.0, the GNU - Lesser General Public License, Version 2.1, the GNU Affero General - Public License, Version 3.0, or any later versions of those - licenses. - -1.13. "Source Code Form" - means the form of the work preferred for making modifications. - -1.14. "You" (or "Your") - means an individual or a legal entity exercising rights under this - License. For legal entities, "You" includes any entity that - controls, is controlled by, or is under common control with You. For - purposes of this definition, "control" means (a) the power, direct - or indirect, to cause the direction or management of such entity, - whether by contract or otherwise, or (b) ownership of more than - fifty percent (50%) of the outstanding shares or beneficial - ownership of such entity. - -2. License Grants and Conditions --------------------------------- - -2.1. Grants - -Each Contributor hereby grants You a world-wide, royalty-free, -non-exclusive license: - -(a) under intellectual property rights (other than patent or trademark) - Licensable by such Contributor to use, reproduce, make available, - modify, display, perform, distribute, and otherwise exploit its - Contributions, either on an unmodified basis, with Modifications, or - as part of a Larger Work; and - -(b) under Patent Claims of such Contributor to make, use, sell, offer - for sale, have made, import, and otherwise transfer either its - Contributions or its Contributor Version. - -2.2. Effective Date - -The licenses granted in Section 2.1 with respect to any Contribution -become effective for each Contribution on the date the Contributor first -distributes such Contribution. - -2.3. Limitations on Grant Scope - -The licenses granted in this Section 2 are the only rights granted under -this License. No additional rights or licenses will be implied from the -distribution or licensing of Covered Software under this License. -Notwithstanding Section 2.1(b) above, no patent license is granted by a -Contributor: - -(a) for any code that a Contributor has removed from Covered Software; - or - -(b) for infringements caused by: (i) Your and any other third party's - modifications of Covered Software, or (ii) the combination of its - Contributions with other software (except as part of its Contributor - Version); or - -(c) under Patent Claims infringed by Covered Software in the absence of - its Contributions. - -This License does not grant any rights in the trademarks, service marks, -or logos of any Contributor (except as may be necessary to comply with -the notice requirements in Section 3.4). - -2.4. Subsequent Licenses - -No Contributor makes additional grants as a result of Your choice to -distribute the Covered Software under a subsequent version of this -License (see Section 10.2) or under the terms of a Secondary License (if -permitted under the terms of Section 3.3). - -2.5. Representation - -Each Contributor represents that the Contributor believes its -Contributions are its original creation(s) or it has sufficient rights -to grant the rights to its Contributions conveyed by this License. - -2.6. Fair Use - -This License is not intended to limit any rights You have under -applicable copyright doctrines of fair use, fair dealing, or other -equivalents. - -2.7. Conditions - -Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted -in Section 2.1. - -3. Responsibilities -------------------- - -3.1. Distribution of Source Form - -All distribution of Covered Software in Source Code Form, including any -Modifications that You create or to which You contribute, must be under -the terms of this License. You must inform recipients that the Source -Code Form of the Covered Software is governed by the terms of this -License, and how they can obtain a copy of this License. You may not -attempt to alter or restrict the recipients' rights in the Source Code -Form. - -3.2. Distribution of Executable Form - -If You distribute Covered Software in Executable Form then: - -(a) such Covered Software must also be made available in Source Code - Form, as described in Section 3.1, and You must inform recipients of - the Executable Form how they can obtain a copy of such Source Code - Form by reasonable means in a timely manner, at a charge no more - than the cost of distribution to the recipient; and - -(b) You may distribute such Executable Form under the terms of this - License, or sublicense it under different terms, provided that the - license for the Executable Form does not attempt to limit or alter - the recipients' rights in the Source Code Form under this License. - -3.3. Distribution of a Larger Work - -You may create and distribute a Larger Work under terms of Your choice, -provided that You also comply with the requirements of this License for -the Covered Software. If the Larger Work is a combination of Covered -Software with a work governed by one or more Secondary Licenses, and the -Covered Software is not Incompatible With Secondary Licenses, this -License permits You to additionally distribute such Covered Software -under the terms of such Secondary License(s), so that the recipient of -the Larger Work may, at their option, further distribute the Covered -Software under the terms of either this License or such Secondary -License(s). - -3.4. Notices - -You may not remove or alter the substance of any license notices -(including copyright notices, patent notices, disclaimers of warranty, -or limitations of liability) contained within the Source Code Form of -the Covered Software, except that You may alter any license notices to -the extent required to remedy known factual inaccuracies. - -3.5. Application of Additional Terms - -You may choose to offer, and to charge a fee for, warranty, support, -indemnity or liability obligations to one or more recipients of Covered -Software. However, You may do so only on Your own behalf, and not on -behalf of any Contributor. You must make it absolutely clear that any -such warranty, support, indemnity, or liability obligation is offered by -You alone, and You hereby agree to indemnify every Contributor for any -liability incurred by such Contributor as a result of warranty, support, -indemnity or liability terms You offer. You may include additional -disclaimers of warranty and limitations of liability specific to any -jurisdiction. - -4. Inability to Comply Due to Statute or Regulation ---------------------------------------------------- - -If it is impossible for You to comply with any of the terms of this -License with respect to some or all of the Covered Software due to -statute, judicial order, or regulation then You must: (a) comply with -the terms of this License to the maximum extent possible; and (b) -describe the limitations and the code they affect. Such description must -be placed in a text file included with all distributions of the Covered -Software under this License. Except to the extent prohibited by statute -or regulation, such description must be sufficiently detailed for a -recipient of ordinary skill to be able to understand it. - -5. Termination --------------- - -5.1. The rights granted under this License will terminate automatically -if You fail to comply with any of its terms. However, if You become -compliant, then the rights granted under this License from a particular -Contributor are reinstated (a) provisionally, unless and until such -Contributor explicitly and finally terminates Your grants, and (b) on an -ongoing basis, if such Contributor fails to notify You of the -non-compliance by some reasonable means prior to 60 days after You have -come back into compliance. Moreover, Your grants from a particular -Contributor are reinstated on an ongoing basis if such Contributor -notifies You of the non-compliance by some reasonable means, this is the -first time You have received notice of non-compliance with this License -from such Contributor, and You become compliant prior to 30 days after -Your receipt of the notice. - -5.2. If You initiate litigation against any entity by asserting a patent -infringement claim (excluding declaratory judgment actions, -counter-claims, and cross-claims) alleging that a Contributor Version -directly or indirectly infringes any patent, then the rights granted to -You by any and all Contributors for the Covered Software under Section -2.1 of this License shall terminate. - -5.3. In the event of termination under Sections 5.1 or 5.2 above, all -end user license agreements (excluding distributors and resellers) which -have been validly granted by You or Your distributors under this License -prior to termination shall survive termination. - -************************************************************************ -* * -* 6. Disclaimer of Warranty * -* ------------------------- * -* * -* Covered Software is provided under this License on an "as is" * -* basis, without warranty of any kind, either expressed, implied, or * -* statutory, including, without limitation, warranties that the * -* Covered Software is free of defects, merchantable, fit for a * -* particular purpose or non-infringing. The entire risk as to the * -* quality and performance of the Covered Software is with You. * -* Should any Covered Software prove defective in any respect, You * -* (not any Contributor) assume the cost of any necessary servicing, * -* repair, or correction. This disclaimer of warranty constitutes an * -* essential part of this License. No use of any Covered Software is * -* authorized under this License except under this disclaimer. * -* * -************************************************************************ - -************************************************************************ -* * -* 7. Limitation of Liability * -* -------------------------- * -* * -* Under no circumstances and under no legal theory, whether tort * -* (including negligence), contract, or otherwise, shall any * -* Contributor, or anyone who distributes Covered Software as * -* permitted above, be liable to You for any direct, indirect, * -* special, incidental, or consequential damages of any character * -* including, without limitation, damages for lost profits, loss of * -* goodwill, work stoppage, computer failure or malfunction, or any * -* and all other commercial damages or losses, even if such party * -* shall have been informed of the possibility of such damages. This * -* limitation of liability shall not apply to liability for death or * -* personal injury resulting from such party's negligence to the * -* extent applicable law prohibits such limitation. Some * -* jurisdictions do not allow the exclusion or limitation of * -* incidental or consequential damages, so this exclusion and * -* limitation may not apply to You. * -* * -************************************************************************ - -8. Litigation -------------- - -Any litigation relating to this License may be brought only in the -courts of a jurisdiction where the defendant maintains its principal -place of business and such litigation shall be governed by laws of that -jurisdiction, without reference to its conflict-of-law provisions. -Nothing in this Section shall prevent a party's ability to bring -cross-claims or counter-claims. - -9. Miscellaneous ----------------- - -This License represents the complete agreement concerning the subject -matter hereof. If any provision of this License is held to be -unenforceable, such provision shall be reformed only to the extent -necessary to make it enforceable. Any law or regulation which provides -that the language of a contract shall be construed against the drafter -shall not be used to construe this License against a Contributor. - -10. Versions of the License ---------------------------- - -10.1. New Versions - -Mozilla Foundation is the license steward. Except as provided in Section -10.3, no one other than the license steward has the right to modify or -publish new versions of this License. Each version will be given a -distinguishing version number. - -10.2. Effect of New Versions - -You may distribute the Covered Software under the terms of the version -of the License under which You originally received the Covered Software, -or under the terms of any subsequent version published by the license -steward. - -10.3. Modified Versions - -If you create software not governed by this License, and you want to -create a new license for such software, you may create and use a -modified version of this License if you rename the license and remove -any references to the name of the license steward (except to note that -such modified license differs from this License). - -10.4. Distributing Source Code Form that is Incompatible With Secondary -Licenses - -If You choose to distribute Source Code Form that is Incompatible With -Secondary Licenses under the terms of this version of the License, the -notice described in Exhibit B of this License must be attached. - -Exhibit A - Source Code Form License Notice -------------------------------------------- - - This Source Code Form is subject to the terms of the Mozilla Public - License, v. 2.0. If a copy of the MPL was not distributed with this - file, You can obtain one at http://mozilla.org/MPL/2.0/. - -If it is not possible or desirable to put the notice in a particular -file, then You may include the notice in a location (such as a LICENSE -file in a relevant directory) where a recipient would be likely to look -for such a notice. - -You may add additional accurate notices of copyright ownership. - -Exhibit B - "Incompatible With Secondary Licenses" Notice ---------------------------------------------------------- - - This Source Code Form is "Incompatible With Secondary Licenses", as - defined by the Mozilla Public License, v. 2.0. - -=============================================================================== -Unicode License Agreement - Data Files and Software (2016) -=============================================================================== -UNICODE, INC. LICENSE AGREEMENT - DATA FILES AND SOFTWARE - -Unicode Data Files include all data files under the directories http://www.unicode.org/Public/, http://www.unicode.org/reports/, http://www.unicode.org/cldr/data/, http://source.icu-project.org/repos/icu/, and http://www.unicode.org/utility/trac/browser/. - -Unicode Data Files do not include PDF online code charts under the directory http://www.unicode.org/Public/. - -Software includes any source code published in the Unicode Standard or under the directories http://www.unicode.org/Public/, http://www.unicode.org/reports/, http://www.unicode.org/cldr/data/, http://source.icu-project.org/repos/icu/, and http://www.unicode.org/utility/trac/browser/. - -NOTICE TO USER: Carefully read the following legal agreement. BY DOWNLOADING, INSTALLING, COPYING OR OTHERWISE USING UNICODE INC.'S DATA FILES ("DATA FILES"), AND/OR SOFTWARE ("SOFTWARE"), YOU UNEQUIVOCALLY ACCEPT, AND AGREE TO BE BOUND BY, ALL OF THE TERMS AND CONDITIONS OF THIS AGREEMENT. IF YOU DO NOT AGREE, DO NOT DOWNLOAD, INSTALL, COPY, DISTRIBUTE OR USE THE DATA FILES OR SOFTWARE. - -COPYRIGHT AND PERMISSION NOTICE - -Copyright © 1991-2016 Unicode, Inc. All rights reserved. Distributed under the Terms of Use in http://www.unicode.org/copyright.html. - -Permission is hereby granted, free of charge, to any person obtaining a copy of the Unicode data files and any associated documentation (the "Data Files") or Unicode software and any associated documentation (the "Software") to deal in the Data Files or Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, and/or sell copies of the Data Files or Software, and to permit persons to whom the Data Files or Software are furnished to do so, provided that either - - (a) this copyright and permission notice appear with all copies of the Data Files or Software, or - (b) this copyright and permission notice appear in associated Documentation. - -THE DATA FILES AND SOFTWARE ARE PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF THIRD PARTY RIGHTS. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR HOLDERS INCLUDED IN THIS NOTICE BE LIABLE FOR ANY CLAIM, OR ANY SPECIAL INDIRECT OR CONSEQUENTIAL DAMAGES, OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THE DATA FILES OR SOFTWARE. - -Except as contained in this notice, the name of a copyright holder shall not be used in advertising or otherwise to promote the sale, use or other dealings in these Data Files or Software without prior written authorization of the copyright holder. - diff --git a/vscode-extension/THIRD-PARTY-LICENSES b/vscode-extension/THIRD-PARTY-LICENSES deleted file mode 100644 index 550e980c0..000000000 --- a/vscode-extension/THIRD-PARTY-LICENSES +++ /dev/null @@ -1,561 +0,0 @@ -Third-party licenses -==================== - -Implements [LSPFMT-PROVENANCE] (docs/specs/LSP-FORMATTING-SPEC.md). - -Ruff ----- - -The `basilisk` binary embeds crates from the Ruff project by Astral -(https://github.com/astral-sh/ruff), pinned in `Cargo.toml` to a single -immutable rev — 7c645a9a1be8258b9f9e005208a55a0b7e8e18f0, which is release -0.15.17 (see the workspace `[workspace.dependencies]` section). The crates -declared directly are: - - - ruff_python_parser (Python parser) - - ruff_python_ast (Python AST) - - ruff_text_size (text ranges) - - ruff_python_formatter (the embedded formatter engine — [LSPFMT-ENGINE]) - - ruff_formatter (formatter infrastructure) - - ruff_python_stdlib (stdlib module / builtins tables) - -These pull in further crates from the same Ruff workspace transitively -(ruff_python_trivia, ruff_source_file, ruff_annotate_snippets, ruff_cache, -ruff_db, ruff_diagnostics, ruff_macros, ruff_memory_usage, ruff_notebook) — -15 `ruff_*` crates in total, all from the one rev above and all under the same -MIT license. No Ruff source is copied into this repository; the crates are -consumed only as Cargo dependencies. The complete Rust dependency inventory -and license texts live in `RUST-DEPENDENCY-LICENSES`; curated project notices -remain in `NOTICES`. - -Ruff is distributed under the MIT license: - - MIT License - - Copyright (c) 2022 Charles Marsh - - Permission is hereby granted, free of charge, to any person obtaining a copy - of this software and associated documentation files (the "Software"), to deal - in the Software without restriction, including without limitation the rights - to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - copies of the Software, and to permit persons to whom the Software is - furnished to do so, subject to the following conditions: - - The above copyright notice and this permission notice shall be included in all - copies or substantial portions of the Software. - - THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - SOFTWARE. - -Ruff's own LICENSE file additionally lists the externally maintained -libraries Ruff derives from; see -https://github.com/astral-sh/ruff/blob/main/LICENSE for the complete text. - -Typeshed acquisition runtime ----------------------------- - -The Typeshed acquisition runtime uses Ureq 3.3.0 with Rustls 0.23.42 and zip -5.1.1. The locked dependency family and exact license choices are recorded in -`NOTICES`. The Apache License, Version 2.0 is reproduced below as part of -Typeshed's composite license. The remaining required notices and license texts -follow. - -Ureq — MIT license - -Copyright (c) 2019 Martin Algesten -Copyright 2022 Martin Algesten (ureq-proto) - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - -Rustls — MIT license - -Copyright (c) 2016 Joseph Birr-Pixton - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - -rustls-pki-types — MIT license - -Copyright (c) 2023 Dirkjan Ochtman - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - -Rustls — ISC license - -Copyright (c) 2016, Joseph Birr-Pixton - -Permission to use, copy, modify, and/or distribute this software for any -purpose with or without fee is hereby granted, provided that the above -copyright notice and this permission notice appear in all copies. - -THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH -REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY -AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, -INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM -LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR -OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR -PERFORMANCE OF THIS SOFTWARE. - -rustls-webpki — ISC license - -Copyright 2015 Brian Smith. - -Permission to use, copy, modify, and/or distribute this software for any -purpose with or without fee is hereby granted, provided that the above -copyright notice and this permission notice appear in all copies. - -THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHORS DISCLAIM ALL WARRANTIES WITH -REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY -AND FITNESS. IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY SPECIAL, DIRECT, -INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM -LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR -OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR -PERFORMANCE OF THIS SOFTWARE. - -untrusted — ISC license - -Copyright 2015-2016 Brian Smith. - -Permission to use, copy, modify, and/or distribute this software for any -purpose with or without fee is hereby granted, provided that the above -copyright notice and this permission notice appear in all copies. - -THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHORS DISCLAIM ALL WARRANTIES WITH -REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY -AND FITNESS. IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY SPECIAL, DIRECT, -INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM -LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR -OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR -PERFORMANCE OF THIS SOFTWARE. - -ring — ISC license for new code - -Copyright 2015-2025 Brian Smith. - -Permission to use, copy, modify, and/or distribute this software for any -purpose with or without fee is hereby granted, provided that the above -copyright notice and this permission notice appear in all copies. - -THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH -REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY -AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, -INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM -LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR -OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR -PERFORMANCE OF THIS SOFTWARE. - -webpki-roots — Community Data License Agreement - Permissive - Version 2.0 - -This is the Community Data License Agreement - Permissive, Version 2.0 (the -"agreement"). Data Provider(s) and Data Recipient(s) agree as follows: - -1. Provision of the Data - -1.1. A Data Recipient may use, modify, and share the Data made available by -Data Provider(s) under this agreement if that Data Recipient follows the terms -of this agreement. - -1.2. This agreement does not impose any restriction on a Data Recipient's use, -modification, or sharing of any portions of the Data that are in the public -domain or that may be used, modified, or shared under any other legal exception -or limitation. - -2. Conditions for Sharing Data - -2.1. A Data Recipient may share Data, with or without modifications, so long as -the Data Recipient makes available the text of this agreement with the shared -Data. - -3. No Restrictions on Results - -3.1. This agreement does not impose any restriction or obligations with -respect to the use, modification, or sharing of Results. - -4. No Warranty; Limitation of Liability - -4.1. All Data Recipients receive the Data subject to the following terms: - -THE DATA IS PROVIDED ON AN "AS IS" BASIS, WITHOUT REPRESENTATIONS, WARRANTIES -OR CONDITIONS OF ANY KIND, EITHER EXPRESS OR IMPLIED INCLUDING, WITHOUT -LIMITATION, ANY WARRANTIES OR CONDITIONS OF TITLE, NON-INFRINGEMENT, -MERCHANTABILITY OR FITNESS FOR A PARTICULAR PURPOSE. - -NO DATA PROVIDER SHALL HAVE ANY LIABILITY FOR ANY DIRECT, INDIRECT, INCIDENTAL, -SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING WITHOUT LIMITATION -LOST PROFITS), HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN -CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) -ARISING IN ANY WAY OUT OF THE DATA OR RESULTS, EVEN IF ADVISED OF THE -POSSIBILITY OF SUCH DAMAGES. - -5. Definitions - -5.1. "Data" means the material received by a Data Recipient under this -agreement. - -5.2. "Data Provider" means any person who is the source of Data provided under -this agreement and in reliance on a Data Recipient's agreement to its terms. - -5.3. "Data Recipient" means any person who receives Data directly or indirectly -from a Data Provider and agrees to the terms of this agreement. - -5.4. "Results" means any outcome obtained by computational analysis of Data, -including for example machine learning models and models' insights. - -subtle — BSD-3-Clause license - -Copyright (c) 2016-2017 Isis Agora Lovecruft, Henry de Valence. All rights reserved. -Copyright (c) 2016-2024 Isis Agora Lovecruft. All rights reserved. - -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are met: - -1. Redistributions of source code must retain the above copyright notice, -this list of conditions and the following disclaimer. - -2. Redistributions in binary form must reproduce the above copyright notice, -this list of conditions and the following disclaimer in the documentation -and/or other materials provided with the distribution. - -3. Neither the name of the copyright holder nor the names of its contributors -may be used to endorse or promote products derived from this software without -specific prior written permission. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" -AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE -IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR -ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES -(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; -LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON -ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS -SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - -zip — MIT license - -Copyright (c) 2014 Mathijs van de Nes - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - -Typeshed --------- - -Basilisk ships every `stdlib/` `.pyi`, `stdlib/VERSIONS`, and the root composite -`LICENSE` from Typeshed commit -83c2518a9e6abbda0c44592c3483de459198f887 (root tree -66408ffce2750980efc6da09e8a6652733f852e4), repackaged without modifying -upstream file bytes as a deterministic stored ZIP with SHA-256 -e5141e63b6b1932dc2b648591b41f842604155e63f6af8ebd0a2f096fdb3c189. -The reviewed source has no root `NOTICE` and no nested `stdlib/` license or -notice file. The stub-distribution index is generated and identity-bound to this -same commit. The standard-library module-root accelerator is generated directly -from the `.pyi` paths in this exact bundled ZIP. - -The exact composite license below is pinned to -https://github.com/python/typeshed/blob/83c2518a9e6abbda0c44592c3483de459198f887/LICENSE. - -The "typeshed" project is licensed under the terms of the Apache license, as -reproduced below. - -= = = = = - -Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "{}" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright {yyyy} {name of copyright owner} - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. - -= = = = = - -Parts of typeshed are licensed under different licenses (like the MIT -license), reproduced below. - -= = = = = - -The MIT License - -Copyright (c) 2015 Jukka Lehtosalo and contributors - -Permission is hereby granted, free of charge, to any person obtaining a -copy of this software and associated documentation files (the "Software"), -to deal in the Software without restriction, including without limitation -the rights to use, copy, modify, merge, publish, distribute, sublicense, -and/or sell copies of the Software, and to permit persons to whom the -Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -DEALINGS IN THE SOFTWARE. - -= = = = = diff --git a/vscode-extension/VSCODE-DEPENDENCY-LICENSES b/vscode-extension/VSCODE-DEPENDENCY-LICENSES deleted file mode 100644 index 51494df8b..000000000 --- a/vscode-extension/VSCODE-DEPENDENCY-LICENSES +++ /dev/null @@ -1,432 +0,0 @@ -Basilisk VS Code Production Dependency Licenses -================================================= - -Generated from the exact npm production graph selected by package-lock.json. -Production graph SHA-256: 393945c298e4e686471c62592a55a0ecedde9a2fe5782e696613f4f79d5f2bba -Regenerate with: npm run licenses:update - -=============================================================================== -@nimblesite/shipwright-core 0.10.0 -License: MIT -Repository: https://github.com/Nimblesite/Shipwright.git -Source: shared Shipwright repository LICENSE -SHA-256: 032c14bd0ff61c4ea546e23e8849b74a68770e9b91375d25a772b20587dc3159 - -MIT License - -Copyright (c) 2026 NIMBLESITE PTY LTD - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - -=============================================================================== -@nimblesite/shipwright-vscode 0.10.0 -License: MIT -Repository: https://github.com/Nimblesite/Shipwright.git -Source: LICENSE -SHA-256: 032c14bd0ff61c4ea546e23e8849b74a68770e9b91375d25a772b20587dc3159 - -MIT License - -Copyright (c) 2026 NIMBLESITE PTY LTD - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - -=============================================================================== -@preact/signals-core 1.14.4 -License: MIT -Repository: https://github.com/preactjs/signals -Source: LICENSE -SHA-256: a11fc89e4c6b118854c7a667734a0b2e6bf2af5e45c6686de31adbccc8f3ae8d - -The MIT License (MIT) - -Copyright (c) 2022-present Preact Team - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - -=============================================================================== -balanced-match 4.0.4 -License: MIT -Repository: git://github.com/juliangruber/balanced-match.git -Source: LICENSE.md -SHA-256: d408f38ffa3355c5faec517153295338892eb0f1ea43f57874bb23c6075979b5 - -(MIT) - -Original code Copyright Julian Gruber - -Port to TypeScript Copyright Isaac Z. Schlueter - -Permission is hereby granted, free of charge, to any person obtaining a copy of -this software and associated documentation files (the "Software"), to deal in -the Software without restriction, including without limitation the rights to -use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies -of the Software, and to permit persons to whom the Software is furnished to do -so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - -=============================================================================== -brace-expansion 5.0.9 -License: MIT -Repository: git+https://github.com/juliangruber/brace-expansion.git -Source: LICENSE -SHA-256: 9c63a23124d68cd30cd316a94a1a0bca34f032786df6df69fc4b5f136bac8d2e - -MIT License - -Copyright Julian Gruber - -TypeScript port Copyright Isaac Z. Schlueter - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - -=============================================================================== -minimatch 10.2.5 -License: BlueOak-1.0.0 -Repository: git@github.com:isaacs/minimatch -Source: LICENSE.md -SHA-256: 2c7c5d22ed5a8ee968c64757710979afcd77438c48b4a265b94e615babd8a901 - -# Blue Oak Model License - -Version 1.0.0 - -## Purpose - -This license gives everyone as much permission to work with -this software as possible, while protecting contributors -from liability. - -## Acceptance - -In order to receive this license, you must agree to its -rules. The rules of this license are both obligations -under that agreement and conditions to your license. -You must not do anything with this software that triggers -a rule that you cannot or will not follow. - -## Copyright - -Each contributor licenses you to do everything with this -software that would otherwise infringe that contributor's -copyright in it. - -## Notices - -You must ensure that everyone who gets a copy of -any part of this software from you, with or without -changes, also gets the text of this license or a link to -. - -## Excuse - -If anyone notifies you in writing that you have not -complied with [Notices](#notices), you can keep your -license by taking all practical steps to comply within 30 -days after the notice. If you do not do so, your license -ends immediately. - -## Patent - -Each contributor licenses you to do everything with this -software that would otherwise infringe any patent claims -they can license or become able to license. - -## Reliability - -No contributor can revoke this license. - -## No Liability - -**_As far as the law allows, this software comes as is, -without any warranty or condition, and no contributor -will be liable to anyone for any damages related to this -software or this license, under any kind of legal claim._** - -=============================================================================== -semver 7.8.2 -License: ISC -Repository: git+https://github.com/npm/node-semver.git -Source: LICENSE -SHA-256: 4ec3d4c66cd87f5c8d8ad911b10f99bf27cb00cdfcff82621956e379186b016b - -The ISC License - -Copyright (c) Isaac Z. Schlueter and Contributors - -Permission to use, copy, modify, and/or distribute this software for any -purpose with or without fee is hereby granted, provided that the above -copyright notice and this permission notice appear in all copies. - -THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES -WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF -MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR -ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES -WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN -ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR -IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. - -=============================================================================== -vscode-jsonrpc 9.0.1 -License: MIT -Repository: https://github.com/Microsoft/vscode-languageserver-node.git -Source: License.txt -SHA-256: ec9ee83580841e8eb687aca9867f221503809ba6426c7f876ede17d91b9fcfd0 - -Copyright (c) Microsoft Corporation - -All rights reserved. - -MIT License - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - -Source: thirdpartynotices.txt -SHA-256: a89123562fe364dc8e969e85614eb6c1f8452afe131d6e42bd1314bb2d092b04 - -NOTICES AND INFORMATION -Do Not Translate or Localize - -This software incorporates material from third parties. -Microsoft makes certain open source code available at https://3rdpartysource.microsoft.com, -or you may send a check or money order for US $5.00, including the product name, -the open source component name, platform, and version number, to: - -Source Code Compliance Team -Microsoft Corporation -One Microsoft Way -Redmond, WA 98052 -USA - -Notwithstanding any other terms, you may reverse engineer this software to the extent -required to debug changes to any libraries licensed under the GNU Lesser General Public License. - -=============================================================================== -vscode-languageclient 10.1.0 -License: MIT -Repository: https://github.com/Microsoft/vscode-languageserver-node.git -Source: License.txt -SHA-256: ec9ee83580841e8eb687aca9867f221503809ba6426c7f876ede17d91b9fcfd0 - -Copyright (c) Microsoft Corporation - -All rights reserved. - -MIT License - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - -=============================================================================== -vscode-languageserver-protocol 3.18.2 -License: MIT -Repository: https://github.com/Microsoft/vscode-languageserver-node.git -Source: License.txt -SHA-256: ec9ee83580841e8eb687aca9867f221503809ba6426c7f876ede17d91b9fcfd0 - -Copyright (c) Microsoft Corporation - -All rights reserved. - -MIT License - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - -Source: thirdpartynotices.txt -SHA-256: 9265d27cf75775aa5ae19c5ba01846fb70ecd5121f8c39c81bef7e03f007072c - -THIRD-PARTY SOFTWARE NOTICES AND INFORMATION -For Microsoft vscode-languageclient - -This project incorporates material from the project(s) listed below (collectively, “Third Party Code”). -Microsoft is not the original author of the Third Party Code. The original copyright notice and license -under which Microsoft received such Third Party Code are set out below. This Third Party Code is licensed -to you under their original license terms set forth below. Microsoft reserves all other rights not expressly -granted, whether by implication, estoppel or otherwise. - -1. DefinitelyTyped version 0.0.1 (https://github.com/borisyankov/DefinitelyTyped) - -This project is licensed under the MIT license. -Copyrights are respective of each contributor listed at the beginning of each definition file. - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. - -=============================================================================== -vscode-languageserver-textdocument 1.0.13 -License: MIT -Repository: https://github.com/Microsoft/vscode-languageserver-node.git -Source: License.txt -SHA-256: ec9ee83580841e8eb687aca9867f221503809ba6426c7f876ede17d91b9fcfd0 - -Copyright (c) Microsoft Corporation - -All rights reserved. - -MIT License - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - -Source: thirdpartynotices.txt -SHA-256: a89123562fe364dc8e969e85614eb6c1f8452afe131d6e42bd1314bb2d092b04 - -NOTICES AND INFORMATION -Do Not Translate or Localize - -This software incorporates material from third parties. -Microsoft makes certain open source code available at https://3rdpartysource.microsoft.com, -or you may send a check or money order for US $5.00, including the product name, -the open source component name, platform, and version number, to: - -Source Code Compliance Team -Microsoft Corporation -One Microsoft Way -Redmond, WA 98052 -USA - -Notwithstanding any other terms, you may reverse engineer this software to the extent -required to debug changes to any libraries licensed under the GNU Lesser General Public License. - -=============================================================================== -vscode-languageserver-types 3.18.0 -License: MIT -Repository: https://github.com/Microsoft/vscode-languageserver-node.git -Source: License.txt -SHA-256: ec9ee83580841e8eb687aca9867f221503809ba6426c7f876ede17d91b9fcfd0 - -Copyright (c) Microsoft Corporation - -All rights reserved. - -MIT License - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - -Source: thirdpartynotices.txt -SHA-256: a89123562fe364dc8e969e85614eb6c1f8452afe131d6e42bd1314bb2d092b04 - -NOTICES AND INFORMATION -Do Not Translate or Localize - -This software incorporates material from third parties. -Microsoft makes certain open source code available at https://3rdpartysource.microsoft.com, -or you may send a check or money order for US $5.00, including the product name, -the open source component name, platform, and version number, to: - -Source Code Compliance Team -Microsoft Corporation -One Microsoft Way -Redmond, WA 98052 -USA - -Notwithstanding any other terms, you may reverse engineer this software to the extent -required to debug changes to any libraries licensed under the GNU Lesser General Public License. - diff --git a/vscode-extension/package-lock.json b/vscode-extension/package-lock.json index e44307e99..49b68a01d 100644 --- a/vscode-extension/package-lock.json +++ b/vscode-extension/package-lock.json @@ -8,11 +8,6 @@ "name": "basilisk", "version": "0.0.0-PLACEHOLDER", "license": "SEE LICENSE IN LICENSE.txt", - "dependencies": { - "@nimblesite/shipwright-vscode": "^0.10.0", - "@preact/signals-core": "^1.14.4", - "vscode-languageclient": "^10.1.0" - }, "devDependencies": { "@types/mocha": "^10.0.10", "@types/node": "^26.1.2", @@ -25,7 +20,6 @@ "ajv": "^8.20.0", "ajv-formats": "^3.0.1", "eslint": "^10.8.0", - "glob": "^13.0.6", "mocha": "^11.7.6", "typescript": "^6.0.3", "typescript-eslint": "^8.65.0" @@ -438,29 +432,6 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@nimblesite/shipwright-core": { - "version": "0.10.0", - "resolved": "https://registry.npmjs.org/@nimblesite/shipwright-core/-/shipwright-core-0.10.0.tgz", - "integrity": "sha512-vVbQ2K5VmOwkZ9zsiG0jGsVACJJ79R44vk0iUMR+4a8/8e+oYB330lv3pAQDFSwUDnH/3SGA7LXLDH8xB5LHCQ==", - "license": "MIT" - }, - "node_modules/@nimblesite/shipwright-vscode": { - "version": "0.10.0", - "resolved": "https://registry.npmjs.org/@nimblesite/shipwright-vscode/-/shipwright-vscode-0.10.0.tgz", - "integrity": "sha512-rdTYt+jetNtxcEhAwJFYcyPlvnzhzCJxSNTrmMuXYLoL7ES38xbErJW36GfxWliUgvOVsL7XI3KtUZAewptdpA==", - "license": "MIT", - "dependencies": { - "@nimblesite/shipwright-core": "0.10.0" - }, - "peerDependencies": { - "vscode": "*" - }, - "peerDependenciesMeta": { - "vscode": { - "optional": true - } - } - }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -510,16 +481,6 @@ "node": ">=14" } }, - "node_modules/@preact/signals-core": { - "version": "1.14.4", - "resolved": "https://registry.npmjs.org/@preact/signals-core/-/signals-core-1.14.4.tgz", - "integrity": "sha512-HNB6HYeYKhQbJ1aKl+YRjrS4+QWHLKX6qKoUsfS/m0vqzsVaEBiZiaKbG/e+NKk2ch5ALQr/ihWaMHxiCuuWHA==", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/preact" - } - }, "node_modules/@secretlint/config-creator": { "version": "10.2.2", "resolved": "https://registry.npmjs.org/@secretlint/config-creator/-/config-creator-10.2.2.tgz", @@ -1838,6 +1799,7 @@ "version": "5.0.9", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" @@ -1850,6 +1812,7 @@ "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, "license": "MIT", "engines": { "node": "18 || 20 || >=22" @@ -4213,6 +4176,7 @@ "version": "10.2.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, "license": "BlueOak-1.0.0", "dependencies": { "brace-expansion": "^5.0.5" @@ -5393,6 +5357,7 @@ "version": "7.8.2", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.2.tgz", "integrity": "sha512-c8jsqUZm3omBOI66G90z1Dyw5z622G8oLG+omfsHBJf3CWQTlOcwOjvOG6wtiNfW6anKm/eA39LMwMtMez2TiQ==", + "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -6398,52 +6363,6 @@ "url": "https://bevry.me/fund" } }, - "node_modules/vscode-jsonrpc": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-9.0.1.tgz", - "integrity": "sha512-rfuA6T75H6m5EkbhtEPzre9pT0HPcDI2MMy4+nPFIBks5J8JBAUHD4tRYSgaBOijIEC7SRkC1kKyXTLqbmh9jw==", - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/vscode-languageclient": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/vscode-languageclient/-/vscode-languageclient-10.1.0.tgz", - "integrity": "sha512-XXRx6lqVitQy/oOLr9MfNYRG+MbQkhXkDaxbQMiKxEm8zZNfheRFUKNb8UYNh2stn9btl2wQM5wZFJjJvoc+jA==", - "license": "MIT", - "dependencies": { - "minimatch": "^10.2.5", - "semver": "^7.8.1", - "vscode-languageserver-protocol": "3.18.2", - "vscode-languageserver-textdocument": "1.0.13" - }, - "engines": { - "vscode": "^1.91.0" - } - }, - "node_modules/vscode-languageserver-protocol": { - "version": "3.18.2", - "resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.18.2.tgz", - "integrity": "sha512-XRyDbT0Pp3sSNti3JmxVEUMySWCSi1hhM+/KUlCy1hV1zmrqpM1OwO12EAki8blhmLuIMpaJrYbo0OzGVfK2Qg==", - "license": "MIT", - "dependencies": { - "vscode-jsonrpc": "9.0.1", - "vscode-languageserver-types": "3.18.0" - } - }, - "node_modules/vscode-languageserver-textdocument": { - "version": "1.0.13", - "resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.13.tgz", - "integrity": "sha512-nx0ZHwMGIsVkzFG3/VLeJYBLTaFBRuNdGDvevvjuoayU5EOS2fEYazOhtCM3PI9ClMMg5igc0uwXtAq4tJj+Dw==", - "license": "MIT" - }, - "node_modules/vscode-languageserver-types": { - "version": "3.18.0", - "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.18.0.tgz", - "integrity": "sha512-8TsGPNMIMiiBdkORgRSvLjuiEIiAFtO+KssmYWxQ+uSVvlf7RjK8YKCOjPzZ+YA04jXEV7+7LvkSmHkhpNS99g==", - "license": "MIT" - }, "node_modules/whatwg-encoding": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", diff --git a/vscode-extension/package.json b/vscode-extension/package.json index f530d6dd8..63fd8e604 100644 --- a/vscode-extension/package.json +++ b/vscode-extension/package.json @@ -1,7 +1,7 @@ { "name": "basilisk", "displayName": "Basilisk", - "description": "An open-source Python type checker and language server built in Rust: diagnostics, autocomplete, go-to-definition, refactoring, formatting, integrated debugging, and profiling in one extension. Strictness is configured per rule, so a codebase can adopt type safety incrementally.", + "description": "Basilisk's type checker produced incorrect results. Basilisk is unlisted and is being rebuilt from the ground up as a new product.", "version": "0.0.0-PLACEHOLDER", "publisher": "Nimblesite", "license": "SEE LICENSE IN LICENSE.txt", @@ -22,1010 +22,35 @@ "vscode": "^1.99.0" }, "categories": [ - "Programming Languages", - "Linters", - "Debuggers", "Other" ], "keywords": [ - "python", - "language server", - "pylance", - "pyright", - "type checker", - "intellisense", - "autocomplete", - "go to definition", - "rust", - "basilisk", - "lsp" + "basilisk" ], "activationEvents": [ - "onLanguage:python", - "onDebug", - "onDebugResolve:basilisk-debug", - "onDebugDynamicConfigurations:basilisk-debug" + "onStartupFinished" ], "main": "./out/extension.js", "contributes": { - "viewsContainers": { - "activitybar": [ - { - "id": "basilisk-explorer", - "title": "Basilisk", - "icon": "resources/activity-bar-icon.svg" - } - ] - }, - "views": { - "basilisk-explorer": [ - { - "id": "basilisk.moduleExplorer", - "name": "Modules", - "when": "basilisk.hasWorkspace" - }, - { - "id": "basilisk.pythonProcesses", - "name": "Python Processes", - "when": "basilisk.hasWorkspace" - }, - { - "id": "basilisk.info", - "name": "Basilisk", - "visibility": "visible" - } - ] - }, - "viewsWelcome": [ - { - "view": "basilisk.moduleExplorer", - "contents": "No modules found.\n[Restart Server](command:basilisk.restartServer)" - }, - { - "view": "basilisk.pythonProcesses", - "contents": "Connecting to the Basilisk language server…", - "when": "basilisk.serverState != running && basilisk.serverState != stopped" - }, - { - "view": "basilisk.pythonProcesses", - "contents": "The Basilisk language server is not running.\n[Restart Server](command:basilisk.restartServer)", - "when": "basilisk.serverState == stopped" - }, - { - "view": "basilisk.pythonProcesses", - "contents": "Loading Python processes…\n[Run & Profile CPU (Current File)](command:basilisk.profileCurrentFileCpu)\n[Run & Track Memory (Current File)](command:basilisk.trackMemoryCurrentFile)", - "when": "basilisk.serverState == running && basilisk.processesState == loading" - }, - { - "view": "basilisk.pythonProcesses", - "contents": "Couldn't load the Python process list.\n[Run & Profile CPU (Current File)](command:basilisk.profileCurrentFileCpu)\n[Run & Track Memory (Current File)](command:basilisk.trackMemoryCurrentFile)", - "when": "basilisk.serverState == running && basilisk.processesState == error" - }, - { - "view": "basilisk.pythonProcesses", - "contents": "No Python processes running.\n[Run & Profile CPU (Current File)](command:basilisk.profileCurrentFileCpu)\n[Run & Track Memory (Current File)](command:basilisk.trackMemoryCurrentFile)", - "when": "basilisk.serverState == running && basilisk.processesState == loaded" - } - ], "commands": [ { - "command": "basilisk.restartServer", - "title": "Basilisk: Restart Language Server", - "category": "Basilisk", - "icon": "$(debug-restart)" - }, - { - "command": "basilisk.showOutput", - "title": "Basilisk: Show Output", - "category": "Basilisk" - }, - { - "command": "basilisk.statusMenu", - "title": "Basilisk: Status Menu", - "category": "Basilisk" - }, - { - "command": "basilisk.openConfigurationEditor", - "title": "Basilisk: Open Configuration Editor", - "category": "Basilisk", - "icon": "$(settings-gear)", - "enablement": "basilisk.configurationEditorSupported" - }, - { - "command": "basilisk.editConfig", - "title": "Edit Config", - "category": "Basilisk", - "icon": "$(settings-gear)", - "enablement": "basilisk.configurationEditorSupported" - }, - { - "command": "basilisk.organizeImports", - "title": "Basilisk: Organize Imports", - "category": "Basilisk", - "icon": "$(list-ordered)" - }, - { - "command": "basilisk.fixFile", - "title": "Basilisk: Fix All (Safe) in File", - "category": "Basilisk" - }, - { - "command": "basilisk.fixFileAll", - "title": "Basilisk: Fix All in File", - "category": "Basilisk" - }, - { - "command": "basilisk.fixWorkspace", - "title": "Basilisk: Fix All (Safe) in Workspace", - "category": "Basilisk", - "icon": "$(wand)" - }, - { - "command": "basilisk.fixWorkspaceAll", - "title": "Basilisk: Fix All in Workspace", - "category": "Basilisk", - "icon": "$(wand)" - }, - { - "command": "basilisk.adoptFile", - "title": "Basilisk: Adopt File", - "category": "Basilisk" - }, - { - "command": "basilisk.adoptWorkspace", - "title": "Basilisk: Adopt Workspace", - "category": "Basilisk" - }, - { - "command": "basilisk.unadoptFile", - "title": "Basilisk: Un-adopt File", - "category": "Basilisk" - }, - { - "command": "basilisk.uv.sync", - "title": "Basilisk: uv Sync Environment", - "category": "Basilisk" - }, - { - "command": "basilisk.uv.add", - "title": "Basilisk: uv Add Dependency", - "category": "Basilisk" - }, - { - "command": "basilisk.uv.addDev", - "title": "Basilisk: uv Add Dev Dependency", - "category": "Basilisk" - }, - { - "command": "basilisk.uv.remove", - "title": "Basilisk: uv Remove Dependency", - "category": "Basilisk" - }, - { - "command": "basilisk.uv.lock", - "title": "Basilisk: uv Lock", - "category": "Basilisk" - }, - { - "command": "basilisk.uv.createEnv", - "title": "Basilisk: uv Create Virtual Environment", - "category": "Basilisk" - }, - { - "command": "basilisk.refreshModuleExplorer", - "title": "Refresh Module Explorer", - "category": "Basilisk", - "icon": "$(refresh)" - }, - { - "command": "basilisk.sortModuleExplorer", - "title": "Sort Modules…", - "category": "Basilisk", - "icon": "$(sort-precedence)" - }, - { - "command": "basilisk.copyImportPath", - "title": "Copy Import Path", - "category": "Basilisk" - }, - { - "command": "basilisk.copyQualifiedName", - "title": "Copy Qualified Name", - "category": "Basilisk" - }, - { - "command": "basilisk.toggleModuleExplorerView", - "title": "Toggle Tree/Flat View", - "category": "Basilisk", - "icon": "$(list-tree)" - }, - { - "command": "basilisk.filterModuleExplorer", - "title": "Filter Modules", - "category": "Basilisk", - "icon": "$(filter)" - }, - { - "command": "basilisk.profileStart", - "title": "Basilisk: Start Profiling", - "category": "Basilisk", - "icon": "$(flame)" - }, - { - "command": "basilisk.profileStop", - "title": "Basilisk: Stop Profiling", - "category": "Basilisk", - "icon": "$(debug-stop)" - }, - { - "command": "basilisk.profileSnapshot", - "title": "Basilisk: Take Profile Snapshot", - "category": "Basilisk", - "icon": "$(device-camera)" - }, - { - "command": "basilisk.profileAttachToDebug", - "title": "Basilisk: Profile Debug Session", - "category": "Basilisk", - "icon": "$(debug-alt)" - }, - { - "command": "basilisk.profileShowResults", - "title": "Basilisk: Show Profile Results", - "category": "Basilisk", - "icon": "$(flame)" - }, - { - "command": "basilisk.memoryMenu", - "title": "Basilisk: Memory…", - "category": "Basilisk", - "icon": "$(database)" - }, - { - "command": "basilisk.memoryStart", - "title": "Basilisk: Start Memory Tracking", - "category": "Basilisk", - "icon": "$(database)" - }, - { - "command": "basilisk.memorySnapshot", - "title": "Basilisk: Take Memory Snapshot", - "category": "Basilisk", - "icon": "$(device-camera)" - }, - { - "command": "basilisk.memoryStop", - "title": "Basilisk: Stop Memory Tracking", - "category": "Basilisk", - "icon": "$(debug-stop)" - }, - { - "command": "basilisk.memoryDiff", - "title": "Basilisk: Compare Memory Snapshots", - "category": "Basilisk", - "icon": "$(diff)" - }, - { - "command": "basilisk.memoryGcCollect", - "title": "Basilisk: Force Garbage Collection", - "category": "Basilisk", - "icon": "$(trash)" - }, - { - "command": "basilisk.memoryReferences", - "title": "Basilisk: Show Reference Graph", - "category": "Basilisk", - "icon": "$(type-hierarchy)" - }, - { - "command": "basilisk.openWalkthrough", - "title": "Basilisk: Getting Started", - "category": "Basilisk" - }, - { - "command": "basilisk.info.runAction", - "title": "Toggle Diagnostics", - "category": "Basilisk", - "icon": "$(arrow-swap)" - }, - { - "command": "basilisk.refreshProcesses", - "title": "Refresh Python Processes", - "category": "Basilisk", - "icon": "$(refresh)" - }, - { - "command": "basilisk.sortProcesses", - "title": "Sort Python Processes", - "category": "Basilisk", - "icon": "$(sort-precedence)" - }, - { - "command": "basilisk.groupProcesses", - "title": "Group Python Processes", - "category": "Basilisk", - "icon": "$(group-by-ref-type)" - }, - { - "command": "basilisk.filterProcesses", - "title": "Filter Python Processes", - "category": "Basilisk", - "icon": "$(filter)" - }, - { - "command": "basilisk.profileCurrentFileCpu", - "title": "Run & Profile CPU (Current File)", - "category": "Basilisk", - "icon": "$(flame)" - }, - { - "command": "basilisk.trackMemoryCurrentFile", - "title": "Run & Track Memory (Current File)", - "category": "Basilisk", - "icon": "$(database)" - }, - { - "command": "basilisk.profileProcess", - "title": "Profile CPU", - "category": "Basilisk", - "icon": "$(flame)" - }, - { - "command": "basilisk.memoryTrackProcess", - "title": "Track Memory", - "category": "Basilisk", - "icon": "$(database)" - }, - { - "command": "basilisk.copyProcessPid", - "title": "Copy PID", - "category": "Basilisk" - }, - { - "command": "basilisk.revealProcessScript", - "title": "Reveal Script in Editor", + "command": "basilisk.showStatement", + "title": "Basilisk: Why is Basilisk unlisted?", "category": "Basilisk" } - ], - "menus": { - "commandPalette": [ - { - "command": "basilisk.openConfigurationEditor", - "when": "basilisk.configurationEditorSupported" - }, - { - "command": "basilisk.editConfig", - "when": "false" - }, - { - "command": "basilisk.info.runAction", - "when": "false" - }, - { - "command": "basilisk.profileProcess", - "when": "false" - }, - { - "command": "basilisk.memoryTrackProcess", - "when": "false" - }, - { - "command": "basilisk.copyProcessPid", - "when": "false" - }, - { - "command": "basilisk.revealProcessScript", - "when": "false" - }, - { - "command": "basilisk.memoryMenu", - "when": "basilisk.debugging" - }, - { - "command": "basilisk.memoryStart", - "when": "basilisk.debugging" - }, - { - "command": "basilisk.memorySnapshot", - "when": "basilisk.debugging" - }, - { - "command": "basilisk.memoryDiff", - "when": "basilisk.debugging" - }, - { - "command": "basilisk.memoryGcCollect", - "when": "basilisk.debugging" - }, - { - "command": "basilisk.memoryReferences", - "when": "basilisk.debugging" - }, - { - "command": "basilisk.memoryStop", - "when": "basilisk.debugging" - } - ], - "explorer/context": [ - { - "command": "basilisk.editConfig", - "when": "resourceFilename == pyproject.toml && basilisk.configurationEditorSupported", - "group": "navigation@1" - } - ], - "debug/toolBar": [ - { - "command": "basilisk.memorySnapshot", - "when": "debugType == basilisk-debug && basilisk.memoryTracking", - "group": "navigation@1" - }, - { - "command": "basilisk.memoryDiff", - "when": "debugType == basilisk-debug && basilisk.memoryTracking", - "group": "navigation@2" - }, - { - "command": "basilisk.memoryStop", - "when": "debugType == basilisk-debug && basilisk.memoryTracking", - "group": "navigation@3" - } - ], - "view/title": [ - { - "command": "basilisk.openConfigurationEditor", - "when": "view == basilisk.info && basilisk.configurationEditorSupported", - "group": "navigation@1" - }, - { - "command": "basilisk.openConfigurationEditor", - "when": "view == basilisk.moduleExplorer && basilisk.configurationEditorSupported", - "group": "z_config@1" - }, - { - "command": "basilisk.openConfigurationEditor", - "when": "view == basilisk.pythonProcesses && basilisk.configurationEditorSupported", - "group": "z_config@1" - }, - { - "command": "basilisk.refreshModuleExplorer", - "when": "view == basilisk.moduleExplorer", - "group": "navigation@1" - }, - { - "command": "basilisk.toggleModuleExplorerView", - "when": "view == basilisk.moduleExplorer", - "group": "navigation@2" - }, - { - "command": "basilisk.filterModuleExplorer", - "when": "view == basilisk.moduleExplorer", - "group": "navigation@3" - }, - { - "command": "basilisk.sortModuleExplorer", - "when": "view == basilisk.moduleExplorer && basilisk.moduleExplorerView == 'flat'", - "group": "navigation@4" - }, - { - "command": "basilisk.organizeImports", - "when": "view == basilisk.moduleExplorer && basilisk.serverState == 'running'", - "group": "1_modify@1" - }, - { - "command": "basilisk.fixWorkspace", - "when": "view == basilisk.moduleExplorer && basilisk.serverState == 'running' && config.basilisk.experimental.fixAll", - "group": "1_modify@2" - }, - { - "command": "basilisk.restartServer", - "when": "view == basilisk.moduleExplorer && basilisk.serverState == 'running'", - "group": "9_server@1" - }, - { - "command": "basilisk.profileCurrentFileCpu", - "when": "view == basilisk.pythonProcesses && !basilisk.cpuBusy", - "group": "navigation@1" - }, - { - "command": "basilisk.trackMemoryCurrentFile", - "when": "view == basilisk.pythonProcesses && !basilisk.memoryBusy", - "group": "navigation@2" - }, - { - "command": "basilisk.profileStop", - "when": "view == basilisk.pythonProcesses && basilisk.profiling", - "group": "navigation@1" - }, - { - "command": "basilisk.memorySnapshot", - "when": "view == basilisk.pythonProcesses && basilisk.memoryTracking", - "group": "navigation@1" - }, - { - "command": "basilisk.memoryDiff", - "when": "view == basilisk.pythonProcesses && basilisk.memoryTracking", - "group": "navigation@2" - }, - { - "command": "basilisk.memoryStop", - "when": "view == basilisk.pythonProcesses && basilisk.memoryTracking", - "group": "navigation@3" - }, - { - "command": "basilisk.refreshProcesses", - "when": "view == basilisk.pythonProcesses", - "group": "navigation@3" - } - ], - "view/item/context": [ - { - "command": "basilisk.copyImportPath", - "when": "view == basilisk.moduleExplorer && viewItem =~ /^(module|symbol)/", - "group": "6_copypath@1" - }, - { - "command": "basilisk.copyQualifiedName", - "when": "view == basilisk.moduleExplorer && viewItem =~ /^(module|symbol)/", - "group": "6_copypath@2" - }, - { - "command": "basilisk.info.runAction", - "when": "view == basilisk.info && viewItem == feature", - "group": "inline" - }, - { - "command": "basilisk.profileStop", - "when": "view == basilisk.pythonProcesses && viewItem == pythonProcessProfiling", - "group": "inline@0" - }, - { - "command": "basilisk.profileProcess", - "when": "view == basilisk.pythonProcesses && viewItem =~ /^pythonProcess/ && !basilisk.cpuBusy", - "group": "inline@1" - }, - { - "command": "basilisk.memoryTrackProcess", - "when": "view == basilisk.pythonProcesses && viewItem == pythonProcessDebuggee && !basilisk.memoryBusy", - "group": "inline@2" - }, - { - "command": "basilisk.profileStop", - "when": "view == basilisk.pythonProcesses && viewItem == pythonProcessProfiling", - "group": "1_profile@0" - }, - { - "command": "basilisk.profileProcess", - "when": "view == basilisk.pythonProcesses && viewItem =~ /^pythonProcess/ && !basilisk.cpuBusy", - "group": "1_profile@1" - }, - { - "command": "basilisk.memoryTrackProcess", - "when": "view == basilisk.pythonProcesses && viewItem == pythonProcessDebuggee && !basilisk.memoryBusy", - "group": "1_profile@2" - }, - { - "command": "basilisk.copyProcessPid", - "when": "view == basilisk.pythonProcesses && viewItem =~ /^(pythonProcess|blockedPythonProcess)/", - "group": "9_copy@1" - }, - { - "command": "basilisk.revealProcessScript", - "when": "view == basilisk.pythonProcesses && viewItem =~ /^(pythonProcess|blockedPythonProcess)/", - "group": "9_copy@2" - } - ] - }, - "keybindings": [ - { - "command": "basilisk.fixFile", - "key": "ctrl+shift+.", - "mac": "cmd+shift+.", - "when": "editorLangId == python" - }, - { - "command": "basilisk.profileStart", - "key": "ctrl+shift+p ctrl+shift+s", - "mac": "cmd+shift+p cmd+shift+s", - "when": "editorLangId == python" - }, - { - "command": "basilisk.profileStop", - "key": "ctrl+shift+p ctrl+shift+x", - "mac": "cmd+shift+p cmd+shift+x", - "when": "basilisk.profiling" - } - ], - "breakpoints": [ - { - "language": "python" - } - ], - "debuggers": [ - { - "type": "basilisk-debug", - "label": "Python (Basilisk)", - "languages": [ - "python" - ], - "configurationAttributes": { - "launch": { - "required": [ - "program" - ], - "properties": { - "program": { - "type": "string", - "description": "Absolute path to the Python file to debug.", - "default": "${file}" - }, - "args": { - "type": "array", - "description": "Command-line arguments passed to the program.", - "items": { - "type": "string" - }, - "default": [] - }, - "cwd": { - "type": "string", - "description": "Working directory for the program.", - "default": "${workspaceFolder}" - }, - "console": { - "type": "string", - "enum": [ - "internalConsole", - "integratedTerminal", - "externalTerminal" - ], - "description": "Where to show program output. 'internalConsole' sends stdout/stderr to the Debug Console.", - "default": "internalConsole" - }, - "redirectOutput": { - "type": "boolean", - "description": "Redirect stdout/stderr to the Debug Console.", - "default": true - }, - "justMyCode": { - "type": "boolean", - "default": true - }, - "stopOnEntry": { - "type": "boolean", - "default": false - }, - "python": { - "type": "string", - "description": "Path to the Python interpreter. Auto-detected if omitted." - } - } - }, - "attach": { - "properties": { - "connect": { - "type": "object", - "description": "Connect to a running debugpy server.", - "properties": { - "host": { - "type": "string", - "default": "localhost" - }, - "port": { - "type": "number" - } - }, - "required": [ - "port" - ] - } - } - } - }, - "configurationSnippets": [ - { - "label": "Basilisk: Launch Current File", - "description": "Debug the currently open Python file", - "body": { - "name": "Python: Current File (Basilisk)", - "type": "basilisk-debug", - "request": "launch", - "program": "^\"\\${file}\"", - "console": "internalConsole", - "redirectOutput": true, - "justMyCode": true - } - } - ], - "initialConfigurations": [ - { - "name": "Python: Current File (Basilisk)", - "type": "basilisk-debug", - "request": "launch", - "program": "${file}", - "console": "integratedTerminal", - "justMyCode": true - } - ] - } - ], - "configuration": { - "title": "Basilisk", - "properties": { - "basilisk.python": { - "type": "string", - "default": "", - "description": "Path to the Python interpreter (e.g. .venv/bin/python). If empty, auto-detected from workspace venv or BASILISK_PYTHON env var. Target: Python 3.12." - }, - "basilisk.executablePath": { - "type": "string", - "default": "", - "description": "Explicit path to the basilisk binary. Empty uses the bundled VSIX binary." - }, - "basilisk.binaries.path": { - "type": "string", - "default": "", - "description": "Directory containing Basilisk runtime binaries. Empty uses the bundled VSIX binaries." - }, - "basilisk.binaries.basilisk": { - "type": "string", - "default": "", - "description": "Explicit path to the basilisk language server binary. Empty uses the bundled VSIX binary." - }, - "basilisk.binaries.basilisk-profiler-helper": { - "type": "string", - "default": "", - "description": "Explicit path to the optional macOS profiler helper binary. Empty uses the bundled helper when present." - }, - "basilisk.enabled": { - "type": "boolean", - "default": true, - "description": "Enable or disable diagnostic publication from both Basilisk check and analyze rule sets." - }, - "basilisk.analyze": { - "type": "boolean", - "default": true, - "description": "Publish analyze-scope diagnostics (configured non-PEP rules) in addition to the always-on PEP check scope. Disable to restrict the editor to check scope; project configuration is unaffected. See LSP-ARCHITECTURE-SPEC.md#LSPARCH-DIAGNOSTIC-SCOPE." - }, - "basilisk.useLsp": { - "type": "boolean", - "default": true, - "description": "Use the LSP server for real-time diagnostics. Disable to fall back to subprocess mode." - }, - "basilisk.trace.server": { - "type": "string", - "enum": [ - "off", - "messages", - "verbose" - ], - "default": "off", - "description": "Trace LSP communication for debugging." - }, - "basilisk.inlayHints.parameterNames": { - "type": "boolean", - "default": true, - "description": "Reserved: parameter-name inlay hints are always shown. The language server does not yet read this setting (see EXTENSION-ACTIVITY-PANEL-PLAN.md)." - }, - "basilisk.inlayHints.variableTypes": { - "type": "boolean", - "default": true, - "description": "Reserved: variable-type inlay hints are always shown. The language server does not yet read this setting (see EXTENSION-ACTIVITY-PANEL-PLAN.md)." - }, - "basilisk.formatter": { - "type": "string", - "enum": ["ruff", "none"], - "default": "ruff", - "description": "Formatter engine. 'ruff' uses the Ruff formatter embedded in the Basilisk binary (in-process — no external ruff binary is ever required or spawned); 'none' disables formatting. See LSP-FORMATTING-SPEC.md#LSPFMT-CONFIG." - }, - "basilisk.uv.enabled": { - "type": "boolean", - "default": true, - "description": "Enable uv package manager integration" - }, - "basilisk.uv.executablePath": { - "type": "string", - "default": "", - "description": "Path to the uv executable. Auto-detected if empty." - }, - "basilisk.uv.autoSync": { - "type": "boolean", - "default": false, - "description": "Automatically run 'uv sync' when pyproject.toml changes" - }, - "basilisk.testExplorer.enabled": { - "type": "boolean", - "default": true, - "description": "Enable Python test discovery and execution in Test Explorer." - }, - "basilisk.testExplorer.framework": { - "type": "string", - "enum": [ - "pytest", - "unittest", - "auto" - ], - "default": "auto", - "description": "Test framework to use. 'auto' detects from project config." - }, - "basilisk.testExplorer.pytestPath": { - "type": "string", - "default": "pytest", - "description": "Path to the pytest executable." - }, - "basilisk.testExplorer.args": { - "type": "array", - "items": { - "type": "string" - }, - "default": [], - "description": "Additional arguments passed to the test runner." - }, - "basilisk.testExplorer.autoDiscoverOnSave": { - "type": "boolean", - "default": true, - "description": "Re-discover tests when test files are saved." - }, - "basilisk.testExplorer.useUvRun": { - "type": "boolean", - "default": true, - "description": "Use 'uv run' to execute tests when a uv project is detected. Ensures tests run in the correct environment." - }, - "basilisk.testExplorer.coverageEnabled": { - "type": "boolean", - "default": false, - "description": "Show coverage gutter decorations after running tests with coverage. Runs pytest with --cov flag." - }, - "basilisk.profiler.sampleRate": { - "type": "number", - "default": 100, - "description": "Sampling rate in Hz for CPU profiling (1-1000)." - }, - "basilisk.profiler.includeNative": { - "type": "boolean", - "default": false, - "description": "Include native C extension frames in profile results." - }, - "basilisk.profiler.lineThreshold": { - "type": "number", - "default": 1, - "description": "Minimum percentage of total samples for a line to appear as a hotspot (0.1-100)." - }, - "basilisk.profiler.functionThreshold": { - "type": "number", - "default": 2, - "description": "Minimum percentage of total samples for a function to appear as a hotspot (0.1-100)." - }, - "basilisk.profiler.maxDiagnosticsPerFile": { - "type": "number", - "default": 20, - "description": "Maximum number of profiling diagnostics per file." - }, - "basilisk.profiler.showInlineHeatMap": { - "type": "boolean", - "default": true, - "description": "Show inline heat map decorations for hot lines after profiling." - }, - "basilisk.profiler.profileOnLaunch": { - "type": "boolean", - "default": false, - "description": "Automatically start CPU profiling when a debug session launches." - }, - "basilisk.profiler.processRefreshMs": { - "type": "number", - "default": 2000, - "minimum": 250, - "description": "Poll interval (ms) for refreshing the Python Processes panel while it is visible." - }, - "basilisk.profiler.preset": { - "type": "string", - "enum": [ - "default", - "quick", - "detailed", - "longRunning" - ], - "enumDescriptions": [ - "Use the basilisk.profiler.sampleRate / includeNative settings", - "Short burst: 10 seconds at 100 Hz, for quick hotspot checks", - "Thorough: 60 seconds at 200 Hz, higher-fidelity data", - "No time limit at 50 Hz, for long-running servers and batch jobs" - ], - "default": "default", - "description": "Profiling preset that configures sample rate and duration." - }, - "basilisk.profiler.autoSnapshotOnPause": { - "type": "boolean", - "default": true, - "description": "Memory autopilot: while memory tracking is active, automatically take and compare a snapshot every time the debugger pauses (at a breakpoint or step). Lets you hunt leaks by just pressing Continue — leak confidence escalates automatically. Turn off to capture snapshots manually." - }, - "basilisk.profiler.autoSnapshot": { - "type": "boolean", - "default": false, - "description": "Memory autopilot: while memory tracking is active, automatically take a memory snapshot every few seconds (see basilisk.profiler.autoSnapshotInterval), even when the program never pauses. The program is briefly paused for each capture and resumed." - }, - "basilisk.profiler.autoSnapshotInterval": { - "type": "number", - "default": 30, - "minimum": 1, - "description": "Seconds between automatic memory snapshots when basilisk.profiler.autoSnapshot is enabled." - }, - "basilisk.analysisMode": { - "type": "string", - "enum": [ - "openFilesOnly", - "wholeModule", - "crossModule" - ], - "enumDescriptions": [ - "Analyse only files currently open in the editor. Use for large projects where whole-workspace analysis is too slow.", - "Analyse all Python files in the workspace at startup and on file changes. Diagnostics appear in the Problems panel even for closed files. (default)", - "Cross-file import graph analysis. Reserved for future use; currently behaves like wholeModule." - ], - "default": "wholeModule", - "description": "Controls which files Basilisk analyses. 'wholeModule' (default) analyses all workspace files; 'openFilesOnly' analyses only open editors; 'crossModule' is reserved for future cross-file import graph analysis." - }, - "basilisk.experimental.fixAll": { - "type": "boolean", - "default": false, - "description": "Experimental: show the 'Fix All in Workspace' action in the Modules toolbar. Off by default until the mass-autofix flow is hardened." - } - } - }, - "walkthroughs": [ - { - "id": "basilisk.gettingStarted", - "title": "Getting Started with Basilisk", - "description": "Set up Basilisk as your Python type checker and language server.", - "steps": [ - { - "id": "basilisk.installBinary", - "title": "Basilisk Binary", - "description": "The Basilisk binary is bundled with this extension — no separate install required. To override, set `basilisk.executablePath` or download from GitHub releases.\n[Open Releases](https://github.com/Nimblesite/Basilisk/releases)", - "media": { - "markdown": "The Basilisk binary ships with this extension. No `cargo install` needed." - } - }, - { - "id": "basilisk.openPythonProject", - "title": "Open a Python Project", - "description": "Open a folder containing Python files to start analysis.\n[Open Folder](command:vscode.openFolder)", - "media": { - "markdown": "Open any folder with `.py` files." - } - }, - { - "id": "basilisk.exploreModules", - "title": "Explore Modules", - "description": "Click the Basilisk icon in the activity bar to see your modules with per-module type coverage, errors, and warnings.\n[Open Modules](command:basilisk.moduleExplorer.focus)", - "media": { - "markdown": "The Modules panel shows your workspace's Python modules with a coverage bar on each, so you can see which need more type annotations at a glance." - } - } - ] - } ] }, "scripts": { "licenses:update": "node scripts/update-dependency-licenses.mjs", "licenses:check": "node scripts/update-dependency-licenses.mjs --check", - "sync:shipwright": "node scripts/sync-shipwright-manifest.mjs", - "stage:attribution": "node scripts/verify-shipwright.mjs stage-attribution", - "compile": "tsc -p ./ && node -e \"const fs=require('fs');fs.mkdirSync('out/src/test/fixtures',{recursive:true});fs.readdirSync('src/test/fixtures').forEach(f=>fs.copyFileSync('src/test/fixtures/'+f,'out/src/test/fixtures/'+f));\"", + "compile": "tsc -p ./", "watch": "tsc -watch -p ./", "lint": "eslint src/", "lint:fix": "eslint src/ --fix", - "pretest": "node scripts/fetch-real-world-repos.mjs", "test": "vscode-test --bail", - "test:real-world": "npm run compile && node scripts/fetch-real-world-repos.mjs && vscode-test --bail --label real-world-flask --label real-world-rich --label real-world-fastapi", "test:shipwright": "node scripts/verify-shipwright.mjs manifest && npm run licenses:check", - "vscode:prepublish": "npm run stage:attribution && npm run sync:shipwright && npm run compile", - "package": "cd .. && make _release_vsix", - "screenshots:editor": "node scripts/capture-screenshots.mjs" - }, - "dependencies": { - "@nimblesite/shipwright-vscode": "^0.10.0", - "@preact/signals-core": "^1.14.4", - "vscode-languageclient": "^10.1.0" + "vscode:prepublish": "npm run compile", + "package": "cd .. && make _release_vsix" }, "devDependencies": { "@types/mocha": "^10.0.10", @@ -1039,7 +64,6 @@ "ajv": "^8.20.0", "ajv-formats": "^3.0.1", "eslint": "^10.8.0", - "glob": "^13.0.6", "mocha": "^11.7.6", "typescript": "^6.0.3", "typescript-eslint": "^8.65.0" diff --git a/vscode-extension/scripts/capture-screenshots.mjs b/vscode-extension/scripts/capture-screenshots.mjs deleted file mode 100644 index 5e96be9b7..000000000 --- a/vscode-extension/scripts/capture-screenshots.mjs +++ /dev/null @@ -1,59 +0,0 @@ -// Implements [VSIX-EDITOR-SCREENSHOTS-PIPELINE]: one command to regenerate the -// website's VS Code editor screenshots. Stages the built binary into the dev ext, -// copies the shipwright manifest, launches the CDP screenshot sidecar, and runs -// the (otherwise-skipped) "Editor screenshots" suite headed with -// BASILISK_SCREENSHOTS=1 so the sidecar captures each feature. -// -// Prerequisite: the binaries must be built — -// cargo build -p basilisk-cli -p basilisk-profiler-helper -// -// Usage (from vscode-extension/): npm run screenshots:editor -// See docs/specs/VSIX-EDITOR-SCREENSHOTS-SPEC.md. - -import { spawn, spawnSync } from "node:child_process"; -import { copyFileSync, existsSync } from "node:fs"; -import { dirname, join, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; - -const extensionRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); -const repoRoot = resolve(extensionRoot, ".."); -const CDP_PORT = process.env.BASILISK_SCREENSHOT_CDP_PORT ?? "9229"; - -const run = (cmd, args, opts = {}) => { - const result = spawnSync(cmd, args, { stdio: "inherit", cwd: repoRoot, ...opts }); - if (result.status !== 0) { - throw new Error(`${cmd} ${args.join(" ")} exited with ${result.status ?? result.signal}`); - } -}; - -// 1. Stage the runtime binaries into the dev extension's bin// (the -// same path the packaged VSIX and the extension's shipwright resolver use). -if (!existsSync(join(repoRoot, "target", "debug", "basilisk"))) { - throw new Error("missing target/debug/basilisk — run: cargo build -p basilisk-cli -p basilisk-profiler-helper"); -} -run("node", [join(extensionRoot, "scripts", "stage-runtime.mjs"), "target/debug"]); - -// 2. The extension reads shipwright.json from its own root; at dev time it only -// lives at the repo root (packaging copies it in). Mirror it (gitignored). -copyFileSync(join(repoRoot, "shipwright.json"), join(extensionRoot, "shipwright.json")); - -// 3. Compile the extension + tests. -run("npm", ["run", "compile"], { cwd: extensionRoot }); - -// 4. Launch the CDP screenshot sidecar. -const env = { ...process.env, BASILISK_SCREENSHOTS: "1", BASILISK_SCREENSHOT_CDP_PORT: CDP_PORT }; -const watcher = spawn("node", [join(extensionRoot, "scripts", "screenshot-watcher.mjs")], { - stdio: "inherit", - cwd: extensionRoot, - env, -}); - -// 5. Run only the screenshot suite, headed, with the sidecar attached. -const test = spawnSync("npx", ["vscode-test", "--grep", "Editor screenshots"], { - stdio: "inherit", - cwd: extensionRoot, - env, -}); - -watcher.kill(); -process.exit(test.status ?? 1); diff --git a/vscode-extension/scripts/fetch-real-world-repos.mjs b/vscode-extension/scripts/fetch-real-world-repos.mjs deleted file mode 100644 index 1515a9da5..000000000 --- a/vscode-extension/scripts/fetch-real-world-repos.mjs +++ /dev/null @@ -1,160 +0,0 @@ -// Implements [VSIX-REALWORLD-CORPUS]. See docs/specs/VSIX-REAL-WORLD-SPEC.md#VSIX-REALWORLD-CORPUS -// -// Fetches the pinned real-world Python repositories the [VSIX-REALWORLD] -// e2e suites open as VS Code workspaces. Every repo is pinned to an exact -// commit SHA (immutable content), downloaded as a GitHub tarball (no git -// dependency), extracted under `.real-world//`, and stamped with a -// marker file so repeat runs are a no-op. Runs as `pretest`, so the corpus -// is always present before `vscode-test` launches. -// -// Honesty rule: a fetch that cannot produce the pinned tree FAILS the run. -// There is no offline skip — a missing corpus would silently disarm the -// real-world suites, which is forbidden (CLAUDE.md, Testing). - -import { execFileSync } from "node:child_process"; -import fs from "node:fs"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; - -const EXTENSION_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); -const CORPUS_PATH = path.join(EXTENSION_ROOT, "test-fixtures", "real-world-corpus.json"); -const REPOS_ROOT = path.join(EXTENSION_ROOT, ".real-world"); -const MARKER_NAME = ".bsk-real-world-ok"; -// VS Code hot-exit backups from a previous (possibly aborted mid-churn) test -// session. All test configs share this persistent user-data dir, so a dirty -// buffer backed up by an interrupted edit-churn run would be silently -// restored into the NEXT run's identical workspace, poisoning its baseline. -const USER_DATA_BACKUPS = path.join(EXTENSION_ROOT, ".vscode-test", "user-data", "Backups"); - -const DOWNLOAD_ATTEMPTS = 3; -const RETRY_BASE_DELAY_MS = 2_000; - -/** @returns {{repos: Array<{name: string, org: string, repo: string, tag: string, commit: string, sentinel: string, minPythonFiles: number}>}} */ -function loadCorpus() { - return JSON.parse(fs.readFileSync(CORPUS_PATH, "utf8")); -} - -function markerPath(dest) { - return path.join(dest, MARKER_NAME); -} - -/** Repo already extracted at the pinned commit? */ -function isFresh(dest, commit) { - try { - return fs.readFileSync(markerPath(dest), "utf8").trim() === commit; - } catch { - return false; - } -} - -async function sleep(ms) { - await new Promise((resolve) => setTimeout(resolve, ms)); -} - -/** Download `url` to `file`, retrying transient failures with backoff. */ -async function download(url, file) { - let lastError; - for (let attempt = 1; attempt <= DOWNLOAD_ATTEMPTS; attempt++) { - try { - const response = await fetch(url, { redirect: "follow" }); - if (!response.ok) { - throw new Error(`HTTP ${response.status} for ${url}`); - } - const bytes = Buffer.from(await response.arrayBuffer()); - if (bytes.length === 0) { - throw new Error(`Empty tarball from ${url}`); - } - fs.writeFileSync(file, bytes); - return; - } catch (error) { - lastError = error; - console.warn(` attempt ${attempt}/${DOWNLOAD_ATTEMPTS} failed: ${error.message ?? error}`); - if (attempt < DOWNLOAD_ATTEMPTS) { - await sleep(RETRY_BASE_DELAY_MS * attempt); - } - } - } - throw new Error(`Download failed after ${DOWNLOAD_ATTEMPTS} attempts: ${lastError?.message ?? lastError}`); -} - -/** - * Fetch one pinned repo into `.real-world/` and stamp the marker. - * Extraction uses the system `tar` (bsdtar on Windows 10+, GNU tar on - * Linux, bsdtar on macOS) — present on every platform CI and devs use. - */ -async function fetchRepo(entry) { - const dest = path.join(REPOS_ROOT, entry.name); - if (isFresh(dest, entry.commit)) { - console.log(`✓ ${entry.name} already pinned at ${entry.commit.slice(0, 12)} (${entry.tag})`); - return; - } - - console.log(`▶ Fetching ${entry.org}/${entry.repo} @ ${entry.tag} (${entry.commit.slice(0, 12)})`); - // Stale or partial tree: rebuild the cache dir from scratch. - fs.rmSync(dest, { recursive: true, force: true }); - fs.mkdirSync(dest, { recursive: true }); - - const tarballName = `${entry.name}-${entry.commit.slice(0, 12)}.tar.gz`; - const tarball = path.join(REPOS_ROOT, tarballName); - const url = `https://codeload.github.com/${entry.org}/${entry.repo}/tar.gz/${entry.commit}`; - try { - await download(url, tarball); - // Relative paths + cwd, NOT absolute Windows paths: GNU tar (e.g. the - // MSYS tar on a Git Bash PATH) parses `C:\...` as a remote host spec. - execFileSync("tar", ["-xzf", tarballName, "--strip-components=1", "-C", entry.name], { - cwd: REPOS_ROOT, - stdio: "inherit", - }); - } finally { - fs.rmSync(tarball, { force: true }); - } - - const sentinel = path.join(dest, entry.sentinel); - if (!fs.existsSync(sentinel)) { - throw new Error( - `${entry.name}: sentinel ${entry.sentinel} missing after extraction — ` + - "tarball layout changed or extraction failed" - ); - } - const pyFiles = countPythonFiles(dest); - if (pyFiles < entry.minPythonFiles) { - throw new Error( - `${entry.name}: extracted tree holds ${pyFiles} .py files — expected at ` + - `least ${entry.minPythonFiles}; the tree is truncated` - ); - } - - fs.writeFileSync(markerPath(dest), `${entry.commit}\n`); - console.log(`✓ ${entry.name} ready at ${path.relative(EXTENSION_ROOT, dest)} (${pyFiles} .py files)`); -} - -/** Recursively count `.py` files under `dir` (skipping dot-directories). */ -function countPythonFiles(dir) { - let count = 0; - for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { - if (entry.name.startsWith(".")) { continue; } - const full = path.join(dir, entry.name); - if (entry.isDirectory()) { - count += countPythonFiles(full); - } else if (entry.name.endsWith(".py")) { - count += 1; - } - } - return count; -} - -async function main() { - const corpus = loadCorpus(); - fs.mkdirSync(REPOS_ROOT, { recursive: true }); - // A dirty buffer from an aborted previous session must never be hot-exit - // restored into this run — see USER_DATA_BACKUPS. - fs.rmSync(USER_DATA_BACKUPS, { recursive: true, force: true }); - for (const entry of corpus.repos) { - await fetchRepo(entry); - } -} - -main().catch((error) => { - console.error(`fetch-real-world-repos failed: ${error.message ?? error}`); - process.exit(1); -}); diff --git a/vscode-extension/scripts/screenshot-watcher.mjs b/vscode-extension/scripts/screenshot-watcher.mjs deleted file mode 100644 index ad3e55f23..000000000 --- a/vscode-extension/scripts/screenshot-watcher.mjs +++ /dev/null @@ -1,109 +0,0 @@ -// Implements [VSIX-EDITOR-SCREENSHOTS-PIPELINE]: dependency-free sidecar that -// captures the real VS Code window for the website. Runs with the VSIX suite when -// BASILISK_SCREENSHOTS=1. The harness launches VS Code with -// --remote-debugging-port; this watcher speaks the Chrome DevTools Protocol over -// Node's built-in WebSocket (no Playwright / browser download), watches for -// `.signal` files written by takeWindowScreenshot() in screenshot.ts, captures -// the workbench page, and writes the PNG. See docs/specs/VSIX-EDITOR-SCREENSHOTS-SPEC.md. - -import fs from "node:fs"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -// vscode-extension/scripts -> repo root is two levels up. -const repoRoot = path.resolve(__dirname, "../../"); -const outputDir = process.env.BASILISK_SCREENSHOT_OUTPUT_DIR - ? path.resolve(process.env.BASILISK_SCREENSHOT_OUTPUT_DIR) - : path.resolve(repoRoot, "website/src/assets/images"); -const CDP_PORT = Number.parseInt(process.env.BASILISK_SCREENSHOT_CDP_PORT ?? "9229", 10); -const POLL_MS = 200; -const TIMEOUT_MS = 600_000; - -fs.mkdirSync(outputDir, { recursive: true }); -const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); - -// Find the VS Code workbench page target and return its debugger WebSocket URL. -async function findWorkbenchSocket(deadline) { - while (Date.now() < deadline) { - try { - const res = await fetch(`http://127.0.0.1:${CDP_PORT}/json/list`); - if (res.ok) { - const targets = await res.json(); - const page = targets.find( - (t) => t.type === "page" && /workbench\.(esm\.)?html|workbench\.html/.test(t.url ?? ""), - ) ?? targets.find((t) => t.type === "page"); - if (page?.webSocketDebuggerUrl) return page.webSocketDebuggerUrl; - } - } catch { - /* endpoint not up yet */ - } - await sleep(500); - } - throw new Error(`VS Code workbench page not found on CDP port ${CDP_PORT}`); -} - -// Minimal CDP client over the built-in WebSocket: correlates responses by id. -function connect(wsUrl) { - const ws = new WebSocket(wsUrl); - const pending = new Map(); - let nextId = 1; - ws.addEventListener("message", (event) => { - const msg = JSON.parse(typeof event.data === "string" ? event.data : event.data.toString()); - if (msg.id !== undefined && pending.has(msg.id)) { - const { resolve, reject } = pending.get(msg.id); - pending.delete(msg.id); - msg.error ? reject(new Error(msg.error.message)) : resolve(msg.result); - } - }); - const ready = new Promise((resolve, reject) => { - ws.addEventListener("open", () => resolve()); - ws.addEventListener("error", () => reject(new Error("CDP socket error"))); - }); - const send = (method, params = {}) => - new Promise((resolve, reject) => { - const id = nextId++; - pending.set(id, { resolve, reject }); - ws.send(JSON.stringify({ id, method, params })); - }); - return { ws, ready, send }; -} - -async function main() { - console.log(`[screenshots] waiting for VS Code CDP on port ${CDP_PORT}...`); - const wsUrl = await findWorkbenchSocket(Date.now() + 120_000); - const { ws, ready, send } = connect(wsUrl); - await ready; - await send("Page.enable"); - // Force a uniform, Retina-crisp viewport so every screenshot is the same size - // regardless of the headed window's actual dimensions. - await send("Emulation.setDeviceMetricsOverride", { - width: 1440, - height: 900, - deviceScaleFactor: 2, - mobile: false, - }); - await sleep(800); - console.log("[screenshots] connected; watching for signal files..."); - - const start = Date.now(); - while (Date.now() - start < TIMEOUT_MS) { - for (const signal of fs.readdirSync(outputDir).filter((f) => f.endsWith(".signal"))) { - const signalPath = path.join(outputDir, signal); - const requested = fs.readFileSync(signalPath, "utf8").trim(); - const tempPath = path.join(outputDir, signal.replace(/\.signal$/, "")); - const { data } = await send("Page.captureScreenshot", { format: "png", captureBeyondViewport: false }); - const screenshot = Buffer.from(data, "base64"); - fs.writeFileSync(tempPath, screenshot); - fs.unlinkSync(signalPath); - console.log(`[screenshots] ${requested} (${Math.round(screenshot.length / 1024)}KB)`); - } - await sleep(POLL_MS); - } - ws.close(); -} - -main().catch((error) => { - console.error("[screenshots] failed:", error.message); - process.exit(1); -}); diff --git a/vscode-extension/scripts/stage-runtime.mjs b/vscode-extension/scripts/stage-runtime.mjs deleted file mode 100644 index bb4036654..000000000 --- a/vscode-extension/scripts/stage-runtime.mjs +++ /dev/null @@ -1,79 +0,0 @@ -// Stage every shipwright-declared runtime *binary* for a platform into the -// extension's `bin//`, copying from a build directory. -// -// This is the SINGLE source of truth for which binaries the VSIX bundles — used -// by the e2e test harness (`_test_vsix`), the release packager (`_release_vsix`), -// AND the release.yml `vsix` job. Keeping one path is what stops the tests from -// validating a different bundle than what ships (issue #71). Asset components -// (e.g. debugpy) are vendored separately by `vendor-debugpy.mjs`. -// Implements [VSIX-PACKAGING-PARITY]. -// -// Usage: node scripts/stage-runtime.mjs [platform] -import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, rmSync } from "node:fs"; -import { dirname, join, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; - -const scriptDir = dirname(fileURLToPath(import.meta.url)); -const extensionRoot = resolve(scriptDir, ".."); -const repoRoot = resolve(extensionRoot, ".."); - -/** Kinds that carry a `binaryName` (per the shipwright schema). */ -const BINARY_KINDS = new Set(["cli", "lsp", "mcp", "sidecar", "dap", "tool"]); - -function detectPlatform() { - const arch = process.arch === "arm64" ? "arm64" : "x64"; - if (process.platform === "darwin") return `darwin-${arch}`; - if (process.platform === "linux") return `linux-${arch}`; - if (process.platform === "win32") return `win32-${arch}`; - throw new Error(`Unsupported platform: ${process.platform}-${process.arch}`); -} - -function supportsPlatform(component, platform) { - return ( - component.platforms === undefined || - component.platforms.includes(platform) || - component.platforms.includes("all") - ); -} - -const buildDirArg = process.argv[2]; -if (!buildDirArg) { - console.error("Usage: node scripts/stage-runtime.mjs [platform]"); - process.exit(2); -} -const buildDir = resolve(process.cwd(), buildDirArg); -const platform = process.argv[3] ?? detectPlatform(); -const exe = platform.startsWith("win32-") ? ".exe" : ""; - -const manifest = JSON.parse(readFileSync(join(repoRoot, "shipwright.json"), "utf8")); - -const binRoot = join(extensionRoot, "bin"); -rmSync(binRoot, { recursive: true, force: true }); -const platformDir = join(binRoot, platform); -mkdirSync(platformDir, { recursive: true }); - -const staged = []; -for (const component of manifest.components) { - if (!component.bundled || !component.binaryName) continue; - if (!BINARY_KINDS.has(component.kind)) continue; - if (!supportsPlatform(component, platform)) continue; - - const file = `${component.binaryName}${exe}`; - const source = join(buildDir, file); - if (!existsSync(source)) { - throw new Error( - `stage-runtime: built binary for component '${component.id}' not found: ${source}` - ); - } - const dest = join(platformDir, file); - copyFileSync(source, dest); - if (process.platform !== "win32") { - chmodSync(dest, 0o755); - } - staged.push(dest); -} - -console.log(`Staged ${staged.length} runtime binary/binaries for ${platform}:`); -for (const path of staged) { - console.log(` ${path}`); -} diff --git a/vscode-extension/scripts/sync-shipwright-manifest.mjs b/vscode-extension/scripts/sync-shipwright-manifest.mjs deleted file mode 100644 index c2df6807b..000000000 --- a/vscode-extension/scripts/sync-shipwright-manifest.mjs +++ /dev/null @@ -1,15 +0,0 @@ -import { copyFileSync, existsSync } from "node:fs"; -import { dirname, join, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; - -const scriptDir = dirname(fileURLToPath(import.meta.url)); -const extensionRoot = resolve(scriptDir, ".."); -const repoRoot = resolve(extensionRoot, ".."); -const source = join(repoRoot, "shipwright.json"); -const target = join(extensionRoot, "shipwright.json"); - -if (!existsSync(source)) { - throw new Error(`Missing Shipwright manifest: ${source}`); -} - -copyFileSync(source, target); diff --git a/vscode-extension/scripts/vendor-debugpy.mjs b/vscode-extension/scripts/vendor-debugpy.mjs deleted file mode 100644 index b1b6a1f38..000000000 --- a/vscode-extension/scripts/vendor-debugpy.mjs +++ /dev/null @@ -1,71 +0,0 @@ -// Vendor debugpy into the VSIX bundle so debugging works without the user -// installing debugpy into their interpreter. The version is the single source -// of truth in shipwright.json (the `debugpy` asset's `pip:debugpy==X.Y.Z`), -// so this never drifts from what verification expects. -import { createHash } from "node:crypto"; -import { execFileSync } from "node:child_process"; -import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { dirname, join, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; - -const scriptDir = dirname(fileURLToPath(import.meta.url)); -const extensionRoot = resolve(scriptDir, ".."); -const repoRoot = resolve(extensionRoot, ".."); - -const manifest = JSON.parse(readFileSync(join(repoRoot, "shipwright.json"), "utf8")); -const component = manifest.components.find((entry) => entry.id === "debugpy"); -if (!component) { - throw new Error("shipwright.json has no 'debugpy' component to vendor"); -} - -const source = component.asset?.source ?? ""; -const match = /^pip:(.+)$/.exec(source); -if (!match) { - throw new Error(`debugpy asset.source must be 'pip:'; got: ${JSON.stringify(source)}`); -} -const spec = match[1]; -const expectedSha256 = component.asset?.sha256 ?? ""; -if (component.asset?.contentHash !== true || !/^[0-9a-f]{64}$/.test(expectedSha256)) { - throw new Error("debugpy asset must enable contentHash and declare a SHA-256"); -} -const target = join(extensionRoot, component.bundled.bundlePath); -const defaultPython = process.platform === "win32" ? "python" : "python3"; -const python = process.env.BASILISK_PYTHON || process.env.PYTHON || defaultPython; - -console.log(`Vendoring pinned universal ${spec} -> ${target} (using ${python})`); -const download = mkdtempSync(join(tmpdir(), "basilisk-debugpy-")); -try { - execFileSync( - python, - [ - "-m", "pip", "download", "--disable-pip-version-check", "--no-deps", - "--only-binary=:all:", "--platform", "any", "--implementation", "py", - "--python-version", "38", "--abi", "none", "--dest", download, spec, - ], - { stdio: "inherit" }, - ); - const wheels = readdirSync(download).filter((name) => name.endsWith("-none-any.whl")); - if (wheels.length !== 1) { - throw new Error(`expected one universal debugpy wheel, found ${wheels.length}`); - } - const wheel = join(download, wheels[0]); - const actualSha256 = createHash("sha256").update(readFileSync(wheel)).digest("hex"); - if (actualSha256 !== expectedSha256) { - throw new Error(`debugpy wheel SHA-256 mismatch: ${actualSha256}`); - } - rmSync(target, { recursive: true, force: true }); - mkdirSync(target, { recursive: true }); - execFileSync( - python, - ["-m", "pip", "install", "--no-compile", "--no-deps", "--no-index", "--target", target, wheel], - { stdio: "inherit" }, - ); -} finally { - rmSync(download, { recursive: true, force: true }); -} - -if (!existsSync(join(target, "debugpy")) || readdirSync(target).length === 0) { - throw new Error(`debugpy vendoring produced no files in ${target}`); -} -console.log("debugpy vendored."); diff --git a/vscode-extension/src/configuration-editor-document.ts b/vscode-extension/src/configuration-editor-document.ts deleted file mode 100644 index 283b56124..000000000 --- a/vscode-extension/src/configuration-editor-document.ts +++ /dev/null @@ -1,104 +0,0 @@ -// Implements [VSIX-CONFIGURATION-EDITOR-HOST] / [CONFIGEDITOR-ACCESSIBILITY-SECURITY]. -/** Static, data-free configuration editor document assembled by the hardened host. */ - -import { buildWebviewDocument } from "./profiler-webview"; -import { CONFIGURATION_EDITOR_SCRIPT } from "./configuration-editor-script"; -import { CONFIGURATION_EDITOR_STYLES } from "./configuration-editor-styles"; - -// [CONFIGEDITOR-VSIX-EXPERIENCE]: five navigation views — Overview, Rules, -// Adoption, Path Overrides, Project. Every view renders server-computed state -// (the exact effective state, never a synthetic score). The Rules view is -// tag-first and drives the four rule/tag mutations (tag groups carry the -// tag-entry control; rows carry per-rule entry controls). Overview and Project -// are read-only dashboards (Project additionally carries the Typeshed and -// Caching setting panels); Adoption invokes the standalone adopt / safe-fix -// commands; Path Overrides lists the nested [tool.basilisk] tables the checker -// honors and opens one for editing. There is no preset UI. -const SECTION_NAV = ` - `; - -const OVERVIEW_SECTION = ` - `; - -const ADOPTION_SECTION = ` - `; - -const PATHS_SECTION = ` - `; - -const PROJECT_SECTION = ` - `; - -const RULES_SECTION = ` -
-
- -
-

Rules

-
-
- -
-
`; - -// 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}
- ${DIALOGS}`; - -/** Build the complete CSP-locked document; no workspace data is interpolated. */ -export function buildConfigurationEditorDocument(): string { - return buildWebviewDocument({ - title: "Basilisk Configuration", - css: CONFIGURATION_EDITOR_STYLES, - body: BODY, - script: CONFIGURATION_EDITOR_SCRIPT, - }); -} diff --git a/vscode-extension/src/configuration-editor-errors.ts b/vscode-extension/src/configuration-editor-errors.ts deleted file mode 100644 index b4d89b097..000000000 --- a/vscode-extension/src/configuration-editor-errors.ts +++ /dev/null @@ -1,65 +0,0 @@ -// Implements [CONFIGEDITOR-ACCESSIBILITY-SECURITY] structured error routing. - -import * as path from "path"; -import * as vscode from "vscode"; - -const CONFLICT_WORDS = ["stale", "conflict", "revision", "changed since preview"] as const; - -export interface ConfigurationError { - readonly message: string; - readonly conflict: boolean; - readonly repairUri: string | undefined; -} - -/** Accept only the root-level `pyproject.toml` as a repair/open target. */ -export function configurationRepairUri(value: unknown, rootUri: string | undefined): string | undefined { - if (typeof value !== "string" || rootUri === undefined) { return undefined; } - try { - const source = vscode.Uri.parse(value, true); - const root = vscode.Uri.parse(rootUri, true); - if (source.scheme !== "file" || root.scheme !== "file") { return undefined; } - const sourcePath = path.resolve(source.fsPath); - const rootPath = path.resolve(root.fsPath); - if (path.dirname(sourcePath) !== rootPath || path.basename(sourcePath) !== "pyproject.toml") { - return undefined; - } - return source.toString(); - } catch { - return undefined; - } -} - -export function configurationError(error: unknown, rootUri?: string): ConfigurationError { - const record = typeof error === "object" && error !== null - ? error as { readonly data?: unknown; readonly message?: unknown } - : undefined; - const message = error instanceof Error - ? error.message - : typeof record?.message === "string" ? record.message : String(error); - const data = record?.data; - const conflict = typeof data === "object" && data !== null - && (data as { readonly kind?: unknown }).kind === "revisionConflict"; - const context = typeof data === "object" && data !== null - ? (data as { readonly context?: unknown }).context - : undefined; - const sourceUri = typeof context === "object" && context !== null - ? (context as { readonly sourceUri?: unknown }).sourceUri - : undefined; - return { - message, - conflict: conflict || CONFLICT_WORDS.some((word) => message.toLowerCase().includes(word)), - repairUri: configurationRepairUri(sourceUri, rootUri), - }; -} - -export function fileIsWithinRoot(target: vscode.Uri, rootUri: string | undefined): boolean { - if (rootUri === undefined) { return false; } - try { - const root = vscode.Uri.parse(rootUri, true); - if (root.scheme !== "file") { return false; } - const relative = path.relative(path.resolve(root.fsPath), path.resolve(target.fsPath)); - return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); - } catch { - return false; - } -} diff --git a/vscode-extension/src/configuration-editor-intents.ts b/vscode-extension/src/configuration-editor-intents.ts deleted file mode 100644 index db0cc7619..000000000 --- a/vscode-extension/src/configuration-editor-intents.ts +++ /dev/null @@ -1,289 +0,0 @@ -// Implements [VSIX-CONFIGURATION-EDITOR-THIN-SHELL] runtime intent decoding. -/** Untrusted webview messages accepted by the configuration editor host. */ - -import type { - CacheSettingKey, - EditorMutation, - RuleOccurrencesRequest, - RuleSelector, - RuleSeverity, - TypeshedAction, - TypeshedSettingKey, -} from "./configuration-editor-model"; - -const MAX_MUTATIONS = 512; -const MAX_CODES = 2_048; -const MAX_TAGS = 128; -const MAX_TEXT_LENGTH = 8_192; -const MAX_OCCURRENCES = 500; - -/** Intents that read or change the configuration the editor is editing. */ -export type ConfigurationEditorStateIntent = - | { readonly type: "ready" } - | { readonly type: "refresh" } - | { readonly type: "apply" } - | { readonly type: "cancelPreview" } - | { readonly type: "preview"; readonly mutations: EditorMutation[] } - | { readonly type: "adopt"; readonly scope: "workspace" } - | { readonly type: "fixSafe" } - | { readonly type: "occurrences"; readonly request: Omit }; - -/** - * Intents that only open a document or run a native picker/action. They are - * split out so each side routes through its own exhaustive switch, which is - * how the union stays checked as it grows ([LSPCFGED-CACHE] added two). - */ -export type ConfigurationEditorNavigationIntent = - | { readonly type: "openRaw" } - | { readonly type: "openConfigFile"; readonly uri: string } - | { readonly type: "openDocs"; readonly uri: string } - | { readonly type: "openOccurrence"; readonly uri: string; readonly line: number; readonly character: number } - | { readonly type: "pickTypeshedFolder"; readonly key: "TypeshedPath" | "TypeshedStorePath" } - | { readonly type: "pickCacheFolder" } - | { readonly type: "typeshedAction"; readonly action: TypeshedAction }; - -export type ConfigurationEditorIntent = - | ConfigurationEditorStateIntent - | ConfigurationEditorNavigationIntent; - -const NAVIGATION_TYPES: readonly ConfigurationEditorNavigationIntent["type"][] = [ - "openRaw", "openConfigFile", "openDocs", "openOccurrence", - "pickTypeshedFolder", "pickCacheFolder", "typeshedAction", -]; - -/** Narrow one decoded intent to the navigation half of the union. */ -export function isNavigationIntent( - intent: ConfigurationEditorIntent, -): intent is ConfigurationEditorNavigationIntent { - return (NAVIGATION_TYPES as readonly string[]).includes(intent.type); -} - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function boundedString(value: unknown): string | undefined { - return typeof value === "string" && value.length > 0 && value.length <= MAX_TEXT_LENGTH - ? value - : undefined; -} - -function stringList(value: unknown, maximum: number): string[] | undefined { - if (!Array.isArray(value) || value.length === 0 || value.length > maximum) { return undefined; } - const strings = value.map(boundedString); - return strings.every((item): item is string => item !== undefined) ? strings : undefined; -} - -/** Read-side occurrence selectors only — mutations never take selectors ([CONFIGEDITOR-MODEL]). */ -function decodeSelector(value: unknown): RuleSelector | undefined { - if (!isRecord(value) || typeof value.kind !== "string") { return undefined; } - switch (value.kind) { - case "All": return { kind: "All" }; - case "Codes": { - const codes = stringList(value.codes, MAX_CODES); - return codes === undefined ? undefined : { kind: "Codes", codes }; - } - case "Tags": { - const tags = stringList(value.tags, MAX_TAGS); - return tags === undefined || typeof value.matchAll !== "boolean" - ? undefined - : { kind: "Tags", tags, matchAll: value.matchAll }; - } - default: return undefined; - } -} - -function decodeSeverity(value: unknown): RuleSeverity | undefined { - if (!isRecord(value) || typeof value.kind !== "string") { return undefined; } - switch (value.kind) { - case "Error": return { kind: "Error" }; - case "Warning": return { kind: "Warning" }; - case "Info": return { kind: "Info" }; - case "Disabled": return { kind: "Disabled" }; - default: return undefined; - } -} - -function decodeTypeshedKey(value: unknown): TypeshedSettingKey | undefined { - if (!isRecord(value) || typeof value.kind !== "string") { return undefined; } - switch (value.kind) { - case "TypeshedPath": return { kind: "TypeshedPath" }; - case "TypeshedCommit": return { kind: "TypeshedCommit" }; - case "TypeshedPackage": return { kind: "TypeshedPackage" }; - case "TypeshedStorePath": return { kind: "TypeshedStorePath" }; - default: return undefined; - } -} - -/** - * 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 = boundedString(value.value); - return key === undefined || setting === undefined - ? undefined - : { kind: "SetTypeshedSetting", key, value: setting }; - } - if (value.kind === "RemoveTypeshedSetting") { - const key = decodeTypeshedKey(value.key); - return key === undefined ? undefined : { kind: "RemoveTypeshedSetting", key }; - } - return undefined; -} - -function decodeCacheKey(value: unknown): CacheSettingKey | undefined { - if (!isRecord(value) || typeof value.kind !== "string") { return undefined; } - switch (value.kind) { - case "CacheEnabled": return { kind: "CacheEnabled" }; - case "CacheDir": return { kind: "CacheDir" }; - default: return undefined; - } -} - -/** - * The persistent cache's two keys ([LSPCFGED-CACHE]). Both cross the wire as - * text — the server parses "true"/"false" for CacheEnabled and rejects - * anything else, so no unchecked value reaches the configuration file. - */ -function decodeCacheMutation(value: Record): EditorMutation | undefined { - if (value.kind === "SetCacheSetting") { - const key = decodeCacheKey(value.key); - const setting = boundedString(value.value); - return key === undefined || setting === undefined - ? undefined - : { kind: "SetCacheSetting", key, value: setting }; - } - if (value.kind === "RemoveCacheSetting") { - const key = decodeCacheKey(value.key); - return key === undefined ? undefined : { kind: "RemoveCacheSetting", key }; - } - return undefined; -} - -function decodeMutation(value: unknown): EditorMutation | undefined { - if (!isRecord(value) || typeof value.kind !== "string") { return undefined; } - if (value.kind === "SetTypeshedSetting" || value.kind === "RemoveTypeshedSetting") { - return decodeTypeshedMutation(value); - } - if (value.kind === "SetCacheSetting" || value.kind === "RemoveCacheSetting") { - return decodeCacheMutation(value); - } - return decodeRuleMutation(value); -} - -/** - * The only four things the rule table can request: set or remove one rule - * entry, or one tag entry ([CHKARCH-CONFIG-MODEL], [CONFIGEDITOR-OPERATIONS]). - */ -function decodeRuleMutation(value: Record): EditorMutation | undefined { - switch (value.kind) { - case "SetRule": { - const code = boundedString(value.code); - const severity = decodeSeverity(value.severity); - return code === undefined || severity === undefined - ? undefined - : { kind: "SetRule", code, severity }; - } - case "RemoveRule": { - const code = boundedString(value.code); - return code === undefined ? undefined : { kind: "RemoveRule", code }; - } - case "SetTag": { - const tag = boundedString(value.tag); - const severity = decodeSeverity(value.severity); - return tag === undefined || severity === undefined - ? undefined - : { kind: "SetTag", tag, severity }; - } - case "RemoveTag": { - const tag = boundedString(value.tag); - return tag === undefined ? undefined : { kind: "RemoveTag", tag }; - } - default: return undefined; - } -} - -function decodeTypeshedAction(value: unknown): TypeshedAction | undefined { - switch (value) { - case "DownloadLatest": return { kind: "DownloadLatest" }; - case "DownloadPinned": return { kind: "DownloadPinned" }; - case "ViewLicense": return { kind: "ViewLicense" }; - default: return undefined; - } -} - -function decodePreview(value: Record): ConfigurationEditorIntent | undefined { - if (!Array.isArray(value.mutations) || value.mutations.length === 0 || value.mutations.length > MAX_MUTATIONS) { - return undefined; - } - const mutations = value.mutations.map(decodeMutation); - if (!mutations.every((mutation): mutation is EditorMutation => mutation !== undefined)) { - return undefined; - } - return { type: "preview", mutations }; -} - -function decodeOccurrences(value: Record): ConfigurationEditorIntent | undefined { - const selector = decodeSelector(value.selector); - const cursor = value.cursor === undefined ? undefined : boundedString(value.cursor); - const limit = value.limit; - if (selector === undefined || (value.cursor !== undefined && cursor === undefined)) { return undefined; } - if (!Number.isInteger(limit) || typeof limit !== "number" || limit < 1 || limit > MAX_OCCURRENCES) { - return undefined; - } - return { type: "occurrences", request: { selector, cursor, limit } }; -} - -function decodeNavigationIntent(value: Record): ConfigurationEditorIntent | undefined { - if (value.type === "openDocs") { - const uri = boundedString(value.uri); - return uri === undefined ? undefined : { type: "openDocs", uri }; - } - if (value.type !== "openOccurrence") { return undefined; } - const uri = boundedString(value.uri); - const line = value.line; - const character = value.character; - return uri !== undefined && typeof line === "number" && Number.isInteger(line) && line >= 0 - && typeof character === "number" && Number.isInteger(character) && character >= 0 - ? { type: "openOccurrence", uri, line, character } - : undefined; -} - -function decodeCoreIntent(value: Record): ConfigurationEditorIntent | undefined { - switch (value.type) { - case "ready": return { type: "ready" }; - case "refresh": return { type: "refresh" }; - case "openRaw": return { type: "openRaw" }; - case "apply": return { type: "apply" }; - case "cancelPreview": return { type: "cancelPreview" }; - case "adopt": return value.scope === "workspace" ? { type: "adopt", scope: "workspace" } : undefined; - case "fixSafe": return { type: "fixSafe" }; - default: return undefined; - } -} - -/** Decode one untrusted `webview.onDidReceiveMessage` payload. */ -export function decodeConfigurationEditorIntent(value: unknown): ConfigurationEditorIntent | undefined { - if (!isRecord(value) || typeof value.type !== "string") { return undefined; } - if (value.type === "preview") { return decodePreview(value); } - if (value.type === "occurrences") { return decodeOccurrences(value); } - if (value.type === "openConfigFile") { - const uri = boundedString(value.uri); - return uri === undefined ? undefined : { type: "openConfigFile", uri }; - } - if (value.type === "pickTypeshedFolder") { - return value.key === "TypeshedPath" || value.key === "TypeshedStorePath" - ? { type: "pickTypeshedFolder", key: value.key } - : undefined; - } - if (value.type === "pickCacheFolder") { return { type: "pickCacheFolder" }; } - if (value.type === "typeshedAction") { - const action = decodeTypeshedAction(value.action); - return action === undefined ? undefined : { type: "typeshedAction", action }; - } - return decodeCoreIntent(value) ?? decodeNavigationIntent(value); -} diff --git a/vscode-extension/src/configuration-editor-model.ts b/vscode-extension/src/configuration-editor-model.ts deleted file mode 100644 index 35ecc7edd..000000000 --- a/vscode-extension/src/configuration-editor-model.ts +++ /dev/null @@ -1,349 +0,0 @@ -// Generated from models/configuration.td + models/configuration_editor.td: -// cat models/configuration.td models/configuration_editor.td | typediagram --to typescript -// Implements [CONFIGEDITOR-MODEL] / [LSPARCH-CONFIG-EDITOR-PROTOCOL] / [CHKARCH-CONFIG-MODEL]. -// Do not hand-maintain a second rule/configuration domain in the VSIX. - -export type RuleCode = string; - -export type RuleTag = string; - -export type RuleSeverity = - | { kind: "Error" } - | { kind: "Warning" } - | { kind: "Info" } - | { kind: "Disabled" }; - -export interface RuleEntry { - code: RuleCode; - severity: RuleSeverity; -} - -export interface TagEntry { - tag: RuleTag; - severity: RuleSeverity; -} - -export interface RulesConfig { - rules: RuleEntry[]; - ruleTags: TagEntry[]; -} - -export type Uri = string; - -export type Revision = string; - -export type PreviewId = string; - -export type TagKind = - | { kind: "Provenance" } - | { kind: "PepCategory" } - | { kind: "Descriptive" }; - -export type TypeshedSettingKey = - | { kind: "TypeshedPath" } - | { kind: "TypeshedCommit" } - | { kind: "TypeshedPackage" } - | { kind: "TypeshedStorePath" }; - -/** - * The persistent result cache's two [tool.basilisk] keys ([LSPCFGED-CACHE]). - * The in-session Salsa layer is deliberately absent: it is always on and has - * no key, so there is nothing here to set. - */ -export type CacheSettingKey = - | { kind: "CacheEnabled" } - | { kind: "CacheDir" }; - -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: string } - | { kind: "RemoveTypeshedSetting"; key: TypeshedSettingKey } - | { kind: "SetCacheSetting"; key: CacheSettingKey; value: string } - | { kind: "RemoveCacheSetting"; key: CacheSettingKey }; - -/** - * There are exactly three sources, each carrying the value that defines it: - * only one may be active at a time, and there is no "track latest" source - * ([LSPCFGED-TYPESHED], [STUBRES-TYPESHED-PYPI]). - */ -export type TypeshedSource = - | { kind: "ExactCommit"; commit: string } - | { kind: "CustomFolder"; path: string } - | { kind: "PyPIPackage"; name: string; sha256: string }; - -export type TypeshedLifecycle = - | { kind: "Downloading" } - | { kind: "Ready" } - | { kind: "NoSource" }; - -export type TypeshedAction = - | { 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: "Bundled" } - | { kind: "PyPIPackage" }; - -export type TypeshedLicenseStatus = - | { kind: "Unavailable" } - | { kind: "Approved" } - | { kind: "Changed" } - | { kind: "NotSupplied" }; - -export type TypeshedWarningSeverity = - | { kind: "Advisory" } - | { kind: "High" }; - -export interface TypeshedWarningState { - code: string; - message: string; - severity: TypeshedWarningSeverity; -} - -export interface TypeshedStatusState { - lifecycle: TypeshedLifecycle; - noSourceReason: string | undefined; - activeSource: TypeshedActiveSource | undefined; - commitIdentity: string | undefined; - licenseStatus: TypeshedLicenseStatus; - warnings: TypeshedWarningState[]; -} - -/** - * 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. - */ -export interface TypeshedConfigurationState { - source: TypeshedSource; - storeFolder: string | undefined; - licenseAvailable: boolean; - status: TypeshedStatusState; -} - -/** - * The persistent, cross-session result cache ([CHKCACHE]). `folder` is the - * effective location the next run uses, so the editor never shows a folder the - * run would not use; `folderConfigured` separates the default from a project's - * own choice. - */ -export interface PersistentCacheState { - enabled: boolean; - folder: string; - folderConfigured: boolean; -} - -/** - * The in-session incremental engine ([CHKARCH-INCREMENTAL-SALSA]): always on, - * no configuration key, so its one real value is the live memo count. - */ -export interface InSessionCacheState { - trackedFiles: number; -} - -export interface CacheConfigurationState { - persistent: PersistentCacheState; - inSession: InSessionCacheState; -} - -export type RuleSelector = - | { kind: "All" } - | { kind: "Codes"; codes: RuleCode[] } - | { kind: "Tags"; tags: RuleTag[]; matchAll: boolean }; - -export interface RuleDescriptor { - code: RuleCode; - title: string; - summary: string; - docsUrl: Uri; - tags: RuleTag[]; -} - -export interface RuleState { - descriptor: RuleDescriptor; - entry: RuleSeverity | undefined; - effectiveSeverity: RuleSeverity; - diagnosticCount: number; -} - -export interface TagState { - name: RuleTag; - kind: TagKind; - entry: RuleSeverity | undefined; - ruleCount: number; - diagnosticCount: number; -} - -export interface ConfigurationSource { - uri: Uri; - exists: boolean; - readOnly: boolean; -} - -export interface ConfigurationProblem { - code: RuleCode; - message: string; - uri: Uri; - line: number; - character: number; -} - -export interface DebtSummary { - remainingDiagnostics: number; - errorDiagnostics: number; - warningDiagnostics: number; - infoDiagnostics: number; - adoptedRules: number; - disabledRules: number; -} - -export interface PathRuleSetting { - code: RuleCode; - severity: RuleSeverity; -} - -export interface PathTagSetting { - tag: RuleTag; - severity: RuleSeverity; -} - -export interface PathOverrideState { - path: string; - configUri: Uri; - rules: PathRuleSetting[]; - tags: PathTagSetting[]; -} - -export interface ConfigurationSnapshot { - rootUri: Uri; - configUri: Uri; - revision: Revision; - source: ConfigurationSource; - rules: RuleState[]; - tags: TagState[]; - pathOverrides: PathOverrideState[]; - debt: DebtSummary; - problems: ConfigurationProblem[]; - typeshed: TypeshedConfigurationState; - cache: CacheConfigurationState; -} - -export interface PreviewConfigurationRequest { - rootUri: Uri; - baseRevision: Revision; - mutations: EditorMutation[]; -} - -export interface ResolvedRuleChange { - code: RuleCode; - before: RuleSeverity; - after: RuleSeverity; -} - -export interface ConfigurationImpact { - errorsBefore: number; - errorsAfter: number; - warningsBefore: number; - warningsAfter: number; - infosBefore: number; - infosAfter: number; -} - -export interface ConfigurationPreview { - previewId: PreviewId; - baseRevision: Revision; - changes: ResolvedRuleChange[]; - typeshedChanges: TypeshedSettingChange[]; - cacheChanges: CacheSettingChange[]; - impact: ConfigurationImpact; -} - -export interface TypeshedSettingChange { - key: TypeshedSettingKey; - before: string | undefined; - after: string | undefined; -} - -/** Rendered TOML text on each side; undefined = the key is absent there. */ -export interface CacheSettingChange { - key: CacheSettingKey; - before: string | undefined; - after: string | undefined; -} - -export interface ApplyConfigurationRequest { - rootUri: Uri; - previewId: PreviewId; -} - -export interface SourcePosition { - line: number; - character: number; -} - -export interface SourceRange { - start: SourcePosition; - end: SourcePosition; -} - -export interface RuleOccurrence { - code: RuleCode; - uri: Uri; - range: SourceRange; - severity: RuleSeverity; -} - -export interface RuleOccurrencesRequest { - rootUri: Uri; - selector: RuleSelector; - cursor: string | undefined; - limit: number; -} - -export interface RuleOccurrencesResponse { - items: RuleOccurrence[]; - nextCursor: string | undefined; -} - -export interface ConfigurationChanged { - rootUri: Uri; - revision: Revision; -} - -export interface TypeshedActionRequest { - rootUri: Uri; - baseRevision: Revision; - action: TypeshedAction; -} - -export interface TypeshedLicenseDocument { - title: string; - uri: Uri | undefined; - content: string; - 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: "Snapshot"; snapshot: ConfigurationSnapshot } - | { kind: "License"; license: TypeshedLicenseDocument }; - -export interface TypeshedStatusChanged { - rootUri: Uri; - status: TypeshedStatusState; -} diff --git a/vscode-extension/src/configuration-editor-navigation.ts b/vscode-extension/src/configuration-editor-navigation.ts deleted file mode 100644 index ab73071f9..000000000 --- a/vscode-extension/src/configuration-editor-navigation.ts +++ /dev/null @@ -1,66 +0,0 @@ -// Implements [CONFIGEDITOR-VSIX-EXPERIENCE] navigation out of the editor. -/** - * Opening a document, a rule guide, or an occurrence from the configuration - * editor. Every target is checked against the state the server sent — the - * webview is untrusted, so a URI it did not receive is never opened. - */ - -import * as vscode from "vscode"; -import type { ConfigurationEditorState } from "./configuration-editor-state"; -import { configurationRepairUri, fileIsWithinRoot } from "./configuration-editor-errors"; - -/** Open one of the nested folder configs the snapshot actually listed. */ -export async function openConfigFile( - state: ConfigurationEditorState, - uri: string, -): Promise { - const overrides = state.snapshot?.pathOverrides ?? []; - if (!overrides.some((entry) => entry.configUri === uri)) { return; } - const target = vscode.Uri.parse(uri); - if (target.scheme !== "file" || !fileIsWithinRoot(target, state.rootUri)) { return; } - await vscode.window.showTextDocument(target, { preview: false }); -} - -/** - * Open the raw active configuration — the repair target when the document is - * malformed, otherwise the source the snapshot names. A source that does not - * exist yet is not an error: the file is created on the first applied change. - */ -export async function openRawConfiguration(state: ConfigurationEditorState): Promise { - const repairUri = state.repairUri; - if (repairUri !== undefined) { - await vscode.window.showTextDocument(vscode.Uri.parse(repairUri, true), { preview: false }); - return; - } - const sourceUri = configurationRepairUri(state.snapshot?.configUri, state.snapshot?.rootUri); - if (sourceUri === undefined) { return; } - try { - await vscode.window.showTextDocument(vscode.Uri.parse(sourceUri, true), { preview: false }); - } catch { - void vscode.window.showInformationMessage("Basilisk will create pyproject.toml when you apply a configuration change."); - } -} - -/** Open a rule guide, but only a URL the catalog itself advertised. */ -export async function openRuleDocs(state: ConfigurationEditorState, uri: string): Promise { - const rules = state.snapshot?.rules ?? []; - if (!rules.some((rule) => rule.descriptor.docsUrl === uri)) { return; } - const target = vscode.Uri.parse(uri); - if (target.scheme === "https") { await vscode.env.openExternal(target); } -} - -/** Reveal one occurrence the server actually returned, at its own position. */ -export async function openOccurrence( - state: ConfigurationEditorState, - occurrence: { readonly uri: string; readonly line: number; readonly character: number }, -): Promise { - const items = state.occurrences?.items ?? []; - const allowed = items.some((item) => item.uri === occurrence.uri - && item.range.start.line === occurrence.line - && item.range.start.character === occurrence.character); - if (!allowed) { return; } - const target = vscode.Uri.parse(occurrence.uri); - if (target.scheme !== "file" || !fileIsWithinRoot(target, state.rootUri)) { return; } - const position = new vscode.Position(occurrence.line, occurrence.character); - await vscode.window.showTextDocument(target, { preview: false, selection: new vscode.Range(position, position) }); -} diff --git a/vscode-extension/src/configuration-editor-registration.ts b/vscode-extension/src/configuration-editor-registration.ts deleted file mode 100644 index ee0a45bf8..000000000 --- a/vscode-extension/src/configuration-editor-registration.ts +++ /dev/null @@ -1,77 +0,0 @@ -// Implements [VSIX-CONFIGURATION-EDITOR] capability-gated command registration. - -import { effect } from "@preact/signals-core"; -import * as vscode from "vscode"; -import { - ConfigurationEditorController, - CONFIGURATION_EDITOR_COMMAND, - CONFIGURATION_EDITOR_CONTEXT, - EDIT_CONFIG_COMMAND, - selectConfigurationRoot, - supportsConfigurationEditor, - type ConfigurationEditorTransport, -} from "./configuration-editor"; -import type { Store } from "./store"; - -const MAX_RULE_CODE_LENGTH = 64; - -export function configurationEditorFocusRule(value: unknown): string | undefined { - if (typeof value !== "object" || value === null || Array.isArray(value)) { return undefined; } - const rule = (value as { readonly rule?: unknown }).rule; - return typeof rule === "string" && rule.length > 0 && rule.length <= MAX_RULE_CODE_LENGTH - ? rule - : undefined; -} - -async function openConfigurationFor( - controller: ConfigurationEditorController, - resource?: vscode.Uri, - focusRule?: string, -): Promise { - const folder = resource === undefined ? undefined : vscode.workspace.getWorkspaceFolder(resource); - const rootUri = folder?.uri.toString() ?? await selectConfigurationRoot(); - if (rootUri === undefined) { - void vscode.window.showInformationMessage("Open a workspace folder to configure Basilisk."); - return; - } - controller.open(rootUri, focusRule); -} - -/** Register the capability-gated editor commands and context. */ -export function registerConfigurationEditor( - store: Store, - transport?: ConfigurationEditorTransport, -): { readonly controller: ConfigurationEditorController; readonly disposables: vscode.Disposable[] } { - const controller = new ConfigurationEditorController(store, transport); - let commands: vscode.Disposable[] | undefined; - function disposeCommands(): void { commands?.forEach((command) => { command.dispose(); }); commands = undefined; } - let previouslySupported = false; - const disposeCapabilityEffect = effect(() => { - const supported = transport !== undefined - || (store.lspState.value === "running" && supportsConfigurationEditor(store.client.value)); - void vscode.commands.executeCommand("setContext", CONFIGURATION_EDITOR_CONTEXT, supported); - if (supported && commands === undefined) { - commands = [ - vscode.commands.registerCommand(CONFIGURATION_EDITOR_COMMAND, async (argument?: unknown) => - openConfigurationFor(controller, undefined, configurationEditorFocusRule(argument))), - vscode.commands.registerCommand(EDIT_CONFIG_COMMAND, async (resource?: vscode.Uri) => - openConfigurationFor(controller, resource instanceof vscode.Uri ? resource : undefined)), - ]; - } else if (!supported) { - disposeCommands(); - if (controller.isOpen() && previouslySupported) { - controller.capabilityLost("The language server no longer advertises the configuration editor. Reconnect or update Basilisk."); - } - } - if (supported && !previouslySupported) { controller.refreshOpen(); } - previouslySupported = supported; - }); - const capabilityLifecycle: vscode.Disposable = { - dispose(): void { - disposeCapabilityEffect(); - disposeCommands(); - void vscode.commands.executeCommand("setContext", CONFIGURATION_EDITOR_CONTEXT, false); - }, - }; - return { controller, disposables: [controller, capabilityLifecycle] }; -} diff --git a/vscode-extension/src/configuration-editor-script-cache.ts b/vscode-extension/src/configuration-editor-script-cache.ts deleted file mode 100644 index f99a4bcf0..000000000 --- a/vscode-extension/src/configuration-editor-script-cache.ts +++ /dev/null @@ -1,119 +0,0 @@ -// Implements [LSPCFGED-CACHE] caching controls for the Project view. -/** Caching fragment of the dependency-free webview runtime. */ - -export const CONFIGURATION_EDITOR_SCRIPT_CACHE = String.raw` - // Basilisk caches on two layers and this panel names BOTH ([LSPCFGED-CACHE]). - // Only one of them is configuration; saying so is the whole point of the - // panel, because a surface that shows a single "cache" switch reads as if - // that switch were all the caching there is. - function cacheState() { return snapshot.cache; } - function persistentCache() { return cacheState().persistent; } - - function cacheKey(name) { return { kind: name }; } - function cacheSet(name, value) { - return { kind: 'SetCacheSetting', key: cacheKey(name), value }; - } - function cacheRemove(name) { - return { kind: 'RemoveCacheSetting', key: cacheKey(name) }; - } - - // The toggle always writes an explicit 'cache = true|false', exactly as a - // severity dropdown always writes an explicit entry: what the panel shows - // is then what the file says, with no inferred middle state. - function cacheEnabledField() { - const field = document.createElement('label'); - field.className = 'cache-toggle'; - const input = document.createElement('input'); - input.type = 'checkbox'; - input.checked = persistentCache().enabled; - input.dataset.cacheEnabled = 'CacheEnabled'; - field.append( - input, - textNode('span', 'Reuse results between runs'), - textNode('small', 'Writes cache to pyproject.toml. A cached result is replayed only when the file, everything it imports, the configuration, the typeshed source, and the Basilisk version are all unchanged — otherwise the file is checked in full.'), - ); - return field; - } - - // The server sends the folder the next run actually resolves, default - // included, so the panel can show a location without the project having - // chosen one. Reset only exists once there IS a choice to undo. - function cacheFolderField() { - const cache = persistentCache(); - const field = document.createElement('label'); - field.className = 'cache-field'; - field.append( - textNode('span', 'Cache folder'), - textNode('small', cache.folderConfigured - ? 'Set by cache-dir in pyproject.toml.' - : 'Default location. Basilisk has not been told to use another.'), - ); - const picker = document.createElement('div'); - picker.className = 'path-picker'; - const input = document.createElement('input'); - input.type = 'text'; - input.readOnly = true; - input.value = cache.folder || ''; - input.dataset.cacheFolder = 'CacheDir'; - const choose = textNode('button', 'Change…', 'secondary'); - choose.type = 'button'; - choose.dataset.pickCacheFolder = 'CacheDir'; - picker.append(input, choose); - field.append(picker); - if (cache.folderConfigured) { - const reset = textNode('button', 'Use default folder', 'secondary'); - reset.type = 'button'; - reset.dataset.action = 'reset-cache-folder'; - field.append(reset); - } - return field; - } - - // Read-only, and deliberately so: the in-session engine has no key to - // offer ([CHKARCH-INCREMENTAL-SALSA]). Stating that here is what stops its - // absence from the config file reading as an omission. - function inSessionRows() { - const tracked = cacheState().inSession.trackedFiles; - return [ - ['Engine', 'Salsa incremental queries'], - ['State', 'Always on · no configuration'], - ['Memoized files', formatNumber(tracked) + ' tracked in this session'], - ]; - } - function renderInSessionCache() { - const target = byId('cache-in-session'); - clear(target); - target.append(textNode('p', 'Editing is incremental on its own. parse → resolve → check is one memoized query per file, so an edit re-runs only the file you touched and the files that import it. It lives in memory for the life of the session, needs no setting, and cannot be switched off.')); - const summary = document.createElement('dl'); - inSessionRows().forEach((row) => summary.append(textNode('dt', row[0]), textNode('dd', row[1]))); - target.append(summary); - } - - // A cache write re-renders this section from the fresh snapshot; the - // rebuild must not eat the user's focus. - function cacheFocusSelector(active) { - if (!active || !active.dataset) return undefined; - if (active.dataset.cacheEnabled) return '[data-cache-enabled]'; - if (active.dataset.pickCacheFolder) return '[data-pick-cache-folder]'; - return undefined; - } - function renderCacheControls() { - const active = document.activeElement; - const selector = cacheFocusSelector(active); - const controls = byId('cache-controls'); - clear(controls); - controls.append(cacheEnabledField(), cacheFolderField()); - renderInSessionCache(); - if (!selector) return; - const restored = document.querySelector(selector); - if (restored) restored.focus({ preventScroll: true }); - } - - /** Every caching control change, routed from the one delegated listener. */ - function cacheChanged(target) { - if (!target.dataset.cacheEnabled) return false; - postPreview([cacheSet('CacheEnabled', target.checked ? 'true' : 'false')]); - announce(target.checked ? 'Persistent result cache enabled' : 'Persistent result cache disabled'); - return true; - } -`; diff --git a/vscode-extension/src/configuration-editor-script-core.ts b/vscode-extension/src/configuration-editor-script-core.ts deleted file mode 100644 index b5bbcfc79..000000000 --- a/vscode-extension/src/configuration-editor-script-core.ts +++ /dev/null @@ -1,163 +0,0 @@ -// Implements [CONFIGEDITOR-VSIX-EXPERIENCE]. -/** First fragment of the dependency-free webview runtime. */ - -export const CONFIGURATION_EDITOR_SCRIPT_CORE = String.raw` - (() => { - 'use strict'; - const vscode = acquireVsCodeApi(); - const ROW_HEIGHT = 112; - const OVERSCAN = 5; - const OCCURRENCE_LIMIT = 100; - const PEP_TAG = 'pep'; - const NO_ENTRY = 'None'; - const SEVERITIES = ['Error', 'Warning', 'Info', 'Disabled']; - const SECTION_NAMES = ['overview', 'rules', 'adoption', 'paths', 'project']; - let editorState = { phase: 'idle', message: '' }; - let snapshot; - let preview; - let occurrences; - let filteredRules = []; - let activeTag; - let selectedRuleCode; - let lastFocusedRule; - // Whether the live preview's discard has already been reported to the - // host. The dialog's 'close' event arrives on a QUEUED task that a - // throttled (occluded) webview may never run, so every discard - // initiation point posts synchronously and this flag keeps the close - // event's fallback from double-posting. Re-armed on each preview state. - let previewCancelReported = 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'; - // One-shot per webview lifetime: the Configure Severity deep link's - // focus target is applied on the first snapshot render only, so later - // state posts never stomp the user's own search/selection. - let focusRuleConsumed = false; - - function byId(id) { return document.getElementById(id); } - function clear(node) { node.replaceChildren(); } - function textNode(tag, text, className) { - const node = document.createElement(tag); - node.textContent = text; - if (className) node.className = className; - return node; - } - function kind(value, fallback) { - return value && typeof value.kind === 'string' ? value.kind : fallback; - } - function compactUri(uri) { - try { - const parsed = new URL(uri); - const path = decodeURIComponent(parsed.pathname); - const parts = path.split('/').filter(Boolean); - return parts.slice(-2).join('/') || parsed.host || uri; - } catch (_error) { - return uri; - } - } - function formatNumber(value) { return Number(value || 0).toLocaleString(); } - // entry mirrors the config file exactly: undefined = no per-rule/tag entry. - function entryValue(entry) { return entry === undefined || entry === null ? NO_ENTRY : kind(entry, NO_ENTRY); } - function effectiveValue(rule) { return kind(rule.effectiveSeverity, 'Error'); } - // THE partition ([CHKARCH-COMMANDS]): pep-tagged rules always run and can - // never be disabled; only analyze rules get a Disabled control. - function isPepRule(rule) { return rule.descriptor.tags.indexOf(PEP_TAG) !== -1; } - // A pep-affecting tag entry can never be disabled either: the pep source - // tag and every PEP category grade only pep rules ([CHKARCH-CONFIG-MODEL]). - function isPepTag(tag) { return tag.name === PEP_TAG || kind(tag.kind, 'Descriptive') === 'PepCategory'; } - // Dropdowns list concrete severities only — there is no separate no-entry - // choice; an analyze rule with no entry is disabled, so the two were one. - function severityOptions(pep) { return pep ? SEVERITIES.filter((value) => value !== 'Disabled') : SEVERITIES; } - function ruleSearchText(rule) { - const descriptor = rule.descriptor; - return [descriptor.code, descriptor.title, descriptor.summary].concat(descriptor.tags).join(' ').toLowerCase(); - } - function matchesFacet(rule, token) { - const lower = token.toLowerCase(); - if (lower.startsWith('tag:')) return rule.descriptor.tags.some((tag) => tag.toLowerCase() === lower.slice(4)); - if (lower.startsWith('severity:')) return effectiveValue(rule).toLowerCase() === lower.slice(9); - if (lower === 'status:disabled') return effectiveValue(rule) === 'Disabled'; - if (lower === 'status:entry') return rule.entry !== undefined && rule.entry !== null; - if (lower === 'has:diagnostics') return rule.diagnosticCount > 0; - return ruleSearchText(rule).includes(lower); - } - function applyFilter() { - if (!snapshot) { filteredRules = []; return; } - const query = byId('rule-search').value.trim(); - const tokens = query === '' ? [] : query.split(/\s+/); - filteredRules = snapshot.rules.filter((rule) => { - const tagMatch = !activeTag || rule.descriptor.tags.includes(activeTag); - return tagMatch && tokens.every((token) => matchesFacet(rule, token)); - }); - const result = byId('filter-result'); - result.textContent = formatNumber(filteredRules.length) + ' of ' + formatNumber(snapshot.rules.length); - renderRuleWindow(); - } - function announce(message) { - byId('announcer').textContent = ''; - window.setTimeout(() => { byId('announcer').textContent = message; }, 20); - } - function postPreview(mutations) { - vscode.postMessage({ type: 'preview', mutations }); - } - // A dropdown change always writes an explicit entry ([CONFIGEDITOR-MODEL]): - // an explicit 'disabled' beats any tag entry, so Disabled always disables. - function ruleMutation(code, value) { - return { kind: 'SetRule', code, severity: { kind: value } }; - } - function tagMutation(tag, value) { - return { kind: 'SetTag', tag, severity: { kind: value } }; - } - function typeshedKey(name) { return { kind: name }; } - 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 }; - } - function selectedRule() { - return snapshot && snapshot.rules.find((rule) => rule.descriptor.code === selectedRuleCode); - } - function saveFocus() { - const active = document.activeElement; - const row = active && active.closest ? active.closest('[data-rule-code]') : undefined; - lastFocusedRule = row ? { - code: row.getAttribute('data-rule-code'), - control: active.matches('select') ? 'select' : 'detail', - } : undefined; - } - function restoreFocus() { - if (!lastFocusedRule) return; - // Only re-attach focus the row rebuild destroyed (focus fell back to - // body) — never steal live focus. preventScroll keeps the restored - // focus from scrolling the stale row back into view, which pinned the - // viewport and made rules below the fold unreachable. - const active = document.activeElement; - if (active && active !== document.body) return; - const row = document.querySelector('[data-rule-code="' + CSS.escape(lastFocusedRule.code) + '"]'); - if (!row) return; - const selector = lastFocusedRule.control === 'select' ? 'select' : '.rule-copy button'; - const control = row.querySelector(selector); - if (control) control.focus({ preventScroll: true }); - } - // Switch the visible navigation view. Sections carry data-section; the nav - // buttons carry data-section-target. Rules stays virtualized, so re-measure - // its viewport once it becomes visible again. - function showSection(name) { - if (SECTION_NAMES.indexOf(name) === -1) return; - activeSection = name; - document.querySelectorAll('[data-section]').forEach((section) => { - section.hidden = section.getAttribute('data-section') !== name; - }); - document.querySelectorAll('#section-nav [data-section-target]').forEach((button) => { - if (button.getAttribute('data-section-target') === name) button.setAttribute('aria-current', 'page'); - else button.removeAttribute('aria-current'); - }); - if (name === 'rules') window.requestAnimationFrame(renderRuleWindow); - } -`; diff --git a/vscode-extension/src/configuration-editor-script-events.ts b/vscode-extension/src/configuration-editor-script-events.ts deleted file mode 100644 index 9a6337632..000000000 --- a/vscode-extension/src/configuration-editor-script-events.ts +++ /dev/null @@ -1,170 +0,0 @@ -// Implements [CONFIGEDITOR-ACCESSIBILITY-SECURITY] webview intent emission. -/** Event and ready-handshake fragment of the dependency-free webview runtime. */ - -export const CONFIGURATION_EDITOR_SCRIPT_EVENTS = String.raw` - function selectTag(tag) { - activeTag = activeTag === tag ? undefined : tag; - renderTags(); - applyFilter(); - announce(activeTag ? 'Filtered by tag ' + activeTag : 'Tag filter cleared'); - } - function showRule(code) { - selectedRuleCode = code; - occurrences = undefined; - renderRuleDetail(); - vscode.postMessage({ - type: 'occurrences', - selector: { kind: 'Codes', codes: [code] }, - cursor: undefined, - limit: OCCURRENCE_LIMIT, - }); - } - function loadMoreOccurrences() { - if (!selectedRuleCode || !occurrences || !occurrences.nextCursor) return; - vscode.postMessage({ - type: 'occurrences', - selector: { kind: 'Codes', codes: [selectedRuleCode] }, - cursor: occurrences.nextCursor, - limit: OCCURRENCE_LIMIT, - }); - } - function moveVirtualRuleFocus(event) { - const row = event.target instanceof Element ? event.target.closest('[data-rule-code]') : undefined; - const viewport = byId('rule-viewport'); - if (!row && event.target !== viewport) return false; - if (event.target instanceof HTMLInputElement) return false; - const keys = ['ArrowUp', 'ArrowDown', 'PageUp', 'PageDown', 'Home', 'End']; - if (!keys.includes(event.key) || filteredRules.length === 0) return false; - if (event.target instanceof HTMLSelectElement && (event.key === 'ArrowUp' || event.key === 'ArrowDown')) return false; - const currentCode = row && row.getAttribute('data-rule-code'); - const current = Math.max(0, filteredRules.findIndex((rule) => rule.descriptor.code === currentCode)); - const page = Math.max(1, Math.floor(viewport.clientHeight / ROW_HEIGHT)); - let target = current; - if (event.key === 'Home') target = 0; - else if (event.key === 'End') target = filteredRules.length - 1; - else if (event.key === 'ArrowUp') target = current - 1; - else if (event.key === 'ArrowDown') target = current + 1; - else if (event.key === 'PageUp') target = current - page; - else if (event.key === 'PageDown') target = current + page; - target = Math.max(0, Math.min(filteredRules.length - 1, target)); - const control = event.target instanceof HTMLSelectElement ? 'select' : 'detail'; - lastFocusedRule = { code: filteredRules[target].descriptor.code, control }; - viewport.scrollTop = target * ROW_HEIGHT; - renderRuleWindow(); - event.preventDefault(); - return true; - } - // Discarding a preview must reach the host even if the dialog's queued - // 'close' event never runs (an occluded webview throttles that task - // source), so the intent posts HERE, synchronously with the user's - // action; closing the dialog is only the visual half. - function discardPreview() { - if (editorState.phase === 'preview' && !previewCancelReported) { - previewCancelReported = true; - vscode.postMessage({ type: 'cancelPreview' }); - } - const previewDialog = byId('preview-dialog'); - if (previewDialog.open) previewDialog.close(); - } - function handleAction(action) { - if (action === 'refresh') vscode.postMessage({ type: 'refresh' }); - else if (action === 'open-raw') vscode.postMessage({ type: 'openRaw' }); - else if (action === 'load-more-occurrences') loadMoreOccurrences(); - else if (action === 'close-preview') discardPreview(); - else if (action === 'apply-preview' && editorState.phase === 'preview') vscode.postMessage({ type: 'apply' }); - else if (action === 'adopt-workspace') vscode.postMessage({ type: 'adopt', scope: 'workspace' }); - else if (action === 'fix-safe') vscode.postMessage({ type: 'fixSafe' }); - // Resetting the cache folder removes the key rather than writing the - // default back as an entry ([LSPCFGED-CACHE]). - else if (action === 'reset-cache-folder') postPreview([cacheRemove('CacheDir')]); - else if (action === 'show-current') { - showSection('rules'); - byId('rule-search').value = 'has:diagnostics'; - applyFilter(); - byId('rule-search').focus(); - } - } - function occurrenceMessage(target) { - return { - type: 'openOccurrence', - uri: target.dataset.occurrenceUri, - line: Number(target.dataset.occurrenceLine), - character: Number(target.dataset.occurrenceCharacter), - }; - } - document.addEventListener('click', (event) => { - const target = event.target instanceof Element ? event.target.closest('button') : undefined; - if (!target) return; - const section = target.dataset.sectionTarget; - if (section) { showSection(section); return; } - const action = target.dataset.action; - if (action) { handleAction(action); return; } - const openConfigUri = target.dataset.openConfig; - if (openConfigUri) { vscode.postMessage({ type: 'openConfigFile', uri: openConfigUri }); return; } - const folderKey = target.dataset.pickTypeshedFolder; - if (folderKey) { vscode.postMessage({ type: 'pickTypeshedFolder', key: folderKey }); return; } - if (target.dataset.pickCacheFolder) { vscode.postMessage({ type: 'pickCacheFolder' }); return; } - const typeshedAction = target.dataset.typeshedAction; - 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; - if (code) { showRule(code); return; } - const docsUri = target.dataset.openDocs; - if (docsUri) { vscode.postMessage({ type: 'openDocs', uri: docsUri }); return; } - const findRule = target.dataset.findRule; - if (findRule) { showRule(findRule); return; } - if (target.dataset.occurrenceUri) vscode.postMessage(occurrenceMessage(target)); - }); - document.addEventListener('change', (event) => { - const target = event.target; - if (target instanceof HTMLInputElement && cacheChanged(target)) return; - if (target instanceof HTMLInputElement && typeshedChanged(target)) return; - if (!(target instanceof HTMLSelectElement)) return; - // One control change = exactly one typed mutation ([CONFIGEDITOR-MODEL]). - if (target.dataset.ruleEntry) { - lastFocusedRule = { code: target.dataset.ruleEntry, control: 'select' }; - postPreview([ruleMutation(target.dataset.ruleEntry, target.value)]); - } else if (target.dataset.tagEntry) { - postPreview([tagMutation(target.dataset.tagEntry, target.value)]); - } - }); - // A dialog dismissed any way at all (button, Escape, backdrop) discards - // the change: the host returns to the snapshot and every control - // re-renders from it, so nothing on screen can outlive the decision. - // User-initiated paths post through discardPreview() synchronously; this - // listener is the fallback for any other close so a discard can still - // never be lost, guarded against double-posting. - byId('preview-dialog').addEventListener('close', () => { - if (editorState.phase === 'preview' && !previewCancelReported) { - previewCancelReported = true; - vscode.postMessage({ type: 'cancelPreview' }); - } - }); - byId('rule-search').addEventListener('input', applyFilter); - byId('rule-viewport').addEventListener('scroll', () => window.requestAnimationFrame(renderRuleWindow), { passive: true }); - window.addEventListener('resize', () => window.requestAnimationFrame(renderRuleWindow)); - window.addEventListener('message', (event) => { - if (!event.data || event.data.type !== 'state') return; - renderState(event.data.state); - }); - document.addEventListener('keydown', (event) => { - if (moveVirtualRuleFocus(event)) return; - const typing = event.target instanceof HTMLInputElement || event.target instanceof HTMLSelectElement || event.target instanceof HTMLTextAreaElement; - if (event.key === '/' && !typing) { - event.preventDefault(); - showSection('rules'); - byId('rule-search').focus(); - } - if (event.key === 'Escape' && byId('preview-dialog').open) discardPreview(); - }); - showSection(activeSection); - vscode.postMessage({ type: 'ready' }); - })(); -`; diff --git a/vscode-extension/src/configuration-editor-script-render.ts b/vscode-extension/src/configuration-editor-script-render.ts deleted file mode 100644 index 4a1bd0525..000000000 --- a/vscode-extension/src/configuration-editor-script-render.ts +++ /dev/null @@ -1,388 +0,0 @@ -// Implements [CONFIGEDITOR-VSIX-EXPERIENCE] rendering from LSP-owned state. -/** Render fragment of the dependency-free webview runtime. */ - -export const CONFIGURATION_EDITOR_SCRIPT_RENDER = String.raw` - function entryOption(value, selected) { - const option = document.createElement('option'); - option.value = value; - option.textContent = value; - option.selected = value === selected; - return option; - } - function makeTagEntrySelect(tag) { - const label = document.createElement('label'); - label.append(textNode('span', 'Entry for tag ' + tag.name, 'sr-only')); - const select = document.createElement('select'); - select.className = 'severity-select'; - select.dataset.tagEntry = tag.name; - // No entry shows what no entry resolves to ([CHKARCH-CONFIG-MODEL]): - // pep-affecting tags run at error, everything else does not run. - const current = entryValue(tag.entry) === NO_ENTRY - ? (isPepTag(tag) ? 'Error' : 'Disabled') - : entryValue(tag.entry); - select.dataset.severity = current; - // Tag-entry control: error/warning/info — plus disabled only where the - // server would accept it (never on a tag that grades pep rules). - severityOptions(isPepTag(tag)).forEach((value) => select.append(entryOption(value, current))); - label.append(select); - return label; - } - function renderTags() { - const list = byId('tag-list'); - clear(list); - const groups = [ - { kind: 'Provenance', label: 'Sources' }, - { kind: 'PepCategory', label: 'PEP categories' }, - { kind: 'Descriptive', label: 'Policy tags' }, - ]; - groups.forEach((group) => { - const tags = snapshot.tags - .filter((tag) => kind(tag.kind, 'Descriptive') === group.kind) - .slice() - .sort((left, right) => left.name.localeCompare(right.name)); - if (tags.length === 0) return; - const section = document.createElement('section'); - section.className = 'tag-group'; - section.append(textNode('h3', group.label)); - tags.forEach((tag) => { - const row = document.createElement('div'); - row.className = 'tag-row'; - const button = document.createElement('button'); - button.type = 'button'; - button.className = 'tag-button'; - button.dataset.tag = tag.name; - button.setAttribute('aria-pressed', String(activeTag === tag.name)); - button.append(textNode('span', tag.name), textNode('small', formatNumber(tag.ruleCount) + ' · ' + formatNumber(tag.diagnosticCount))); - row.append(button, makeTagEntrySelect(tag)); - section.append(row); - }); - list.append(section); - }); - } - function makeRuleEntrySelect(rule) { - const label = document.createElement('label'); - label.append(textNode('span', 'Entry for ' + rule.descriptor.code, 'sr-only')); - const select = document.createElement('select'); - select.className = 'severity-select'; - select.dataset.ruleEntry = rule.descriptor.code; - select.dataset.severity = effectiveValue(rule); - // No entry shows the resolved severity — for an untouched analyze rule - // that IS Disabled ([CHKARCH-CONFIG-MODEL] resolution step 3). - const current = entryValue(rule.entry) === NO_ENTRY ? effectiveValue(rule) : entryValue(rule.entry); - // pep rows: error/warning/info — no Disabled control exists for them - // ([CHKARCH-CONFIG-MODEL]); analyze rows also offer Disabled. - severityOptions(isPepRule(rule)).forEach((value) => select.append(entryOption(value, current))); - label.append(select); - return label; - } - function makeRuleRow(rule, index) { - const descriptor = rule.descriptor; - const row = document.createElement('article'); - row.className = 'rule-row'; - row.setAttribute('role', 'listitem'); - row.dataset.ruleCode = descriptor.code; - row.setAttribute('aria-posinset', String(index + 1)); - row.setAttribute('aria-setsize', String(filteredRules.length)); - row.style.top = String(index * ROW_HEIGHT) + 'px'; - const copy = document.createElement('div'); - copy.className = 'rule-copy'; - const detail = document.createElement('button'); - detail.type = 'button'; - detail.dataset.showRule = descriptor.code; - detail.append(textNode('strong', descriptor.code), textNode('span', descriptor.title, 'title')); - const summary = textNode('p', descriptor.summary); - const chips = document.createElement('div'); - chips.className = 'chip-list'; - descriptor.tags.forEach((tag) => chips.append(textNode('span', tag, 'chip'))); - chips.append(textNode('span', 'effective ' + effectiveValue(rule) + ' · ' + formatNumber(rule.diagnosticCount) + ' issues', 'metrics')); - copy.append(detail, summary, chips); - row.append(copy, makeRuleEntrySelect(rule)); - return row; - } - function renderRuleWindow() { - const viewport = byId('rule-viewport'); - const spacer = byId('rule-spacer'); - const windowNode = byId('rule-window'); - if (!viewport || !spacer || !windowNode) return; - spacer.style.height = String(filteredRules.length * ROW_HEIGHT) + 'px'; - const visible = Math.ceil(viewport.clientHeight / ROW_HEIGHT); - const start = Math.max(0, Math.floor(viewport.scrollTop / ROW_HEIGHT) - OVERSCAN); - const end = Math.min(filteredRules.length, start + visible + OVERSCAN * 2); - clear(windowNode); - for (let index = start; index < end; index += 1) windowNode.append(makeRuleRow(filteredRules[index], index)); - restoreFocus(); - } - function renderRuleDetail() { - const rule = selectedRule(); - const empty = byId('detail-empty'); - const content = byId('detail-content'); - if (!rule) { empty.hidden = false; content.hidden = true; return; } - empty.hidden = true; - content.hidden = false; - clear(content); - const descriptor = rule.descriptor; - const heading = textNode('h3', descriptor.code + ' · ' + descriptor.title); - const summary = textNode('p', descriptor.summary); - const dl = document.createElement('dl'); - const facts = [ - ['Entry', entryValue(rule.entry) === 'None' ? 'No entry' : entryValue(rule.entry)], - ['Effective', effectiveValue(rule)], - ['Scope', isPepRule(rule) ? 'check · always runs' : 'analyze'], - ['Diagnostics', formatNumber(rule.diagnosticCount)], - ['Tags', descriptor.tags.join(', ')], - ]; - facts.forEach(([name, value]) => dl.append(textNode('dt', name), textNode('dd', value))); - const actions = document.createElement('div'); - actions.className = 'action-row'; - const docs = textNode('button', 'Open rule guide', 'secondary'); - docs.type = 'button'; - docs.dataset.openDocs = descriptor.docsUrl; - const find = textNode('button', 'Find occurrences', 'primary'); - find.type = 'button'; - find.dataset.findRule = descriptor.code; - actions.append(find, docs); - const occurrenceList = document.createElement('div'); - occurrenceList.id = 'occurrence-list'; - if (occurrences && occurrences.items.length > 0) { - occurrences.items.filter((item) => item.code === descriptor.code).forEach((item) => { - const button = textNode('button', compactUri(item.uri) + ':' + String(item.range.start.line + 1), 'occurrence'); - button.type = 'button'; - button.dataset.occurrenceUri = item.uri; - button.dataset.occurrenceLine = String(item.range.start.line); - button.dataset.occurrenceCharacter = String(item.range.start.character); - button.append(textNode('small', kind(item.severity, 'Error'))); - occurrenceList.append(button); - }); - } - if (editorState.occurrencesLoading) { - occurrenceList.append(textNode('p', 'Loading occurrences…', 'empty-state')); - } else if (occurrences && occurrences.nextCursor) { - const more = textNode('button', 'Load more occurrences', 'secondary'); - more.type = 'button'; - more.dataset.action = 'load-more-occurrences'; - occurrenceList.append(more); - } else if (!occurrences || occurrences.items.length === 0) { - occurrenceList.append(textNode('p', 'No loaded occurrences.', 'empty-state')); - } - content.append(heading, summary, dl, actions, occurrenceList); - } - function renderSource() { - byId('root-label').textContent = compactUri(snapshot.rootUri); - byId('source-label').textContent = compactUri(snapshot.configUri) + ' · revision ' + snapshot.revision; - } - // Configure Severity deep link ([CONFIGEDITOR-VSIX-EXPERIENCE]): focus - // the requested rule once — prefill the search filter with its code, - // scroll its row into the virtual window, and open its detail panel. - function consumeFocusRule() { - const code = editorState.focusRule; - if (focusRuleConsumed || !code || !snapshot) return; - if (!snapshot.rules.some((rule) => rule.descriptor.code === code)) return; - focusRuleConsumed = true; - byId('rule-search').value = code; - applyFilter(); - const index = filteredRules.findIndex((rule) => rule.descriptor.code === code); - if (index >= 0) byId('rule-viewport').scrollTop = index * ROW_HEIGHT; - renderRuleWindow(); - showRule(code); - announce('Focused rule ' + code); - } - // Overview/Adoption/Project/Path Overrides render exact server-computed - // snapshot state (snapshot.debt / .source / .problems / .pathOverrides) — - // never client arithmetic dressed up as a score ([CONFIGEDITOR-VSIX-EXPERIENCE]). - function renderSeverityStrip() { - const strip = byId('severity-strip'); - clear(strip); - const debt = snapshot.debt; - [['Error', debt.errorDiagnostics], ['Warning', debt.warningDiagnostics], ['Info', debt.infoDiagnostics], ['Total', debt.remainingDiagnostics]] - .forEach(([label, value]) => { - const cell = document.createElement('div'); - cell.append(textNode('strong', formatNumber(value)), textNode('span', label)); - strip.append(cell); - }); - } - function renderOverview() { - renderSeverityStrip(); - byId('overview-diagnostics').textContent = formatNumber(snapshot.debt.remainingDiagnostics); - byId('overview-adopted').textContent = formatNumber(snapshot.debt.adoptedRules); - byId('overview-disabled').textContent = formatNumber(snapshot.debt.disabledRules); - } - function renderAdoption() { - const openRules = snapshot.rules.filter((rule) => rule.diagnosticCount > 0).length; - byId('adoption-open-rules').textContent = formatNumber(openRules); - byId('adoption-open-diagnostics').textContent = formatNumber(snapshot.debt.remainingDiagnostics); - } - function pathSettingRow(label, severity) { - const item = document.createElement('li'); - item.append(textNode('code', label), textNode('span', kind(severity, 'Error'))); - return item; - } - function renderPaths() { - const list = byId('path-override-list'); - clear(list); - const overrides = snapshot.pathOverrides || []; - if (overrides.length === 0) { - list.append(textNode('p', 'No path overrides. Project policy applies everywhere. Add a nested pyproject.toml [tool.basilisk] table to scope rules to a subtree.', 'empty-state')); - return; - } - overrides.forEach((entry) => { - const card = document.createElement('article'); - card.className = 'path-override-card'; - const header = document.createElement('div'); - header.className = 'path-override-head'; - header.append(textNode('h3', entry.path || '.')); - const open = textNode('button', 'Open configuration file', 'secondary'); - open.type = 'button'; - open.dataset.openConfig = entry.configUri; - header.append(open); - card.append(header); - const rows = document.createElement('ul'); - entry.rules.forEach((rule) => rows.append(pathSettingRow(rule.code, rule.severity))); - entry.tags.forEach((tag) => rows.append(pathSettingRow('tag:' + tag.tag, tag.severity))); - card.append(rows); - list.append(card); - }); - } - function renderProject() { - const dl = byId('source-details'); - clear(dl); - const source = snapshot.source; - const facts = [ - ['Root', compactUri(snapshot.rootUri)], - ['Source', compactUri(source.uri)], - ['Revision', snapshot.revision], - ['On disk', source.exists ? 'Yes' : 'Created on first change'], - ['Writable', source.readOnly ? 'Read-only' : 'Writable'], - ]; - facts.forEach(([name, value]) => dl.append(textNode('dt', name), textNode('dd', value))); - renderTypeshedControls(); - renderCacheControls(); - const problemList = byId('problem-list'); - clear(problemList); - const problems = snapshot.problems || []; - if (problems.length === 0) { - problemList.className = 'empty-state'; - problemList.textContent = 'No configuration problems.'; - return; - } - problemList.className = ''; - problems.forEach((problem) => { - const item = document.createElement('p'); - item.className = 'problem-row'; - item.append(textNode('strong', problem.code), document.createTextNode(' ' + problem.message)); - problemList.append(item); - }); - } - function renderSnapshot() { - if (!snapshot) return; - saveFocus(); - renderSource(); - renderOverview(); - renderAdoption(); - renderPaths(); - renderProject(); - renderTags(); - applyFilter(); - consumeFocusRule(); - renderRuleDetail(); - window.requestAnimationFrame(restoreFocus); - } - function impactCell(before, after, label) { - const cell = document.createElement('div'); - cell.append( - textNode('strong', formatNumber(before) + ' → ' + formatNumber(after)), - textNode('span', label), - ); - return cell; - } - function renderPreview() { - if (!preview) return; - const impact = preview.impact; - const grid = byId('impact-grid'); - clear(grid); - // Complete before/after partition by the three emitting severities. - grid.append( - impactCell(impact.errorsBefore, impact.errorsAfter, 'errors'), - impactCell(impact.warningsBefore, impact.warningsAfter, 'warnings'), - impactCell(impact.infosBefore, impact.infosAfter, 'infos'), - ); - const changes = byId('preview-changes'); - clear(changes); - preview.changes.forEach((change) => { - const row = document.createElement('div'); - row.className = 'preview-change'; - row.append( - textNode('code', change.code), - textNode('strong', kind(change.before, 'Error') + ' → ' + kind(change.after, 'Error')), - ); - changes.append(row); - }); - preview.typeshedChanges.forEach((change) => { - const row = document.createElement('div'); - row.className = 'preview-change'; - const before = change.before && Object.prototype.hasOwnProperty.call(change.before, 'value') - ? String(change.before.value) - : 'default'; - const after = change.after && Object.prototype.hasOwnProperty.call(change.after, 'value') - ? String(change.after.value) - : 'default'; - row.append(textNode('code', kind(change.key, 'Typeshed')), textNode('strong', before + ' → ' + after)); - changes.append(row); - }); - (preview.cacheChanges || []).forEach((change) => { - const row = document.createElement('div'); - row.className = 'preview-change'; - row.append( - textNode('code', kind(change.key, 'Cache')), - textNode('strong', (change.before || 'default') + ' → ' + (change.after || 'default')), - ); - changes.append(row); - }); - if (preview.changes.length === 0 && preview.typeshedChanges.length === 0 - && (preview.cacheChanges || []).length === 0) { - changes.append(textNode('p', 'No effective configuration changes.', 'empty-state')); - } - const dialog = byId('preview-dialog'); - if (!dialog.open) dialog.showModal(); - const changed = preview.changes.length + preview.typeshedChanges.length - + (preview.cacheChanges || []).length; - announce('Preview ready: ' + formatNumber(changed) + ' setting(s) change'); - } - // 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', error: 'Configuration unavailable', - conflict: 'The project changed', unsupported: 'Update Basilisk to continue', idle: 'Connecting to Basilisk', - }; - 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' }; - snapshot = editorState.snapshot; - preview = editorState.preview; - occurrences = editorState.occurrences; - const status = byId('status-pill'); - status.dataset.phase = editorState.phase; - status.textContent = editorState.message || editorState.phase; - if (snapshot) renderSnapshot(); - renderNotice(); - // Each preview state re-arms the discard report: this preview has not - // been cancelled yet, whatever happened to the previous one. - if (editorState.phase === 'preview') previewCancelReported = false; - if (preview && editorState.phase === 'preview') renderPreview(); - const dialog = byId('preview-dialog'); - if (editorState.phase !== 'preview' && dialog.open) dialog.close(); - announce(editorState.message || editorState.phase); - } -`; diff --git a/vscode-extension/src/configuration-editor-script-typeshed.ts b/vscode-extension/src/configuration-editor-script-typeshed.ts deleted file mode 100644 index 671b17453..000000000 --- a/vscode-extension/src/configuration-editor-script-typeshed.ts +++ /dev/null @@ -1,368 +0,0 @@ -// 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 and no fourth ([LSPCFGED-TYPESHED], - // [STUBRES-TYPESHED-PYPI]). 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: 'ExactCommit', - label: 'Pinned commit', - description: 'Freeze one python/typeshed commit so every machine resolves the identical standard library.', - }, - { - mode: 'CustomFolder', - label: 'Custom folder', - description: 'Use a stdlib tree you manage yourself. Nothing is downloaded.', - }, - { - mode: 'PyPIPackage', - label: 'PyPI package', - description: 'Pin a stdlib-stubs wheel by SHA-256. Reproducible across machines once downloaded.', - }, - ]; - const COMMIT_PATTERN = /^[0-9a-f]{40}$/i; - let advancedOpen = false; - // 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; - // A package pin is the ONE source with no value the editor can supply for - // the user: a commit falls back to the bundled SHA and a folder comes from - // the picker, but a wheel digest can only be typed. The server describes - // sources by their VALUE, so until a pin exists the snapshot cannot report - // 'PyPIPackage' — and without this, choosing it would render the pinned - // source straight back and the input to type into would never appear. - // Presentation only, exactly like 'advancedOpen': it selects which empty - // field to show and never stands in for server state - // ([LSPCFGED-TYPESHED], [STUBRES-TYPESHED-PYPI]). - let pendingPackageEntry = false; - - function typeshedState() { return snapshot.typeshed; } - function serverSourceMode() { return kind(typeshedState().source, 'ExactCommit'); } - function typeshedSourceMode() { - // A pin the server already knows about always wins: the moment a real - // package source lands, the pending flag is spent. - if (serverSourceMode() === 'PyPIPackage') { pendingPackageEntry = false; return 'PyPIPackage'; } - return pendingPackageEntry ? 'PyPIPackage' : serverSourceMode(); - } - function typeshedLifecycle() { return kind(typeshedState().status.lifecycle, 'Ready'); } - function typeshedDownloading() { return typeshedLifecycle() === 'Downloading'; } - function shortCommit(commit) { return commit ? commit.slice(0, 12) : ''; } - // 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 commit = status.commitIdentity; - return [ - ['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); - const summary = document.createElement('dl'); - statusRows().forEach((row) => summary.append(textNode('dt', row[0]), textNode('dd', row[1]))); - target.append(summary); - typeshedState().status.warnings.forEach((warning) => { - const row = textNode('p', warning.message, 'typeshed-warning'); - 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'; - const input = document.createElement('input'); - input.type = 'radio'; - input.name = 'typeshed-source'; - input.value = choice.mode; - input.dataset.typeshedSource = choice.mode; - input.checked = typeshedSourceMode() === choice.mode; - label.append(input, textNode('span', choice.label), textNode('small', choice.description)); - return label; - } - function renderSourceChoices(target) { - const group = document.createElement('fieldset'); - group.className = 'typeshed-source'; - const legend = document.createElement('legend'); - legend.textContent = 'Source'; - group.append(legend); - SOURCE_CHOICES.forEach((choice) => group.append(sourceChoice(choice))); - target.append(group); - } - // The active source's own value: a commit SHA, a folder, or a package - // pin. No other source's field exists in the DOM ([LSPCFGED-TYPESHED]). - function renderSourceValue(target) { - const source = typeshedState().source; - // typeshedSourceMode(), not the raw snapshot: while a package pin is - // being entered there is no server-side package source yet, and the - // empty field is the only way to create one. - const mode = typeshedSourceMode(); - if (mode === 'CustomFolder') { target.append(folderField('TypeshedPath', 'Folder', source.path)); return; } - if (mode === 'PyPIPackage') { target.append(packageField(source.name, source.sha256)); return; } - target.append(commitField(source.commit)); - } - function commitField(commit) { - const field = document.createElement('label'); - field.className = 'typeshed-field'; - field.append(textNode('span', 'Commit'), textNode('small', 'Full 40-character python/typeshed commit SHA.')); - const input = document.createElement('input'); - input.type = 'text'; - input.value = commit || ''; - input.dataset.typeshedCommit = 'TypeshedCommit'; - input.autocomplete = 'off'; - input.spellcheck = false; - const error = textNode('small', '', 'field-error'); - error.id = 'typeshed-commit-error'; - error.hidden = true; - field.append(input, error); - return field; - } - function folderField(key, label, value) { - const field = document.createElement('label'); - field.className = 'typeshed-field'; - field.append(textNode('span', label)); - const picker = document.createElement('div'); - picker.className = 'path-picker'; - const input = document.createElement('input'); - input.type = 'text'; - input.readOnly = true; - input.value = value || ''; - input.placeholder = 'Not configured'; - input.dataset.typeshedPath = key; - const choose = textNode('button', value ? 'Change…' : 'Choose folder…', 'secondary'); - choose.type = 'button'; - choose.dataset.pickTypeshedFolder = key; - picker.append(input, choose); - field.append(picker); - return field; - } - // A PyPI package pin is the literal 'name@sha256:<64-hex>' spec the - // runtime stores; the field edits it as one string so the server's single - // parser ([STUBRES-TYPESHED-PYPI]) validates it. - function packageField(name, sha256) { - const field = document.createElement('label'); - field.className = 'typeshed-field'; - field.append(textNode('span', 'Package'), textNode('small', 'name@sha256:<64-hex> wheel digest.')); - const input = document.createElement('input'); - input.type = 'text'; - const value = name && sha256 ? name + '@sha256:' + sha256 : ''; - input.value = value; - input.placeholder = 'micropython-stdlib-stubs@sha256:<64-hex>'; - input.dataset.typeshedPackage = 'TypeshedPackage'; - input.autocomplete = 'off'; - input.spellcheck = false; - const error = textNode('small', '', 'field-error'); - error.id = 'typeshed-package-error'; - error.hidden = true; - field.append(input, error); - return field; - } - // A custom folder downloads nothing and has no store folder; a commit pin - // and a PyPI package both resolve from the store, so the Advanced - // disclosure exists for either ([LSPCFGED-TYPESHED], [STUBRES-TYPESHED-STORE]). - function renderStore(target) { - if (typeshedSourceMode() === 'CustomFolder') return; - const details = document.createElement('details'); - details.className = 'typeshed-advanced'; - // Every write re-renders from the fresh snapshot, so the disclosure has - // to remember itself or it would snap shut under the user's hands. - details.open = advancedOpen; - details.addEventListener('toggle', () => { advancedOpen = details.open; }); - const summary = document.createElement('summary'); - summary.textContent = 'Advanced'; - details.append(summary); - details.append(folderField('TypeshedStorePath', 'Store folder', typeshedState().storeFolder)); - target.append(details); - } - function typeshedActionButton(action, label, enabled) { - const button = textNode('button', label, 'secondary'); - button.type = 'button'; - button.disabled = !enabled; - 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.typeshedPackage) return '[data-typeshed-package]'; - 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); - renderStore(controls); - const actions = byId('typeshed-actions'); - clear(actions); - actions.append( - downloadButton('DownloadLatest', 'Download latest'), - typeshedActionButton('ViewLicense', 'View license', typeshedState().licenseAvailable), - ); - restoreTypeshedFocus(focus); - } - // The three sources are mutually exclusive, and exclusivity is enforced by - // the write that SETS a source clearing the other two keys in the same - // atomic mutation — never by a speculative pre-clear - // ([LSPCFGED-TYPESHED], [STUBRES-TYPESHED-PYPI]). Nothing locks while the - // mutation round-trips — every control re-renders from the snapshot. - function chooseTypeshedSource(mode) { - if (mode === typeshedSourceMode()) return; - if (mode === 'CustomFolder') { - // No pre-clear: the folder picker is cancellable, so clearing the - // competing keys up front would destroy the user's pin even when they - // back out. The host's pickTypeshedFolder writes the folder and clears - // the other two source keys in ONE atomic mutation, and posts nothing - // at all when cancelled ([LSPCFGED-TYPESHED]). - pendingPackageEntry = false; - vscode.postMessage({ type: 'pickTypeshedFolder', key: 'TypeshedPath' }); - announce('Switching to a custom folder'); - return; - } - if (mode === 'PyPIPackage') { - // Same reasoning as the folder picker, and for the same reason it is - // not a pre-clear: a pin does not exist until it is typed, so this only - // reveals the empty field. 'packageEdited' performs the exclusive write - // once the pin is valid; abandoning the field leaves the configuration - // exactly as it was. - pendingPackageEntry = true; - renderTypeshedControls(); - announce('Switching to a PyPI package pin'); - return; - } - // The pinned commit is the one source with a value the editor can always - // supply — an unset pin IS the bundled commit — so it is selectable on - // its own, and selecting it drops the two sources that would outrank it. - pendingPackageEntry = false; - postPreview([typeshedRemove('TypeshedPath'), typeshedRemove('TypeshedPackage')]); - announce('Using the pinned standard-library commit'); - } - // An invalid SHA is never sent: it is rejected in place, where the user - // can see why, and the configuration is left untouched. Setting a commit - // atomically clears the folder and package pins. - function commitEdited(input) { - const value = input.value.trim(); - const error = byId('typeshed-commit-error'); - if (value !== '' && !COMMIT_PATTERN.test(value)) { - error.textContent = 'Enter the full 40-character commit SHA (0-9, a-f).'; - error.hidden = false; - input.setAttribute('aria-invalid', 'true'); - announce('Invalid commit SHA'); - return; - } - error.hidden = true; - input.removeAttribute('aria-invalid'); - postPreview(value === '' - ? [typeshedRemove('TypeshedCommit')] - : [typeshedSetText('TypeshedCommit', value), typeshedRemove('TypeshedPath'), typeshedRemove('TypeshedPackage')]); - } - // An invalid package pin is rejected in place, mirroring 'commitEdited'. - // Setting a package atomically clears the commit and folder pins. - // The name half is the PEP 508 grammar the server's single parser enforces - // ([STUBRES-TYPESHED-PYPI]) — alphanumeric at both ends, '.', '_' or '-' - // between. Matching it here means the field explains the problem in place - // instead of shipping a value the server will only bounce back; the server - // remains the authority, this is presentation. - const PACKAGE_PATTERN = /^[A-Za-z0-9]([A-Za-z0-9._-]*[A-Za-z0-9])?@sha256:[0-9a-f]{64}$/i; - function packageEdited(input) { - // Typing in this field keeps the user in it. Without this, emptying the - // box would drop the package source and snap the editor back to the - // pinned commit mid-edit. - pendingPackageEntry = true; - const value = input.value.trim(); - const error = byId('typeshed-package-error'); - if (value !== '' && !PACKAGE_PATTERN.test(value)) { - error.textContent = 'Enter name@sha256:<64-hex>. Name: letters, digits, . _ - (letter or digit at each end).'; - error.hidden = false; - input.setAttribute('aria-invalid', 'true'); - announce('Invalid package pin'); - return; - } - error.hidden = true; - input.removeAttribute('aria-invalid'); - postPreview(value === '' - ? [typeshedRemove('TypeshedPackage')] - : [typeshedSetText('TypeshedPackage', value), typeshedRemove('TypeshedCommit'), typeshedRemove('TypeshedPath')]); - } - /** Every Typeshed control change, routed from the one delegated listener. */ - function typeshedChanged(target) { - if (target.dataset.typeshedSource) { chooseTypeshedSource(target.value); return true; } - if (target.dataset.typeshedCommit) { commitEdited(target); return true; } - if (target.dataset.typeshedPackage) { packageEdited(target); return true; } - return false; - } -`; diff --git a/vscode-extension/src/configuration-editor-script.ts b/vscode-extension/src/configuration-editor-script.ts deleted file mode 100644 index 38cf3949f..000000000 --- a/vscode-extension/src/configuration-editor-script.ts +++ /dev/null @@ -1,16 +0,0 @@ -// Implements [VSIX-CONFIGURATION-EDITOR-HOST]. -/** Complete static webview runtime assembled from focused fragments. */ - -import { CONFIGURATION_EDITOR_SCRIPT_CACHE } from "./configuration-editor-script-cache"; -import { CONFIGURATION_EDITOR_SCRIPT_CORE } from "./configuration-editor-script-core"; -import { CONFIGURATION_EDITOR_SCRIPT_EVENTS } from "./configuration-editor-script-events"; -import { CONFIGURATION_EDITOR_SCRIPT_RENDER } from "./configuration-editor-script-render"; -import { CONFIGURATION_EDITOR_SCRIPT_TYPESHED } from "./configuration-editor-script-typeshed"; - -export const CONFIGURATION_EDITOR_SCRIPT = [ - CONFIGURATION_EDITOR_SCRIPT_CORE, - CONFIGURATION_EDITOR_SCRIPT_RENDER, - CONFIGURATION_EDITOR_SCRIPT_TYPESHED, - CONFIGURATION_EDITOR_SCRIPT_CACHE, - CONFIGURATION_EDITOR_SCRIPT_EVENTS, -].join("\n"); diff --git a/vscode-extension/src/configuration-editor-settings.ts b/vscode-extension/src/configuration-editor-settings.ts deleted file mode 100644 index 808735f1b..000000000 --- a/vscode-extension/src/configuration-editor-settings.ts +++ /dev/null @@ -1,88 +0,0 @@ -// Implements [LSPCFGED-TYPESHED] / [LSPCFGED-CACHE] native folder pickers and -// the direct-write rule the Typeshed and Caching panels share. - -import * as vscode from "vscode"; -import type { ConfigurationSnapshot } from "./configuration-editor-model"; -import type { ConfigurationEditorIntent } from "./configuration-editor-intents"; - -type PreviewIntent = Extract; - -/** - * Every directory-typed setting in the editor renders with a native - * folder-picker rather than free text. A cancelled picker writes nothing at - * all, so the controls stay on the configuration that still holds. - */ -async function pickFolder( - snapshot: ConfigurationSnapshot, - openLabel: string, - title: string, -): Promise { - const selected = await vscode.window.showOpenDialog({ - canSelectFiles: false, canSelectFolders: true, canSelectMany: false, - defaultUri: vscode.Uri.parse(snapshot.rootUri, true), - openLabel, - title, - }); - return selected?.[0]; -} - -/** - * Choosing the Typeshed source folder is one atomic transition — it also - * clears the commit pin AND the package pin, so no combination of source - * values can be written ([LSPCFGED-TYPESHED], [STUBRES-TYPESHED-PYPI]). - * Cancelling the picker returns `undefined` and writes nothing, so the - * current source survives a backed-out switch. - */ -export async function pickTypeshedFolder( - snapshot: ConfigurationSnapshot, - key: "TypeshedPath" | "TypeshedStorePath", -): Promise { - const isSource = key === "TypeshedPath"; - const folder = await pickFolder( - snapshot, - isSource ? "Use Typeshed folder" : "Use store folder", - isSource ? "Choose a Typeshed tree containing stdlib/" : "Choose the Typeshed store folder", - ); - if (folder === undefined) { return undefined; } - const mutations: PreviewIntent["mutations"] = [{ - kind: "SetTypeshedSetting", key: { kind: key }, value: folder.fsPath, - }]; - if (isSource) { - mutations.push({ kind: "RemoveTypeshedSetting", key: { kind: "TypeshedCommit" } }); - mutations.push({ kind: "RemoveTypeshedSetting", key: { kind: "TypeshedPackage" } }); - } - return { type: "preview", mutations }; -} - -/** - * The persistent result cache's folder ([LSPCFGED-CACHE]). Relocating it is a - * single key write; it does not imply enabling the cache, because where - * entries would live and whether they are written are separate decisions. - */ -export async function pickCacheFolder( - snapshot: ConfigurationSnapshot, -): Promise { - const folder = await pickFolder( - snapshot, - "Use cache folder", - "Choose where Basilisk stores cached check results", - ); - return folder === undefined - ? undefined - : { - type: "preview", - mutations: [{ kind: "SetCacheSetting", key: { kind: "CacheDir" }, value: folder.fsPath }], - }; -} - -/** - * A Typeshed or cache edit is a direct setting switch, not a rule-severity - * trade-off: there is no impact to weigh, so it applies as soon as it is made - * ([LSPCFGED-TYPESHED], [LSPCFGED-CACHE]). A control that needed a second - * confirmation could show a value the configuration does not hold. - */ -export function isDirectSettingOnly(intent: PreviewIntent): boolean { - return intent.mutations.every((mutation) => - mutation.kind === "SetTypeshedSetting" || mutation.kind === "RemoveTypeshedSetting" - || mutation.kind === "SetCacheSetting" || mutation.kind === "RemoveCacheSetting"); -} diff --git a/vscode-extension/src/configuration-editor-state.ts b/vscode-extension/src/configuration-editor-state.ts deleted file mode 100644 index e669d2c68..000000000 --- a/vscode-extension/src/configuration-editor-state.ts +++ /dev/null @@ -1,305 +0,0 @@ -// Implements [VSIX-CONFIGURATION-EDITOR-THIN-SHELL]. -/** Central immutable state and explicit actions for the configuration editor. */ - -import { asRecord, isRecord, stringField } from "./unknown-shape"; -import type { ReadonlySignal, Signal } from "@preact/signals-core"; -import type { - ConfigurationChanged, - ConfigurationPreview, - ConfigurationSnapshot, - RuleOccurrencesResponse, - TypeshedStatusChanged, - TypeshedStatusState, -} from "./configuration-editor-model"; - -export type ConfigurationEditorPhase = - | "idle" - | "loading" - | "ready" - | "previewing" - | "preview" - | "applying" - | "error" - | "conflict" - | "unsupported"; - -export interface ConfigurationEditorState { - readonly phase: ConfigurationEditorPhase; - readonly rootUri: string | undefined; - readonly snapshot: ConfigurationSnapshot | undefined; - readonly preview: ConfigurationPreview | undefined; - readonly occurrences: RuleOccurrencesResponse | undefined; - readonly occurrencesLoading: boolean; - readonly repairUri: string | undefined; - readonly message: string; - readonly refreshRequested: boolean; - /** - * Rule code the webview should focus once per webview lifetime — set when - * the editor is opened from a diagnostic's Configure Severity hover link - * ([CONFIGEDITOR-VSIX-EXPERIENCE]). - */ - readonly focusRule: string | undefined; -} - -export const IDLE_CONFIGURATION_EDITOR: ConfigurationEditorState = { - phase: "idle", - rootUri: undefined, - snapshot: undefined, - preview: undefined, - occurrences: undefined, - occurrencesLoading: false, - repairUri: undefined, - message: "", - refreshRequested: false, - focusRule: undefined, -}; - -export interface ConfigurationEditorActions { - /** - * `focusRule` semantics: a string sets the pending focus target, `null` - * clears it (a plain open with no target), and `undefined` (internal - * refreshes) preserves any pending same-root focus. - */ - beginConfigurationLoad(rootUri: string, focusRule?: string | null): void; - acceptConfigurationSnapshot(snapshot: ConfigurationSnapshot): void; - beginConfigurationPreview(): void; - acceptConfigurationPreview(preview: ConfigurationPreview): void; - /** Drop an unapplied preview and return to the snapshot as it stands. */ - cancelConfigurationPreview(): void; - beginConfigurationApply(): void; - beginRuleOccurrences(reset: boolean): void; - acceptRuleOccurrences(response: RuleOccurrencesResponse, append: boolean): void; - failRuleOccurrences(message: string): void; - failConfigurationEditor(message: string, conflict?: boolean, repairUri?: string): void; - markConfigurationChanged(change: ConfigurationChanged): void; - markConfigurationUnsupported(message: string): void; - resetConfigurationEditor(): void; -} - -export interface ConfigurationEditorStore extends ConfigurationEditorActions { - readonly configurationEditor: ReadonlySignal; -} - -/** Validate the small server-pushed invalidation before it touches shared state. */ -export function decodeConfigurationChanged(value: unknown): ConfigurationChanged | undefined { - const rootUri = stringField(value, "rootUri"); - const revision = stringField(value, "revision"); - return rootUri !== undefined && revision !== undefined ? { rootUri, revision } : undefined; -} - -function hasKind(value: unknown, allowed: readonly string[]): boolean { - const kind = stringField(value, "kind"); - return kind !== undefined && allowed.includes(kind); -} - -function hasOptionalKind(value: unknown, allowed: readonly string[]): boolean { - return value === undefined || hasKind(value, allowed); -} - -function isOptionalString(value: unknown): boolean { - return value === undefined || typeof value === "string"; -} - -function isTypeshedWarning(value: unknown): boolean { - if (!isRecord(value)) { return false; } - return stringField(value, "code") !== undefined && stringField(value, "message") !== undefined - && hasKind(value.severity, ["Advisory", "High"]); -} - -function hasTypeshedStateKinds(fields: Record): boolean { - return hasKind(fields.lifecycle, ["Downloading", "Ready", "NoSource"]) - && hasKind(fields.licenseStatus, ["Unavailable", "Approved", "Changed", "NotSupplied"]) - && hasOptionalKind(fields.activeSource, ["Custom", "ExactCommit", "Bundled", "PyPIPackage"]); -} - -function hasTypeshedIdentityFields(fields: Record): boolean { - return isOptionalString(fields.noSourceReason) - && isOptionalString(fields.commitIdentity); -} - -// A type predicate, not a boolean check: every field the wire type declares is -// verified below, so callers get the narrowed type without an `as` that the -// compiler would have to take on faith. -function isTypeshedStatus(value: unknown): value is TypeshedStatusState { - if (!isRecord(value)) { return false; } - const fields = value; - return hasTypeshedStateKinds(fields) - && hasTypeshedIdentityFields(fields) - && Array.isArray(fields.warnings) - && fields.warnings.every(isTypeshedWarning); -} - -/** Validate the typed Typeshed lifecycle notification before using it as an invalidation. */ -export function decodeTypeshedStatusChanged(value: unknown): TypeshedStatusChanged | undefined { - const rootUri = stringField(value, "rootUri"); - const status = asRecord(value).status; - if (rootUri === undefined || !isTypeshedStatus(status)) { - return undefined; - } - // `isTypeshedStatus` has just verified every field the wire type declares, - // so the narrowed shape is rebuilt rather than re-asserted. - return { rootUri, status }; -} - -/** Mark an open root stale without replacing the snapshot beneath an active preview. */ -export function requestConfigurationRefresh( - state: Signal, - change: ConfigurationChanged, -): void { - if (state.value.rootUri !== change.rootUri) { return; } - if (state.value.snapshot?.revision === change.revision) { return; } - state.value = { - ...state.value, - message: "The project configuration changed; refreshing…", - refreshRequested: true, - }; -} - -/** A status generation can change while the TOML revision remains identical. */ -export function requestTypeshedStatusRefresh( - state: Signal, - change: TypeshedStatusChanged, -): void { - if (state.value.rootUri !== change.rootUri) { return; } - state.value = { - ...state.value, - message: "Typeshed source status changed; refreshing…", - refreshRequested: true, - }; -} - -function beginLoad( - state: Signal, - rootUri: string, - focusRule?: string | null, -): void { - const sameRoot = state.value.rootUri === rootUri; - // string = set, null = clear (plain open), undefined = internal refresh — - // keep any pending same-root focus so it survives open()'s load chain. - const nextFocus = focusRule === undefined - ? (sameRoot ? state.value.focusRule : undefined) - : (focusRule ?? undefined); - state.value = { - ...state.value, - phase: "loading", - rootUri, - snapshot: sameRoot ? state.value.snapshot : undefined, - preview: undefined, - occurrences: sameRoot ? state.value.occurrences : undefined, - occurrencesLoading: false, - repairUri: undefined, - message: "Reading the active project policy…", - refreshRequested: false, - focusRule: nextFocus, - }; -} - -function acceptSnapshot(state: Signal, snapshot: ConfigurationSnapshot): void { - state.value = { - ...state.value, - phase: "ready", - rootUri: snapshot.rootUri, - snapshot, - preview: undefined, - occurrences: undefined, - occurrencesLoading: false, - repairUri: undefined, - message: "Configuration is up to date", - refreshRequested: false, - }; -} - -function beginPreview(state: Signal): void { - state.value = { - ...state.value, - phase: "previewing", - preview: undefined, - message: "Calculating exact workspace impact…", - }; -} - -function acceptPreview(state: Signal, preview: ConfigurationPreview): void { - state.value = { - ...state.value, - phase: "preview", - preview, - message: `Preview ready: ${preview.changes.length} rule(s) change effective severity`, - }; -} - -/** - * Nothing was written, so the snapshot still describes the truth: return to it - * and let every control re-render from it ([CONFIGEDITOR-VSIX-EXPERIENCE]). - */ -function cancelPreview(state: Signal): void { - if (state.value.snapshot === undefined) { return; } - if (state.value.phase !== "preview" && state.value.phase !== "previewing") { return; } - state.value = { - ...state.value, - phase: "ready", - preview: undefined, - message: "Change discarded; configuration is unchanged", - }; -} - -function failEditor( - state: Signal, - failure: { readonly message: string; readonly conflict: boolean; readonly repairUri: string | undefined }, -): void { - state.value = { - ...state.value, - phase: failure.conflict ? "conflict" : "error", - occurrencesLoading: false, - repairUri: failure.repairUri, - message: failure.message, - }; -} - -/** Build copy-on-write actions over the store's one configuration-editor Signal. */ -export function createConfigurationEditorActions( - state: Signal, -): ConfigurationEditorActions { - return { - beginConfigurationLoad(rootUri, focusRule): void { beginLoad(state, rootUri, focusRule); }, - acceptConfigurationSnapshot(snapshot): void { acceptSnapshot(state, snapshot); }, - beginConfigurationPreview(): void { beginPreview(state); }, - acceptConfigurationPreview(preview): void { acceptPreview(state, preview); }, - cancelConfigurationPreview(): void { cancelPreview(state); }, - beginConfigurationApply(): void { - state.value = { ...state.value, phase: "applying", message: "Applying one validated workspace edit…" }; - }, - beginRuleOccurrences(reset): void { - state.value = { - ...state.value, - occurrences: reset ? undefined : state.value.occurrences, - occurrencesLoading: true, - }; - }, - acceptRuleOccurrences(response, append): void { - const items = append - ? [...(state.value.occurrences?.items ?? []), ...response.items] - : response.items; - state.value = { - ...state.value, - occurrences: { items, nextCursor: response.nextCursor }, - occurrencesLoading: false, - }; - }, - failRuleOccurrences(message): void { - state.value = { ...state.value, occurrencesLoading: false, message }; - }, - failConfigurationEditor(message, conflict = false, repairUri): void { - failEditor(state, { message, conflict, repairUri }); - }, - markConfigurationChanged(change): void { requestConfigurationRefresh(state, change); }, - markConfigurationUnsupported(message): void { - state.value = { - ...IDLE_CONFIGURATION_EDITOR, - phase: "unsupported", - rootUri: state.value.rootUri, - message, - }; - }, - resetConfigurationEditor(): void { state.value = IDLE_CONFIGURATION_EDITOR; }, - }; -} diff --git a/vscode-extension/src/configuration-editor-styles.ts b/vscode-extension/src/configuration-editor-styles.ts deleted file mode 100644 index 1be52a6d6..000000000 --- a/vscode-extension/src/configuration-editor-styles.ts +++ /dev/null @@ -1,415 +0,0 @@ -// Implements [VSIX-CONFIGURATION-EDITOR-HOST]. -/** Theme-native visual system for the configuration editor webview. */ - -export const CONFIGURATION_EDITOR_STYLES = ` - :root { - color-scheme: light dark; - --bsk-orange: #e65305; - --bsk-orange-soft: color-mix(in srgb, var(--bsk-orange) 14%, transparent); - --bsk-sky: #3aa3d3; - --bsk-sky-soft: color-mix(in srgb, var(--bsk-sky) 14%, transparent); - --bsk-teal: #12857a; - --bg: var(--vscode-editor-background); - --surface: var(--vscode-sideBar-background, var(--vscode-editor-background)); - --surface-raised: var(--vscode-editorWidget-background, var(--surface)); - --border: var(--vscode-panel-border, var(--vscode-widget-border, transparent)); - --text: var(--vscode-editor-foreground); - --muted: var(--vscode-descriptionForeground); - --focus: var(--vscode-focusBorder); - --error: var(--vscode-errorForeground); - --warning: var(--vscode-editorWarning-foreground, #cca700); - --info: var(--vscode-editorInfo-foreground, var(--bsk-sky)); - --disabled: var(--vscode-disabledForeground); - --radius: 10px; - --rule-height: 112px; - } - - * { box-sizing: border-box; } - html, body { height: 100%; } - body { - margin: 0; - display: flex; - flex-direction: column; - overflow: hidden; - background: var(--bg); - color: var(--text); - font: var(--vscode-font-size) / 1.45 var(--vscode-font-family); - } - button, input, select { font: inherit; } - button, select, input[type="search"] { - border: 1px solid var(--border); - border-radius: 6px; - } - button { cursor: pointer; } - button:focus-visible, input:focus-visible, select:focus-visible, [tabindex]:focus-visible { - outline: 2px solid var(--focus); - outline-offset: 2px; - } - button:disabled, select:disabled { cursor: not-allowed; opacity: .55; } - .sr-only { - position: absolute; - width: 1px; - height: 1px; - padding: 0; - margin: -1px; - overflow: hidden; - clip: rect(0, 0, 0, 0); - white-space: nowrap; - border: 0; - } - #skip-link { - position: fixed; - z-index: 20; - top: 8px; - left: 8px; - padding: 7px 10px; - transform: translateY(-150%); - background: var(--vscode-button-background); - color: var(--vscode-button-foreground); - } - #skip-link:focus { transform: translateY(0); } - - body > header { - position: sticky; - z-index: 8; - top: 0; - min-height: 74px; - display: grid; - grid-template-columns: minmax(250px, 1fr) minmax(180px, auto) auto; - align-items: center; - gap: 18px; - padding: 12px 20px; - background: color-mix(in srgb, var(--bg) 94%, transparent); - border-bottom: 1px solid var(--border); - backdrop-filter: blur(14px); - } - #identity { display: flex; align-items: center; min-width: 0; gap: 12px; } - #mark { - width: 38px; - height: 38px; - flex: 0 0 auto; - display: grid; - place-items: center; - border: 1px solid color-mix(in srgb, var(--bsk-orange) 55%, var(--border)); - border-radius: 12px 5px 12px 5px; - background: linear-gradient(145deg, var(--bsk-orange-soft), var(--bsk-sky-soft)); - color: var(--bsk-orange); - font-weight: 800; - letter-spacing: -.08em; - } - h1 { margin: 0; font-size: 17px; line-height: 1.2; letter-spacing: -.01em; } - #root-label, #source-label { - overflow: hidden; - color: var(--muted); - font-size: 12px; - text-overflow: ellipsis; - white-space: nowrap; - } - #source-block { min-width: 0; text-align: right; } - #source-label { display: block; max-width: 38vw; } - #header-actions { display: flex; align-items: center; justify-content: flex-end; gap: 8px; } - #status-pill { - display: inline-flex; - align-items: center; - gap: 7px; - max-width: 230px; - padding: 5px 9px; - overflow: hidden; - border: 1px solid var(--border); - border-radius: 999px; - color: var(--muted); - font-size: 11px; - text-overflow: ellipsis; - white-space: nowrap; - } - #status-pill::before { - width: 7px; - height: 7px; - flex: 0 0 auto; - border-radius: 50%; - background: var(--bsk-sky); - content: ""; - } - #status-pill[data-phase="error"]::before, - #status-pill[data-phase="conflict"]::before { background: var(--error); } - #status-pill[data-phase="applying"]::before, - #status-pill[data-phase="previewing"]::before, - #status-pill[data-phase="loading"]::before { animation: breathe 1.2s ease-in-out infinite; } - - .icon-button, .secondary, .primary { - min-height: 30px; - padding: 5px 10px; - } - .icon-button, .secondary { - background: var(--vscode-button-secondaryBackground); - color: var(--vscode-button-secondaryForeground); - } - .icon-button:hover, .secondary:hover { background: var(--vscode-button-secondaryHoverBackground); } - .primary { background: var(--vscode-button-background); color: var(--vscode-button-foreground); } - .primary:hover { background: var(--vscode-button-hoverBackground); } - - #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); } - #section-nav button[aria-current="page"] { background: var(--vscode-list-activeSelectionBackground); color: var(--vscode-list-activeSelectionForeground); font-weight: 600; } - #section-nav button span:first-child { width: 18px; flex: 0 0 auto; text-align: center; } - main { height: 100%; min-width: 0; overflow: hidden; } - main > section { height: 100%; overflow: auto; padding: 24px; } - main > section[hidden] { display: none; } - #rules-section { padding: 0; overflow: hidden; } - - .section-heading { max-width: 940px; margin: 0 auto 20px; } - .section-heading h2 { margin: 0 0 4px; font-size: 21px; letter-spacing: -.02em; } - .section-heading p { margin: 0; color: var(--muted); } - .dashboard-grid { width: min(100%, 940px); margin: 0 auto; display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 12px; } - .card[data-accent]::before { position: absolute; top: -1px; left: 16px; width: 38px; height: 2px; border-radius: 2px; background: var(--bsk-orange); content: ""; } - .card[data-accent="sky"]::before { background: var(--bsk-sky); } - .card[data-accent="teal"]::before { background: var(--bsk-teal); } - .card h4 { margin: 18px 0 6px; padding-top: 14px; border-top: 1px solid var(--border); font-size: 12px; } - .card h3 { margin: 0 0 6px; font-size: 13px; } - .card > p { margin: 0 0 12px; color: var(--muted); font-size: 12px; } - .stat { display: block; margin-bottom: 10px; font-size: 30px; font-weight: 650; letter-spacing: -.04em; } - #severity-strip { grid-column: 1 / -1; display: grid; grid-template-columns: repeat(4, 1fr); gap: 1px; padding: 0; overflow: hidden; background: var(--border); } - #severity-strip div { padding: 14px 16px; background: var(--surface-raised); } - #severity-strip strong { display: block; font-size: 22px; letter-spacing: -.03em; } - #severity-strip span { color: var(--muted); font-size: 11px; } - .wide { grid-column: 1 / -1; } - #source-details { display: grid; grid-template-columns: auto 1fr; gap: 6px 14px; margin: 0 0 14px; } - #source-details dt { color: var(--muted); } - #source-details dd { margin: 0; overflow-wrap: anywhere; } - #problem-list { display: grid; gap: 6px; } - .problem-row { margin: 0; padding: 8px 10px; background: var(--surface); border-left: 3px solid var(--warning); border-radius: 4px; font-size: 12px; } - .problem-row strong { color: var(--warning); } - #path-override-list { display: grid; gap: 10px; } - .path-override-card { padding: 12px 14px; background: var(--surface-raised); border: 1px solid var(--border); border-radius: 8px; } - .path-override-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; } - .path-override-card h3 { margin: 0; overflow-wrap: anywhere; font: 600 12px var(--vscode-editor-font-family); } - .path-override-card ul { display: grid; gap: 4px; margin: 10px 0 0; padding: 0; list-style: none; } - .path-override-card li { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 12px; color: var(--muted); font-size: 12px; } - .path-override-card code { color: var(--text); overflow-wrap: anywhere; } - - #rules-layout { height: 100%; display: grid; grid-template-columns: 250px minmax(360px, 1fr) minmax(240px, 320px); } - #tag-rail, #rule-detail { min-width: 0; padding: 18px 12px; overflow-y: auto; background: var(--surface); } - #tag-rail { border-right: 1px solid var(--border); } - #rule-detail { border-left: 1px solid var(--border); } - #tag-rail h2, #rule-detail h2 { margin: 0 8px 10px; font-size: 12px; text-transform: uppercase; letter-spacing: .08em; } - #tag-list { display: grid; gap: 14px; } - .tag-group { display: grid; gap: 3px; } - .tag-group h3 { margin: 0 8px 2px; color: var(--muted); font-size: 10px; font-weight: 600; letter-spacing: .06em; text-transform: uppercase; } - .tag-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 6px; } - .tag-button { - min-width: 0; - display: grid; - grid-template-columns: 1fr auto; - gap: 8px; - padding: 7px 8px; - background: transparent; - border-color: transparent; - color: var(--text); - text-align: left; - } - .tag-button:hover { background: var(--vscode-list-hoverBackground); } - .tag-button[aria-pressed="true"] { background: var(--bsk-sky-soft); border-color: var(--bsk-sky); } - .tag-button small { color: var(--muted); } - #rules-workspace { min-width: 0; height: 100%; display: grid; grid-template-rows: auto 1fr; overflow: hidden; } - #rules-toolbar { display: flex; align-items: center; gap: 8px; padding: 12px; border-bottom: 1px solid var(--border); } - #rule-search { width: 100%; min-width: 120px; padding: 7px 10px; background: var(--vscode-input-background); color: var(--vscode-input-foreground); border-color: var(--vscode-input-border, var(--border)); } - #filter-result { flex: 0 0 auto; color: var(--muted); font-size: 11px; } - #rule-viewport { position: relative; overflow: auto; contain: strict; } - #rule-spacer { position: relative; width: 100%; } - #rule-window { position: absolute; inset: 0 0 auto; } - .rule-row { - position: absolute; - left: 0; - right: 0; - height: var(--rule-height); - display: grid; - grid-template-columns: minmax(0, 1fr) auto; - gap: 10px; - align-items: center; - padding: 10px 12px; - border-bottom: 1px solid var(--border); - } - .rule-row:hover { background: var(--vscode-list-hoverBackground); } - .rule-copy { min-width: 0; } - .rule-copy button { max-width: 100%; padding: 0; background: transparent; border: 0; color: var(--text); text-align: left; } - .rule-copy strong { font: 600 12px var(--vscode-editor-font-family); } - .rule-copy .title { margin-left: 7px; font-weight: 600; } - .rule-copy p { margin: 4px 0; overflow: hidden; color: var(--muted); font-size: 12px; text-overflow: ellipsis; white-space: nowrap; } - .chip-list { display: flex; gap: 4px; overflow: hidden; } - .chip { padding: 1px 6px; border: 1px solid var(--border); border-radius: 999px; color: var(--muted); font-size: 10px; white-space: nowrap; } - .metrics { margin-left: 5px; color: var(--muted); font-size: 10px; white-space: nowrap; } - .severity-select { min-width: 108px; padding: 6px; background: var(--vscode-dropdown-background); color: var(--vscode-dropdown-foreground); border-color: var(--vscode-dropdown-border, var(--border)); } - .severity-select[data-severity="Error"] { border-left: 3px solid var(--error); } - .severity-select[data-severity="Warning"] { border-left: 3px solid var(--warning); } - .severity-select[data-severity="Info"] { border-left: 3px solid var(--info); } - .severity-select[data-severity="Disabled"] { border-left: 3px solid var(--disabled); } - .tag-row .severity-select { min-width: 88px; } - #detail-empty, .empty-state { padding: 24px 10px; color: var(--muted); text-align: center; } - #detail-content dl { display: grid; grid-template-columns: auto 1fr; gap: 6px 10px; } - #detail-content dt { color: var(--muted); } - #detail-content dd { margin: 0; overflow-wrap: anywhere; } - .action-row { display: flex; flex-wrap: wrap; gap: 8px; } - #occurrence-list { display: grid; gap: 6px; margin-top: 12px; } - .occurrence { padding: 7px; background: transparent; color: var(--text); text-align: left; } - .occurrence small { display: block; color: var(--muted); } - - dialog { - width: min(620px, calc(100vw - 36px)); - max-height: calc(100vh - 32px); - padding: 0; - background: var(--vscode-editorWidget-background); - color: var(--text); - border: 1px solid var(--focus); - border-radius: 12px; - box-shadow: 0 14px 50px var(--vscode-widget-shadow); - } - dialog[open] { display: grid; grid-template-rows: auto minmax(0, 1fr) auto; } - dialog::backdrop { background: color-mix(in srgb, #000 42%, transparent); } - dialog header, dialog footer { padding: 15px 18px; border-bottom: 1px solid var(--border); } - dialog footer { display: flex; justify-content: flex-end; gap: 8px; border-top: 1px solid var(--border); border-bottom: 0; } - dialog h2 { margin: 0; font-size: 17px; } - #preview-body { min-height: 0; padding: 18px; overflow-y: auto; } - #impact-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; } - #impact-grid div { padding: 10px; background: var(--surface); border: 1px solid var(--border); border-radius: 7px; } - #impact-grid strong { display: block; font-size: 19px; } - #impact-grid span { color: var(--muted); font-size: 10px; } - #preview-changes { display: grid; gap: 4px; } - .preview-change { - display: grid; - grid-template-columns: minmax(110px, 1fr) auto; - gap: 10px; - padding: 7px 9px; - background: var(--surface); - border: 1px solid var(--border); - border-radius: 5px; - } - .preview-change code { overflow-wrap: anywhere; } - .preview-change strong { color: var(--bsk-orange); font-size: 11px; text-align: right; } - - .card { - position: relative; - min-width: 0; - padding: 16px; - background: var(--surface-raised); - border: 1px solid var(--border); - border-radius: var(--radius); - box-shadow: 0 1px 0 color-mix(in srgb, var(--text) 5%, transparent); - } - #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(--border); border-radius: 8px; padding: 12px 14px; margin: 0; } - .typeshed-source legend { padding: 0 6px; color: var(--muted); } - .source-choice { - display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 2px 10px; align-items: baseline; - } - .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); } - .typeshed-field input[type="text"] { width: 100%; min-width: 0; } - .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(--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; } - #cache-controls { display: grid; gap: 16px; max-width: 620px; margin: 4px 0 0; } - .cache-toggle { - display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 2px 10px; align-items: baseline; - } - .cache-toggle input { grid-row: 1 / span 2; align-self: center; margin: 0; } - .cache-toggle > span { font-weight: 600; } - .cache-toggle small { grid-column: 2; color: var(--muted); } - .cache-field { display: grid; gap: 5px; justify-items: start; } - .cache-field > span { font-weight: 600; } - .cache-field > small { color: var(--muted); } - .cache-field .path-picker { width: 100%; } - .cache-field input[type="text"] { width: 100%; min-width: 0; } - #cache-in-session p { margin: 0 0 10px; color: var(--muted); font-size: 12px; } - #cache-in-session dl { display: grid; grid-template-columns: minmax(100px, auto) minmax(0, 1fr); gap: 4px 12px; margin: 0; } - #cache-in-session dt { color: var(--muted); } - #cache-in-session dd { margin: 0; } - .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; } - } - @media (max-width: 980px) { - main > section { overflow: auto; } - #rules-section { overflow: auto; } - #rules-layout { - height: auto; - min-height: 100%; - grid-template-columns: minmax(0, 1fr); - grid-template-rows: auto minmax(480px, 70vh) auto; - } - #tag-rail { overflow: visible; border-right: 0; border-bottom: 1px solid var(--border); } - #tag-list { grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); } - #rule-detail { display: block; overflow: visible; border-top: 1px solid var(--border); border-left: 0; } - body > header { grid-template-columns: minmax(220px, 1fr) auto; } - #source-block { display: none; } - } - @media (max-width: 720px) { - body > header { min-height: 64px; padding: 9px 12px; } - #status-pill { display: none; } - #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); } - .preview-change strong { text-align: left; } - } - body.vscode-high-contrast, body.vscode-high-contrast-light { - --border: var(--vscode-contrastBorder); - --bsk-orange: var(--vscode-foreground); - --bsk-sky: var(--vscode-focusBorder); - } -`; diff --git a/vscode-extension/src/configuration-editor-transport.ts b/vscode-extension/src/configuration-editor-transport.ts deleted file mode 100644 index 22c38fc19..000000000 --- a/vscode-extension/src/configuration-editor-transport.ts +++ /dev/null @@ -1,104 +0,0 @@ -// 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 deleted file mode 100644 index e282b8c3f..000000000 --- a/vscode-extension/src/configuration-editor-typeshed.ts +++ /dev/null @@ -1,33 +0,0 @@ -// Implements [LSPCFGED-TYPESHED] read-only license view. The folder pickers -// and the direct-write rule it shares with the Caching panel live in -// `configuration-editor-settings.ts`. - -import * as vscode from "vscode"; -import type { TypeshedLicenseDocument } from "./configuration-editor-model"; - -class LicenseProvider implements vscode.TextDocumentContentProvider, vscode.Disposable { - private readonly changed = new vscode.EventEmitter(); - private content = ""; - public readonly onDidChange = this.changed.event; - public readonly uri = vscode.Uri.parse("basilisk-typeshed-license:/LICENSE"); - - public provideTextDocumentContent(): string { return this.content; } - public set(content: string): void { this.content = content; this.changed.fire(this.uri); } - public dispose(): void { this.changed.dispose(); } -} - -export class TypeshedEditorUi implements vscode.Disposable { - private readonly provider = new LicenseProvider(); - private readonly registration = vscode.workspace.registerTextDocumentContentProvider( - "basilisk-typeshed-license", - this.provider, - ); - - public async showLicense(license: TypeshedLicenseDocument): Promise { - this.provider.set(`${license.title}\n\n${license.content}`); - const document = await vscode.workspace.openTextDocument(this.provider.uri); - await vscode.window.showTextDocument(document, { preview: true }); - } - - public dispose(): void { this.registration.dispose(); this.provider.dispose(); } -} diff --git a/vscode-extension/src/configuration-editor.ts b/vscode-extension/src/configuration-editor.ts deleted file mode 100644 index b4df83f88..000000000 --- a/vscode-extension/src/configuration-editor.ts +++ /dev/null @@ -1,469 +0,0 @@ -// Implements [VSIX-CONFIGURATION-EDITOR] / [VSIX-CONFIGURATION-EDITOR-THIN-SHELL]. -/** VS Code host for the LSP-owned Basilisk configuration editor. */ - -import { effect } from "@preact/signals-core"; -import * as vscode from "vscode"; -import { buildConfigurationEditorDocument } from "./configuration-editor-document"; -import { configurationError } from "./configuration-editor-errors"; -import { - decodeConfigurationEditorIntent, - isNavigationIntent, - type ConfigurationEditorIntent, - type ConfigurationEditorNavigationIntent, -} from "./configuration-editor-intents"; -import type { - ConfigurationPreview, - ConfigurationSnapshot, - RuleOccurrencesRequest, - TypeshedActionRequest, -} from "./configuration-editor-model"; -import { - clientTransport, - type ConfigurationEditorTransport, -} from "./configuration-editor-transport"; -import type { ConfigurationEditorState } from "./configuration-editor-state"; -import { TypeshedEditorUi } from "./configuration-editor-typeshed"; -import { - openConfigFile, - openOccurrence, - openRawConfiguration, - openRuleDocs, -} from "./configuration-editor-navigation"; -import { - isDirectSettingOnly, - pickCacheFolder, - pickTypeshedFolder, -} from "./configuration-editor-settings"; -import { Logger } from "./logger"; -import { SingletonWebviewPanel, type WebviewMessage } from "./profiler-webview"; -import type { Store } from "./store"; - -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 ADOPT_WORKSPACE_COMMAND = "basilisk.adoptWorkspace"; -const FIX_WORKSPACE_COMMAND = "basilisk.fixWorkspace"; -const CONFIGURATION_VIEW_TYPE = "basilisk.configurationEditor"; -export { configurationRepairUri } from "./configuration-editor-errors"; -// 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 { - private readonly panel: SingletonWebviewPanel; - private readonly transport: ConfigurationEditorTransport; - private readonly disposeStateEffect: () => void; - private readonly typeshedUi = new TypeshedEditorUi(); - private webviewReady = false; - private readyMessages = 0; - private loadingRoot: string | undefined; - private pendingRefreshRoot: string | undefined; - private loadGeneration = 0; - private previewGeneration = 0; - private occurrenceGeneration = 0; - private disposed = false; - - constructor(private readonly store: Store, transport?: ConfigurationEditorTransport) { - this.transport = transport ?? clientTransport(store); - this.panel = new SingletonWebviewPanel( - CONFIGURATION_VIEW_TYPE, - (message: WebviewMessage) => { void this.receive(message); }, - { - viewColumn: vscode.ViewColumn.Active, - retainContextWhenHidden: false, - enableFindWidget: true, - onDidReveal: () => { void this.refresh(); }, - onDidDispose: () => { this.handlePanelDisposed(); }, - }, - ); - this.disposeStateEffect = effect(() => { - const state = this.store.configurationEditor.value; - if (this.webviewReady) { - void this.panel.postMessage({ type: "state", state }); - } - if (state.refreshRequested && state.rootUri !== undefined) { - if (this.loadingRoot === undefined) { - void this.load(state.rootUri); - } else { - this.pendingRefreshRoot = state.rootUri; - } - } - }); - } - - /** - * Open/re-render the editor for one explicit workspace root, optionally - * focused on one rule (the diagnostic hover's Configure Severity link, - * [CONFIGEDITOR-VSIX-EXPERIENCE]). - */ - public open(rootUri: string, focusRule?: string): void { - if (this.disposed) { return; } - const wasOpen = this.panel.isOpen(); - const wasVisible = this.panel.isVisible(); - this.webviewReady = false; - if (this.store.configurationEditor.value.rootUri !== rootUri) { - this.pendingRefreshRoot = undefined; - } - // A plain open must clear any stale focus target — `null`, not undefined. - this.store.beginConfigurationLoad(rootUri, focusRule ?? null); - this.panel.show("Basilisk Configuration", buildConfigurationEditorDocument()); - // A hidden live panel refreshes from the real hidden→visible callback. - // New/already-visible panels do not produce that transition, so load here. - if (!wasOpen || wasVisible) { void this.load(rootUri); } - } - - public isOpen(): boolean { return this.panel.isOpen(); } - - /** Number of real ready handshakes received (e2e lifecycle seam). */ - public readyMessageCount(): number { return this.readyMessages; } - - /** Test seam for exercising the same runtime decoder/router as the webview. */ - public async receive(message: unknown): Promise { - const intent = decodeConfigurationEditorIntent(message); - if (intent === undefined) { - Logger.warn("Ignored invalid configuration editor webview message"); - return; - } - await this.route(intent); - } - - private async route(intent: ConfigurationEditorIntent): Promise { - if (isNavigationIntent(intent)) { await this.routeNavigation(intent); return; } - switch (intent.type) { - case "ready": this.handleReady(); return; - case "refresh": await this.refresh(); return; - case "preview": await this.preview(intent); return; - case "apply": await this.apply(); return; - case "cancelPreview": this.cancelPreview(); return; - case "adopt": await this.runWorkspaceCommand(ADOPT_WORKSPACE_COMMAND, false); return; - case "fixSafe": await this.runWorkspaceCommand(FIX_WORKSPACE_COMMAND, true); return; - case "occurrences": await this.loadOccurrences(intent.request); return; - } - } - - /** Opening documents and the two setting panels' native pickers/actions. */ - private async routeNavigation(intent: ConfigurationEditorNavigationIntent): Promise { - switch (intent.type) { - case "openConfigFile": await openConfigFile(this.editorState(), intent.uri); return; - case "openRaw": await openRawConfiguration(this.editorState()); return; - case "openDocs": await openRuleDocs(this.editorState(), intent.uri); return; - case "openOccurrence": await openOccurrence(this.editorState(), intent); return; - case "pickTypeshedFolder": - await this.pickSettingFolder(async (snapshot) => pickTypeshedFolder(snapshot, intent.key)); - return; - case "pickCacheFolder": await this.pickSettingFolder(pickCacheFolder); return; - case "typeshedAction": await this.runTypeshedAction(intent.action); return; - } - } - - private handleReady(): void { - this.readyMessages += 1; - this.webviewReady = true; - void this.panel.postMessage({ type: "state", state: this.store.configurationEditor.value }); - } - - private handlePanelDisposed(): void { - this.webviewReady = false; - this.loadGeneration += 1; - this.previewGeneration += 1; - this.occurrenceGeneration += 1; - this.loadingRoot = undefined; - this.pendingRefreshRoot = undefined; - this.store.resetConfigurationEditor(); - } - - private async refresh(): Promise { - const rootUri = this.store.configurationEditor.value.rootUri; - if (rootUri !== undefined) { await this.load(rootUri); } - } - - private requestIsStale(generation: number, rootUri?: string): boolean { - return generation !== this.loadGeneration || this.disposed || !this.panel.isOpen() - || (rootUri !== undefined && this.store.configurationEditor.value.rootUri !== rootUri); - } - - /** - * A completed Typeshed ACTION carries the authoritative post-action snapshot - * for its root: the server builds it LAST, after the download and pin land - * (`download_latest_and_pin` in `crates/basilisk-lsp/src/typeshed_download.rs`). - * Unlike a plain load it must NOT be gated on the load generation — the - * download's own transient `Downloading` notification triggers a same-root - * refresh that bumps the generation on EVERY run, so a generation gate would - * discard the freshly pinned result and strand the panel on the pre-download - * bundled/unpinned snapshot ([LSPCFGED-TYPESHED-DOWNLOAD]). Only a genuine - * context change — the panel closing/disposing or the user moving to another - * root — invalidates it. - */ - private actionContextChanged(rootUri: string): boolean { - return this.disposed || !this.panel.isOpen() - || this.store.configurationEditor.value.rootUri !== rootUri; - } - - private async load(rootUri: string): Promise { - if (this.loadingRoot === rootUri || this.disposed) { return; } - const generation = ++this.loadGeneration; - this.occurrenceGeneration += 1; - this.loadingRoot = rootUri; - this.store.beginConfigurationLoad(rootUri); - try { - const snapshot = await this.transport.snapshot(rootUri); - if (this.requestIsStale(generation)) { return; } - if (snapshot.rootUri !== rootUri) { throw new Error("The server returned configuration for a different workspace root."); } - this.store.acceptConfigurationSnapshot(snapshot); - } catch (error: unknown) { - if (this.requestIsStale(generation)) { return; } - const details = configurationError(error, rootUri); - this.store.failConfigurationEditor(details.message, details.conflict, details.repairUri); - } finally { - if (generation === this.loadGeneration) { - this.loadingRoot = undefined; - const pendingRoot = this.pendingRefreshRoot; - this.pendingRefreshRoot = undefined; - if (pendingRoot !== undefined && !this.requestIsStale(generation, pendingRoot)) { - void this.load(pendingRoot); - } - } - } - } - - private async preview(intent: Extract): Promise { - const state = this.store.configurationEditor.value; - const snapshot = state.snapshot; - if (snapshot === undefined || state.phase === "applying") { return; } - const generation = this.loadGeneration; - const previewGeneration = ++this.previewGeneration; - this.store.beginConfigurationPreview(); - try { - const preview = await this.transport.preview({ - rootUri: snapshot.rootUri, - baseRevision: snapshot.revision, - mutations: intent.mutations, - }); - if (generation !== this.loadGeneration || previewGeneration !== this.previewGeneration - || this.disposed || !this.panel.isOpen()) { return; } - // A Typeshed or cache edit has no severity impact to weigh, so it lands - // at once ([LSPCFGED-TYPESHED], [LSPCFGED-CACHE]). - if (isDirectSettingOnly(intent)) { await this.applyPreview(preview); return; } - this.store.acceptConfigurationPreview(preview); - } catch (error: unknown) { - if (generation !== this.loadGeneration || previewGeneration !== this.previewGeneration - || this.disposed || !this.panel.isOpen()) { return; } - const details = configurationError(error, snapshot.rootUri); - this.store.failConfigurationEditor(details.message, details.conflict, details.repairUri); - } - } - - /** The live editor state every navigation and picker helper reads from. */ - private editorState(): ConfigurationEditorState { - return this.store.configurationEditor.value; - } - - /** - * Run one native folder-picker and preview what it chose. A cancelled picker - * writes nothing, so the controls snap back to the configuration that still - * holds ([CONFIGEDITOR-VSIX-EXPERIENCE]). - */ - private async pickSettingFolder( - pick: (snapshot: ConfigurationSnapshot) => Promise | undefined>, - ): Promise { - const state = this.editorState(); - if (state.snapshot === undefined) { return; } - const intent = await pick(state.snapshot); - if (intent === undefined) { void this.panel.postMessage({ type: "state", state }); return; } - await this.preview(intent); - } - - private async runTypeshedAction(action: TypeshedActionRequest["action"]): Promise { - const snapshot = this.store.configurationEditor.value.snapshot; - if (snapshot === undefined) { return; } - const generation = this.loadGeneration; - try { - const result = await this.transport.typeshedAction({ - rootUri: snapshot.rootUri, - baseRevision: snapshot.revision, - action, - }); - // The action's result is authoritative for its root, so it is accepted - // even when the download's OWN transient Downloading notification bumped - // the load generation mid-flight — gating that on the generation dropped - // the freshly pinned snapshot and left the panel showing the pre-download - // bundled/unpinned source ([LSPCFGED-TYPESHED-DOWNLOAD]). Completion may - // also arrive via server notifications, but the returned snapshot is the - // definitive final state; a download is not a configuration edit, so no - // action returns a preview. - if (this.actionContextChanged(snapshot.rootUri)) { return; } - if (result.kind === "Snapshot") { - this.store.acceptConfigurationSnapshot(result.snapshot); - } else { - await this.typeshedUi.showLicense(result.license); - } - } catch (error: unknown) { - const details = configurationError(error, snapshot.rootUri); - // A genuine action FAILURE must be a HARD error — to the user AND the - // log — and NOT gated on the stale check: the server's transient - // Downloading notification triggers a refresh that bumps the load - // generation on EVERY download, so a stale-gated catch would swallow - // every real failure and make a failed download indistinguishable from a - // dead button. But a revision conflict is NOT a failure: it is a soft, - // retryable state the store routes to the "conflict" phase, so it must - // never pop a hard error toast ([CONFIGEDITOR-VSIX-EXPERIENCE]). - if (!details.conflict) { - Logger.error(`Typeshed action ${action.kind} failed: ${details.message}`); - void vscode.window.showErrorMessage(`Basilisk: ${details.message}`); - } - if (this.requestIsStale(generation, snapshot.rootUri)) { return; } - this.store.failConfigurationEditor(details.message, details.conflict, details.repairUri); - } - } - - private async apply(): Promise { - const { preview, phase } = this.store.configurationEditor.value; - if (preview === undefined || phase !== "preview") { return; } - await this.applyPreview(preview); - } - - /** - * Discard an unapplied preview and re-render from the snapshot, so a - * dismissed dialog can never leave a control showing a value the - * configuration does not hold ([CONFIGEDITOR-VSIX-EXPERIENCE]). - */ - private cancelPreview(): void { - this.previewGeneration += 1; - this.store.cancelConfigurationPreview(); - } - - private async applyPreview(preview: ConfigurationPreview): Promise { - const snapshot = this.store.configurationEditor.value.snapshot; - if (snapshot === undefined) { return; } - const generation = this.loadGeneration; - this.previewGeneration += 1; - this.store.beginConfigurationApply(); - const sourceWasDirty = findConfigurationDocument(snapshot.configUri)?.isDirty === true; - try { - // [CONFIGEDITOR-OPERATIONS]: rootUri + previewId fully identify the - // cached preview; the preview itself pins the base revision. - const fresh = await this.transport.apply({ - rootUri: snapshot.rootUri, - previewId: preview.previewId, - }); - // Save before the staleness check: the server's configurationChanged - // notification precedes the apply response, so a racing refresh - // routinely bumps the generation — the disk write still has to land. - if (!sourceWasDirty) { await saveConfigurationDocument(fresh.configUri); } - if (this.disposed || !this.panel.isOpen() - || this.store.configurationEditor.value.rootUri !== snapshot.rootUri) { return; } - if (generation !== this.loadGeneration) { - this.loadGeneration += 1; - this.loadingRoot = undefined; - } - this.pendingRefreshRoot = undefined; - this.store.acceptConfigurationSnapshot(fresh); - } catch (error: unknown) { - if (generation !== this.loadGeneration || this.disposed || !this.panel.isOpen()) { return; } - const details = configurationError(error, snapshot.rootUri); - this.store.failConfigurationEditor(details.message, details.conflict, details.repairUri); - } - } - - /** - * Run a workspace-scoped server command (adopt current debt, apply safe - * fixes) and reload. Both are the real, already-registered commands that - * rewrite configuration via `workspace/applyEdit`; the editor only forwards - * and re-snapshots — it never computes debt or edits config text itself. - */ - private async runWorkspaceCommand(command: string, includeRoot: boolean): Promise { - const rootUri = this.store.configurationEditor.value.snapshot?.rootUri; - if (rootUri === undefined || this.store.configurationEditor.value.phase === "applying") { return; } - const generation = this.loadGeneration; - this.previewGeneration += 1; - this.store.beginConfigurationApply(); - try { - await this.transport.executeCommand(command, includeRoot ? [{ rootUri }] : []); - if (this.requestIsStale(generation, rootUri)) { return; } - await this.load(rootUri); - } catch (error: unknown) { - if (this.requestIsStale(generation, rootUri)) { return; } - const details = configurationError(error, rootUri); - this.store.failConfigurationEditor(details.message, details.conflict, details.repairUri); - } - } - - /** - * Open a nested path-override configuration file. Untrusted input: only a URI - * the current snapshot listed as a path override, and only inside the root. - */ - private async loadOccurrences(request: Omit): Promise { - const rootUri = this.store.configurationEditor.value.snapshot?.rootUri; - if (rootUri === undefined) { return; } - const generation = this.loadGeneration; - const occurrenceGeneration = ++this.occurrenceGeneration; - const append = request.cursor !== undefined; - this.store.beginRuleOccurrences(!append); - try { - const response = await this.transport.occurrences({ rootUri, ...request }); - if (generation !== this.loadGeneration || occurrenceGeneration !== this.occurrenceGeneration - || this.disposed || !this.panel.isOpen()) { return; } - this.store.acceptRuleOccurrences(response, append); - } catch (error: unknown) { - if (generation !== this.loadGeneration || occurrenceGeneration !== this.occurrenceGeneration - || this.disposed || !this.panel.isOpen()) { return; } - const details = configurationError(error, rootUri); - this.store.failRuleOccurrences(details.message); - } - } - - public dispose(): void { - this.disposed = true; - this.disposeStateEffect(); - this.typeshedUi.dispose(); - this.panel.dispose(); - this.store.resetConfigurationEditor(); - } - - /** Re-read an already-open editor after the capability returns. */ - public refreshOpen(): void { - const rootUri = this.store.configurationEditor.value.rootUri; - if (this.isOpen() && rootUri !== undefined) { void this.load(rootUri); } - } - - /** Invalidate in-flight work and clear configuration data when support disappears. */ - public capabilityLost(message: string): void { - this.loadGeneration += 1; - this.previewGeneration += 1; - this.occurrenceGeneration += 1; - this.loadingRoot = undefined; - this.pendingRefreshRoot = undefined; - this.store.markConfigurationUnsupported(message); - } -} - -/** Locate the open text document backing the active configuration source. */ -function findConfigurationDocument(sourceUri: string): vscode.TextDocument | undefined { - try { - const target = vscode.Uri.parse(sourceUri, true).toString(); - return vscode.workspace.textDocuments.find((document) => document.uri.toString() === target); - } catch { - return undefined; - } -} - -/** - * Implements [CONFIGEDITOR-SOURCES]: a successful apply must reach disk. - * `workspace.applyEdit` only rewrites the in-memory buffer, and the server - * overlay merely bridges "until the client write is visible on disk" — so - * persist the document the apply edit dirtied. - */ -async function saveConfigurationDocument(sourceUri: string): Promise { - const document = findConfigurationDocument(sourceUri); - if (document?.isDirty !== true) { return; } - const saved = await document.save(); - if (!saved) { - Logger.warn("Configuration apply could not save pyproject.toml; the change is still unsaved in the editor"); - } -} diff --git a/vscode-extension/src/coverage-decorations.ts b/vscode-extension/src/coverage-decorations.ts deleted file mode 100644 index c7600287e..000000000 --- a/vscode-extension/src/coverage-decorations.ts +++ /dev/null @@ -1,131 +0,0 @@ -// Implements [LSPTEST-UV-INTEGRATION-COVERAGE]. See docs/specs/LSP-TEST-INTEGRATION-SPEC.md#LSPTEST-UV-INTEGRATION-COVERAGE -/** - * Coverage gutter decorations for Basilisk test coverage results. - * - * Listens for `basilisk/coverageResult` notifications and renders - * covered (green) / uncovered (red) line backgrounds in the editor. - */ - -import * as vscode from "vscode"; -import { Logger } from "./logger"; - -/** Per-line coverage data from the LSP server. */ -export interface LspLineCoverage { - line: number; - hits: number; -} - -/** Per-file coverage data from the LSP server. */ -export interface LspFileCoverage { - file: string; - lines: LspLineCoverage[]; - coveragePct: number; -} - -/** Coverage result from the LSP server. */ -export interface LspCoverageResult { - files: LspFileCoverage[]; - totalPct: number; -} - -/** Decoration types for coverage gutters (created lazily). */ -let coveredDecorationType: vscode.TextEditorDecorationType | undefined; -let uncoveredDecorationType: vscode.TextEditorDecorationType | undefined; - -/** Get or create the "covered" gutter decoration type. */ -function getCoveredDecoration(): vscode.TextEditorDecorationType { - coveredDecorationType ??= vscode.window.createTextEditorDecorationType({ - gutterIconPath: undefined, - overviewRulerColor: new vscode.ThemeColor("testing.iconPassed"), - overviewRulerLane: vscode.OverviewRulerLane.Left, - isWholeLine: true, - backgroundColor: new vscode.ThemeColor("diffEditor.insertedLineBackground"), - }); - return coveredDecorationType; -} - -/** Get or create the "uncovered" gutter decoration type. */ -function getUncoveredDecoration(): vscode.TextEditorDecorationType { - uncoveredDecorationType ??= vscode.window.createTextEditorDecorationType({ - gutterIconPath: undefined, - overviewRulerColor: new vscode.ThemeColor("testing.iconFailed"), - overviewRulerLane: vscode.OverviewRulerLane.Left, - isWholeLine: true, - backgroundColor: new vscode.ThemeColor("diffEditor.removedLineBackground"), - }); - return uncoveredDecorationType; -} - -/** - * Apply coverage gutter decorations to all visible editors. - * - * For each file in the coverage result, finds matching open editors - * and applies covered/uncovered line decorations. - * - * Implements [LSPTEST-UV-INTEGRATION-COVERAGE] (VS Code side) — renders the - * `basilisk/coverageResult` payload parsed from the deterministic coverage XML. - */ -export function applyCoverageDecorations(coverage: LspCoverageResult): void { - const enabled = vscode.workspace - .getConfiguration("basilisk") - .get("testExplorer.coverageEnabled", false); - - if (!enabled) { - Logger.info("Coverage decorations disabled — skipping"); - return; - } - - // Clear previous decorations from all editors. - clearCoverageDecorations(); - - const covered = getCoveredDecoration(); - const uncovered = getUncoveredDecoration(); - - for (const fileCov of coverage.files) { - // Find editors showing this file. - const editors = vscode.window.visibleTextEditors.filter((editor) => - editor.document.uri.fsPath.endsWith(fileCov.file) - ); - - if (editors.length === 0) { continue; } - - const coveredRanges: vscode.Range[] = []; - const uncoveredRanges: vscode.Range[] = []; - - for (const line of fileCov.lines) { - // coverage.xml uses 1-based lines, VS Code uses 0-based. - const lineIdx = line.line - 1; - if (lineIdx < 0) { continue; } - const range = new vscode.Range( - new vscode.Position(lineIdx, 0), - new vscode.Position(lineIdx, 0) - ); - if (line.hits > 0) { - coveredRanges.push(range); - } else { - uncoveredRanges.push(range); - } - } - - for (const editor of editors) { - editor.setDecorations(covered, coveredRanges); - editor.setDecorations(uncovered, uncoveredRanges); - } - } - - Logger.info(`Coverage applied: ${coverage.totalPct.toFixed(1)}% total`); -} - -/** Clear all coverage decorations from visible editors. */ -export function clearCoverageDecorations(): void { - if (coveredDecorationType !== undefined) { - for (const editor of vscode.window.visibleTextEditors) { - editor.setDecorations(coveredDecorationType, []); - } - } - if (uncoveredDecorationType !== undefined) { - for (const editor of vscode.window.visibleTextEditors) { - editor.setDecorations(uncoveredDecorationType, []); - } - } -} diff --git a/vscode-extension/src/dap-evaluate.ts b/vscode-extension/src/dap-evaluate.ts deleted file mode 100644 index 77e9169fb..000000000 --- a/vscode-extension/src/dap-evaluate.ts +++ /dev/null @@ -1,344 +0,0 @@ -// Implements [LSPPROF]. See docs/specs/LSP-PROFILING-SPEC.md#PROFILE-MEMORY -/** - * DAP `evaluate` bridge for memory profiling. - * - * The LSP holds no DAP connection — the editor owns it — so memory profiling is - * a courier round-trip: the LSP hands us a Python injection script, we run it in - * the debuggee via DAP `evaluate`, and post the raw output back to the LSP - * (`basilisk.memory.ingest`). These are internal helpers, NOT registered - * commands: the LSP owns commands; the editor only shuttles bytes (CLAUDE.md - * command-ownership rule). - * - * debugpy can only `evaluate` against a *stopped* frame, so memory profiling - * requires the debuggee to be paused at a breakpoint — [`currentStoppedFrameId`] - * resolves that frame (or null when nothing is paused). - */ - -import { numberField, recordArrayField, stringField } from "./unknown-shape"; -import * as vscode from "vscode"; -import * as fs from "fs"; -import { Logger } from "./logger"; -import { ALL_THREADS, debugOutputCursor, debugOutputSince, stoppedThreadIds } from "./dap-output"; -import { POLL_INTERVAL_MS, STARTUP_TIMEOUT_MS, WAIT_MS, delay } from "./timeouts"; -/** The Basilisk debug adapter type. */ -const DEBUG_TYPE = "basilisk-debug"; - -/** Prefix shared by every injection-script output marker (`__BASILISK_MEM*__`, - * `__BASILISK_CPU_ACK__`). */ -const MARKER_PREFIX = "__BASILISK_"; - -/** Marker a memory script prints when it hands its (large) payload back via a - * temp file instead of stdout — see [PROFILE-MEMORY-COURIER] and - * `scripts.rs::emit_via_file_helper`. The text after it is the file path. */ -const FILE_PAYLOAD_MARKER = "__BASILISK_MEM_FILE__"; -/** - * How long to wait for a script's (possibly large, chunked) marker output. - * - * This budget is for DELIVERY, not for work: the evaluate has already returned, - * and we are waiting on the marker line to travel debuggee stdout -> debugpy -> - * adapter -> DAP `output` event -> extension host. The wait loop below returns - * the instant the line is complete, so a larger ceiling costs nothing when the - * pipeline is prompt — it only changes what happens when it is slow. - * - * The previous 4s was calibrated on a prompt machine, and on the win32 CI runner - * it expired mid-delivery: the wait then returned marker-LESS output, which the - * courier posted on to `basilisk.memory.ingest`, and the LSP rejected it with - * "no recognized __BASILISK_MEM*__ marker in script output" - * (profiler/memory/session.rs). That reads as a broken injection script rather - * than as a wait that gave up, which is why the same test failed three different - * ways across consecutive runs ([VSIX-CI-PLATFORM-COVERAGE]). - * - * 20s is a judgement, not a measurement — no per-platform delivery figure was - * taken. It is bounded from both sides: comfortably above the sibling - * `FILE_PAYLOAD_WAIT_MS` render budget's granularity, and small enough that even - * three fully-expired legs stay inside the courier round-trip's own 90s test - * budget. Nothing asserts how QUICKLY a marker arrives. - */ -const MARKER_WAIT_MS = 20_000; -/** Poll interval while waiting for marker output. */ -const MARKER_POLL_MS = 25; -/** How long to wait for the debuggee's render worker to fill the payload file. - * The snapshot/diff evaluate returns as soon as the C-level capture is done - * (so it never stalls past pydevd's 3 s evaluation budget); the per-trace - * aggregation happens on a debuggee worker thread and can take a while on - * multi-million-trace heaps ([PROFILE-MEMORY-COURIER]). */ -const FILE_PAYLOAD_WAIT_MS = 60_000; -/** Poll interval while the payload file is still an empty reservation. */ -const FILE_PAYLOAD_POLL_MS = 100; - -/** Return the active Basilisk debug session, or undefined. */ -function activeBasiliskSession(): vscode.DebugSession | undefined { - const session = vscode.debug.activeDebugSession; - return session?.type === DEBUG_TYPE ? session : undefined; -} - -/** - * Evaluate a Python expression/statement in the active Basilisk debug session - * and return its textual output. - * - * Injection scripts `print()` their `__BASILISK_MEM*__` marker payloads, and - * debugpy delivers that to DAP `output` events (the debuggee's stdout is - * redirected) — **not** in the `evaluate` response. So we snapshot the output - * cursor, run the evaluate, and then recover whatever the script printed (with - * a short wait, since the `output` event can land just after the response). The - * evaluate `result` is included too, in case an adapter does echo it. Returns - * null when there is no active Basilisk session or the request fails. - */ -export async function evaluateInDebugSession( - expression: string, - frameId?: number, - context: "repl" | "watch" | "hover" = "repl", -): Promise { - const session = activeBasiliskSession(); - if (session === undefined) { return null; } - - const cursor = debugOutputCursor(session.id); - try { - const request: Record = { expression, context }; - if (frameId !== undefined) { request.frameId = frameId; } - const response: unknown = await session.customRequest("evaluate", request); - const direct = stringField(response, "result") ?? ""; - if (direct.includes(MARKER_PREFIX)) { return await resolveMarkerFilePayload(direct); } - const printed = await waitForMarkerOutput(session.id, cursor); - return await resolveMarkerFilePayload(printed.length > 0 ? printed : direct); - } catch (err: unknown) { - Logger.warn(`[Memory] evaluate failed: ${err instanceof Error ? err.message : String(err)}`); - return null; - } -} - -/** - * Resolve a file-handoff payload. Large memory snapshots are written to a temp - * file by the injection script — which prints only `__BASILISK_MEM_FILE__` - * — because debugpy truncates a single `print()` at ~20 KB - * ([PROFILE-MEMORY-COURIER]). When that marker is present, read the file (the - * real `__BASILISK_MEM*__ + json` payload), delete it, and return its contents. - * Anything else (CPU acks, small OK markers) passes through untouched. - * - * The snapshot/diff scripts print the path while a debuggee worker thread is - * still rendering the payload (that render is seconds of pure Python on big - * heaps — running it inside the evaluate stalled the debugger past pydevd's - * 3 s budget, the original bug): the reserved file exists but is EMPTY until - * the worker atomically `os.replace`s the whole payload in. So an empty file - * means "still rendering" and is polled; any non-empty content is complete by - * construction. A missing file is still an immediate, honest failure. - */ -export async function resolveMarkerFilePayload(out: string): Promise { - const at = out.indexOf(FILE_PAYLOAD_MARKER); - if (at === -1) { return out; } - const path = out.slice(at + FILE_PAYLOAD_MARKER.length).split(/\r?\n/, 1)[0]?.trim() ?? ""; - if (path === "") { return out; } - const deadline = Date.now() + FILE_PAYLOAD_WAIT_MS; - for (;;) { - let contents: string; - try { - contents = await fs.promises.readFile(path, "utf8"); - } catch (err: unknown) { - Logger.warn(`[Memory] could not read payload file: ${err instanceof Error ? err.message : String(err)}`); - return out; - } - if (contents.length > 0) { - await fs.promises.unlink(path).catch(() => undefined); - return contents; - } - if (Date.now() >= deadline) { - Logger.warn(`[Memory] payload file stayed empty for ${FILE_PAYLOAD_WAIT_MS}ms: ${path}`); - await fs.promises.unlink(path).catch(() => undefined); - return out; - } - await delay(FILE_PAYLOAD_POLL_MS); - } -} - -/** - * Wait for printed marker output to arrive via `output` events. - * - * The payload is a single `print()`ed line (`marker + json.dumps(...)` + `\n`) - * but debugpy can split it across several `output` events, so we wait until the - * marker line is **newline-terminated** — otherwise a large JSON snapshot is - * truncated mid-string. `json.dumps` (no indent) emits no embedded newlines, so - * the first `\n` after the marker reliably ends the payload. - */ -async function waitForMarkerOutput(sessionId: string, cursor: number): Promise { - const deadline = Date.now() + MARKER_WAIT_MS; - for (;;) { - const out = debugOutputSince(sessionId, cursor); - const markerAt = out.indexOf(MARKER_PREFIX); - // The payload line is complete once a newline follows the marker (the - // `print()` terminator); `includes(.., markerAt)` searches from the marker. - const complete = markerAt !== -1 && out.includes("\n", markerAt); - if (complete) { - return out; - } - if (Date.now() >= deadline) { - // Say WHY we gave up. Returning quietly hands marker-less (or truncated) - // output to the ingest leg, which rejects it as an unrecognised marker — - // blaming the injection script for a wait that ran out. Distinguish the - // two cases the caller cannot: nothing arrived at all, versus a marker - // that arrived but never terminated. - Logger.warn( - markerAt === -1 - ? `[Memory] no marker in ${out.length} bytes of debuggee output after ${MARKER_WAIT_MS}ms` - : `[Memory] marker output still unterminated after ${MARKER_WAIT_MS}ms (${out.length} bytes) — payload likely truncated`, - ); - return out; - } - await delay(MARKER_POLL_MS); - } -} - -/** - * Resolve a frameId for a currently-stopped thread, or null if nothing is - * paused. debugpy rejects `evaluate` without a stopped frame, so memory - * profiling requires the debuggee to be paused at a breakpoint. - * - * "Is anything paused?" cannot be probed with requests: debugpy answers - * `stackTrace` for a RUNNING thread with a sampled frame whose id is not - * evaluable (`evaluate` then fails with "Unable to find thread for - * evaluation"). So this gates on the tracker's `stopped`/`continued` - * bookkeeping (dap-output.ts) and only then asks for the top frame. - */ -export async function currentStoppedFrameId(): Promise { - const session = activeBasiliskSession(); - if (session === undefined) { return null; } - - const stopped = stoppedThreadIds(session.id); - if (stopped.length === 0) { return null; } - - try { - const candidates = stopped.includes(ALL_THREADS) ? await allThreadIds(session) : stopped; - for (const threadId of candidates) { - const frameId = await topFrameIdIfStopped(session, threadId); - if (frameId !== null) { return frameId; } - } - return null; - } catch (err: unknown) { - Logger.warn( - `[Memory] could not resolve a stopped frame: ${err instanceof Error ? err.message : String(err)}`, - ); - return null; - } -} - -/** Every thread id the debuggee reports (for `allThreadsStopped` stops). */ -async function allThreadIds(session: vscode.DebugSession): Promise { - const threads: unknown = await session.customRequest("threads"); - return recordArrayField(threads, "threads") - .map((thread) => numberField(thread, "id")) - .filter((id): id is number => id !== undefined); -} - -/** A stopped, evaluable frame plus how to release it when the caller is done. */ -export interface AcquiredFrame { - readonly frameId: number; - /** Resume the debuggee — only if acquiring paused it. */ - readonly release: () => Promise; -} - -/** Backoff between transparent-pause attempts, letting the debuggee progress - * out of interpreter/debugger bootstrap frames into user code. */ -const PAUSE_RETRY_BACKOFF_MS = 150; - -/** - * Acquire an evaluable stopped frame, transparently pausing the debuggee - * when it is running — IDE-grade memory snapshots must not demand a manual - * breakpoint ([PROFILE-MEMORY-HOWTO]). When acquisition pauses the program, - * `release` resumes it; when the user was already stopped at a breakpoint, - * `release` is a no-op and their pause is preserved. - * - * A pause landing while the debuggee is still inside interpreter/debugger - * bootstrap (a launch is only milliseconds old) suspends it in frames - * `justMyCode` hides — `stackTrace` reports **zero frames**, and since the - * thread now sits parked there, it would stay unevaluable forever. So a - * transparent pause is a retry loop: pause, briefly wait for an evaluable - * frame, and when none appears resume and re-pause after a backoff — the - * program progresses into user code between attempts. - */ -export async function acquireStoppedFrame(): Promise { - const session = activeBasiliskSession(); - if (session === undefined) { return null; } - - const existing = await currentStoppedFrameId(); - if (existing !== null) { - // The user's own pause: nothing to release. - return { frameId: existing, release: async () => { await Promise.resolve(); } }; - } - - const deadline = Date.now() + STARTUP_TIMEOUT_MS; - for (let attempt = 1; Date.now() < deadline; attempt += 1) { - if (!(await pauseDebuggee(session))) { return null; } - const frameId = await waitForFrameUntil(Math.min(deadline, Date.now() + WAIT_MS)); - if (frameId !== null) { - Logger.info(`[Memory] transparently paused the debuggee for evaluation (attempt ${attempt})`); - return { frameId, release: async () => resumeDebuggee(session) }; - } - // Paused, but no evaluable frame (bootstrap / hidden frames): resume so - // the program can reach user code, then try again. - Logger.info(`[Memory] pause landed in non-user frames (attempt ${attempt}) — resuming to retry`); - await resumeDebuggee(session); - await delay(PAUSE_RETRY_BACKOFF_MS); - } - return null; -} - -/** Poll for an evaluable stopped frame until `deadlineMs` (epoch), else null. */ -async function waitForFrameUntil(deadlineMs: number): Promise { - while (Date.now() < deadlineMs) { - const frameId = await currentStoppedFrameId(); - if (frameId !== null) { return frameId; } - await delay(POLL_INTERVAL_MS); - } - return null; -} - -/** Ask debugpy to pause the first reported thread. */ -async function pauseDebuggee(session: vscode.DebugSession): Promise { - try { - const ids = await allThreadIds(session); - if (ids.length === 0) { return false; } - await session.customRequest("pause", { threadId: ids[0] }); - return true; - } catch (err: unknown) { - Logger.warn(`[Memory] pause failed: ${err instanceof Error ? err.message : String(err)}`); - return false; - } -} - -/** Resume the first stopped (or reported) thread after a transparent pause. */ -async function resumeDebuggee(session: vscode.DebugSession): Promise { - try { - const stopped = stoppedThreadIds(session.id).filter((id) => id !== ALL_THREADS); - const threadId = stopped[0] ?? (await allThreadIds(session))[0]; - if (threadId === undefined) { return; } - await session.customRequest("continue", { threadId }); - Logger.info("[Memory] resumed the debuggee after evaluation"); - } catch (err: unknown) { - Logger.warn(`[Memory] resume failed: ${err instanceof Error ? err.message : String(err)}`); - } -} - -/** - * Poll for a stopped frame until the startup budget runs out. Shared by the - * memory track-on-launch flow and the cooperative CPU sampler injection, - * both of which wait for the `stopOnEntry` pause before evaluating. - */ -export async function waitForStoppedFrame(): Promise { - return waitForFrameUntil(Date.now() + STARTUP_TIMEOUT_MS); -} - -/** Top frameId of `threadId` if it is stopped, else null (running threads error). */ -async function topFrameIdIfStopped( - session: vscode.DebugSession, - threadId: number, -): Promise { - try { - const stack: unknown = await session.customRequest("stackTrace", { - threadId, - startFrame: 0, - levels: 1, - }); - return numberField(recordArrayField(stack, "stackFrames")[0], "id") ?? null; - } catch { - return null; // thread not suspended - } -} diff --git a/vscode-extension/src/dap-output.ts b/vscode-extension/src/dap-output.ts deleted file mode 100644 index 75e3812ab..000000000 --- a/vscode-extension/src/dap-output.ts +++ /dev/null @@ -1,149 +0,0 @@ -// Implements [LSPPROF]. See docs/specs/LSP-PROFILING-SPEC.md#PROFILE-MEMORY -/** - * Per-session debuggee state fed by the DAP tracker: output and stop-state. - * - * **Output**: memory injection scripts `print('__BASILISK_MEM*__' + json)` — - * and debugpy delivers that stdout as DAP `output` events, **not** in the - * `evaluate` response result (the debuggee's stdout is redirected). So to - * recover a marker payload after running a script, we accumulate the - * session's output here and slice out what arrived after the `evaluate` was - * issued. See `dap-evaluate.ts`. - * - * **Stop-state**: debugpy answers `stackTrace` for a RUNNING thread with a - * sampled frame whose id is not evaluable (`evaluate` then fails with - * "Unable to find thread for evaluation"), so "is anything paused?" cannot - * be probed via requests — it must be tracked from `stopped`/`continued` - * events, exactly as VS Code's own debug UI does. The `continued` event is - * OPTIONAL per the DAP spec ("a debug adapter is not expected to send this - * event in response to a request that implies that execution continues"), - * so a successful resume-implying RESPONSE (`continue`, steps) must clear - * the bookkeeping too — otherwise there is a stale window between the - * response and the (late, optional) event where a courier evaluates against - * a sampled frame of a running thread and fails. - */ - -/** Cap per-session buffer so a long-lived session can't grow it unbounded. */ -import { booleanField, numberField, rawField, stringField } from "./unknown-shape"; - -const MAX_BUFFER_CHARS = 1_000_000; - -/** sessionId → accumulated output text. */ -const buffers = new Map(); - -/** sessionId → thread ids reported stopped (empty/absent = running). */ -const stoppedThreads = new Map>(); - -/** Sentinel for a `stopped` event carrying `allThreadsStopped` but no id. */ -export const ALL_THREADS = -1; - -/** - * Record a `stopped`/`continued` event from the DAP tracker. A continue - * always invalidates the ALL_THREADS marker — when in doubt we prefer - * "running" (an honest "pause first" beats an unevaluable stale frame). - */ -export function trackSuspensionEvent( - sessionId: string, - event: "stopped" | "continued", - body: unknown, -): void { - const threadId = numberField(body, "threadId"); - if (event === "stopped") { - const set = stoppedThreads.get(sessionId) ?? new Set(); - if (threadId !== undefined) { set.add(threadId); } - if (booleanField(body, "allThreadsStopped") === true) { set.add(ALL_THREADS); } - if (set.size > 0) { stoppedThreads.set(sessionId, set); } - return; - } - if (booleanField(body, "allThreadsContinued") !== false || threadId === undefined) { - stoppedThreads.delete(sessionId); - return; - } - const set = stoppedThreads.get(sessionId); - set?.delete(threadId); - set?.delete(ALL_THREADS); - if (set?.size === 0) { stoppedThreads.delete(sessionId); } -} - -/** Thread ids currently stopped (may contain [`ALL_THREADS`]); empty = running. */ -export function stoppedThreadIds(sessionId: string): readonly number[] { - return [...(stoppedThreads.get(sessionId) ?? [])]; -} - -/** Requests whose successful response means execution resumed (DAP spec: - * the `continued` event is optional after these, so the response is the - * only guaranteed signal). */ -const RESUME_COMMANDS = new Set([ - "continue", "reverseContinue", "next", "stepIn", "stepOut", "stepBack", "goto", "restartFrame", -]); - -/** Resume commands whose response covers every thread unless the adapter - * says otherwise (`allThreadsContinued` defaults to true per the spec). */ -const ALL_THREAD_RESUMES = new Set(["continue", "reverseContinue"]); - -/** sessionId → seq of an in-flight resume request → its command + threadId. */ -const pendingResumes = new Map>(); - -/** Remember an outgoing resume-implying request (DAP tracker, editor → adapter). */ -export function trackResumeRequest(sessionId: string, message: unknown): void { - const seq = numberField(message, "seq"); - if (stringField(message, "type") !== "request" || seq === undefined) { return; } - const command = stringField(message, "command"); - if (command === undefined || !RESUME_COMMANDS.has(command)) { return; } - const pending = pendingResumes.get(sessionId) ?? new Map(); - pending.set(seq, { command, threadId: numberField(rawField(message, "arguments"), "threadId") }); - pendingResumes.set(sessionId, pending); -} - -/** - * Clear stop-state when a resume-implying request SUCCEEDS (adapter → editor). - * A failed resume did not move anything, so the pause survives. A `continue` - * clears every thread unless the adapter narrows it (`allThreadsContinued: - * false`); a step clears only the stepped thread — its own `stopped` event - * re-arms the bookkeeping when the step lands. - */ -export function trackResumeResponse(sessionId: string, message: unknown): void { - const requestSeq = numberField(message, "request_seq"); - if (stringField(message, "type") !== "response" || requestSeq === undefined) { return; } - const pending = pendingResumes.get(sessionId); - const request = pending?.get(requestSeq); - if (pending === undefined || request === undefined) { return; } - pending.delete(requestSeq); - if (pending.size === 0) { pendingResumes.delete(sessionId); } - if (booleanField(message, "success") !== true) { return; } - const allThreads = - ALL_THREAD_RESUMES.has(request.command) && - booleanField(rawField(message, "body"), "allThreadsContinued") !== false; - trackSuspensionEvent(sessionId, "continued", { - threadId: request.threadId, - allThreadsContinued: allThreads, - }); -} - -/** Append a chunk of debuggee output for a session (called by the DAP tracker). */ -export function appendDebugOutput(sessionId: string, text: string): void { - const combined = (buffers.get(sessionId) ?? "") + text; - buffers.set( - sessionId, - combined.length > MAX_BUFFER_CHARS - ? combined.slice(combined.length - MAX_BUFFER_CHARS) - : combined, - ); -} - -/** Current length of a session's output buffer — a cursor for [`debugOutputSince`]. */ -export function debugOutputCursor(sessionId: string): number { - return (buffers.get(sessionId) ?? "").length; -} - -/** Output appended after `cursor` (everything, if the buffer was trimmed past it). */ -export function debugOutputSince(sessionId: string, cursor: number): string { - const all = buffers.get(sessionId) ?? ""; - return cursor < all.length ? all.slice(cursor) : ""; -} - -/** Drop a session's buffer and stop-state (called when the debug session ends). */ -export function clearDebugOutput(sessionId: string): void { - buffers.delete(sessionId); - stoppedThreads.delete(sessionId); - pendingResumes.delete(sessionId); -} diff --git a/vscode-extension/src/dap-proxy.ts b/vscode-extension/src/dap-proxy.ts deleted file mode 100644 index 5e8fc859c..000000000 --- a/vscode-extension/src/dap-proxy.ts +++ /dev/null @@ -1,709 +0,0 @@ -// Implements [VSIX-PYTHON-DEBUGGER-DAP-PROXY]. See docs/specs/VSIX-SPEC.md#VSIX-PYTHON-DEBUGGER-DAP-PROXY -/** - * DAP proxy that sits between VS Code and debugpy. - * - * debugpy.adapter has quirks that this proxy smooths over: - * - * 1. **stepOut lands before assignment**: After `stepOut` from a called - * function, debugpy stops at the call-site line BEFORE the return - * value is assigned. This proxy injects an automatic `next` to - * complete the statement. - * - * 2. **Structural line stops**: debugpy stops on `try:` lines during - * stepOver. This proxy detects these stops and auto-steps past them. - * - * 3. **Single-connection adapter**: `debugpy.adapter --port` accepts - * exactly ONE TCP connection. The proxy owns that connection — - * VS Code talks to the proxy, never directly to debugpy. - * - * 4. **Attach mode resilience**: In attach mode, if debugpy doesn't - * respond to the `attach` request (e.g. no target process), the - * proxy synthesizes a success response so the session starts. - * - * Architecture: The proxy is a TCP server. VS Code connects to the proxy - * via DebugAdapterServer, and the proxy connects to debugpy. This ensures - * VS Code manages its own TCP lifecycle, giving it clean session teardown - * (activeDebugSession is cleared before onDidTerminateDebugSession fires). - */ - -import { asRecord, booleanField, numberField, rawField, recordArrayField, recordField, stringField } from "./unknown-shape"; -import * as net from "net"; -import * as fs from "fs"; -import { Logger } from "./logger"; -import { WAIT_MS } from "./timeouts"; - -/** - * Coerce a socket `data` chunk to a `Buffer`. @types/node 25 types the `data` - * event payload as `string | Buffer`; these sockets are binary (no encoding is - * ever set) so the string branch never runs at runtime, but it keeps the types - * honest without a cast. - */ -function asBuffer(chunk: string | Buffer): Buffer { - return typeof chunk === "string" ? Buffer.from(chunk) : chunk; -} - -/** Minimal shape of a DAP message for type narrowing. */ -export interface DapMessage { - type: string; - seq?: number; - request_seq?: number; - command?: string; - event?: string; - success?: boolean; - body?: unknown; - arguments?: Record; -} - -/** - * Decode one DAP wire frame, or `undefined` when the bytes are not a DAP - * message. - * - * `type` is the one field every branch of the proxy switches on, so it is - * checked here rather than assumed: a frame without it would otherwise flow - * through every `msg.type === ...` comparison as a silent no-match. - */ -export function parseDapMessage(body: string): DapMessage | undefined { - let decoded: unknown; - try { - decoded = JSON.parse(body); - } catch { - return undefined; - } - const type = stringField(decoded, "type"); - if (type === undefined) { - return undefined; - } - const record = asRecord(decoded); - return { - // The proxy is a relay — `sendToClient`/`sendToDebugpy` re-serialise what - // was decoded here — so every field the wire carried is kept, including the - // ones nothing below reads: the standard `message` that carries an error - // back to the user on a failed response, and any adapter-specific - // extension. The named fields below then re-state the handful the proxy - // itself switches on, checked rather than assumed. - ...record, - type, - seq: numberField(record, "seq"), - request_seq: numberField(record, "request_seq"), - command: stringField(record, "command"), - event: stringField(record, "event"), - success: booleanField(record, "success"), - body: rawField(record, "body"), - arguments: recordField(record, "arguments"), - }; -} - -// Implements [PROFILE-LAUNCH-NOSTOP]. See docs/specs/LSP-PROFILING-SPEC.md#PROFILE-LAUNCH-NOSTOP -/** - * Strip the breakpoints from a client request so a "Run & Profile" launch runs - * to completion instead of presenting as an interactive debug session that - * halts at the user's breakpoints / exception stops (#145). - * - * Applies only when the session was launched for profiling (`profileOnLaunch`); - * normal debug sessions and every non-breakpoint request pass through untouched. - * Critically it only neutralises `setBreakpoints` / `setExceptionBreakpoints` — - * `stopOnEntry` is a launch argument, not a breakpoint, so the macOS cooperative - * sampler can still inject at the entry pause ([PROFILE-COOPERATIVE]). - */ -export function suppressBreakpointsForProfiling(msg: DapMessage, profilingLaunch: boolean): DapMessage { - if (!profilingLaunch || msg.type !== "request") { - return msg; - } - // Source-line and function breakpoints both arm `breakpoints`; clear it so - // debugpy arms none. - if (msg.command === "setBreakpoints" || msg.command === "setFunctionBreakpoints") { - return { ...msg, arguments: { ...msg.arguments, breakpoints: [] } }; - } - if (msg.command === "setExceptionBreakpoints") { - return { ...msg, arguments: { ...msg.arguments, filters: [], filterOptions: [], exceptionOptions: [] } }; - } - return msg; -} - -/** - * Lines matching this pattern are structural — debugpy stops on them but - * no meaningful user code executes. Only `try:` is skipped; `except` lines - * ARE useful stops because they indicate exception handling flow and the - * test suite counts them as part of the stepping sequence. - */ -/** Length of the `\r\n\r\n` separator between DAP header and body. */ -const DAP_HEADER_SEPARATOR_LEN = 4; - -const STRUCTURAL_LINE_RE = /^\s*(try\s*:)\s*(#.*)?$/; - -/** - * TCP-based DAP proxy. Listens on a local port for VS Code to connect, - * and relays messages to/from a debugpy TCP server with quirk fixes. - * - * Usage: - * const proxy = new DapTcpProxy(debugpyHost, debugpyPort); - * const proxyPort = await proxy.start(); - * return new vscode.DebugAdapterServer(proxyPort); - */ -export class DapTcpProxy { - private server: net.Server | undefined; - private clientSocket: net.Socket | undefined; - private debugpySocket: net.Socket | undefined; - private clientBuffer = Buffer.alloc(0); - private debugpyBuffer = Buffer.alloc(0); - - private pendingStepOutSeq: number | undefined; - private awaitingStepOutStop = false; - private stepOutThreadId: number | undefined; - /** Sequence number base for injected DAP requests — must not collide with VS Code's seqs. */ - private static readonly INJECTED_SEQ_BASE = 900_000; - private injectedSeq = DapTcpProxy.INJECTED_SEQ_BASE; - - /** Track pending next (stepOver) requests from VS Code for structural line skipping. */ - private pendingNextSeq: number | undefined; - private awaitingNextStop = false; - private nextThreadId: number | undefined; - - /** Cache of source file lines for structural line detection. */ - private readonly sourceCache = new Map(); - - /** - * Whether this session was launched for profiling (`profileOnLaunch`). When - * set, the proxy neutralises user breakpoints so the run completes instead of - * stopping interactively ([PROFILE-LAUNCH-NOSTOP], #145). `launch` always - * reaches the proxy before `setBreakpoints`, so this is known in time. - */ - private profilingLaunch = false; - - /** Track pending attach request for timeout-based response injection. */ - private pendingAttachSeq: number | undefined; - private attachResponseTimer: ReturnType | undefined; - - /** Track whether we've seen an exited event before terminated. */ - private sawExitedEvent = false; - - /** Track pending injected stackTrace requests. */ - private pendingStackTraceSeq: number | undefined; - private pendingStoppedMsg: DapMessage | undefined; - private sawTerminatedEvent = false; - - /** Track whether we forwarded disconnect to debugpy after already responding to VS Code. */ - private sawDisconnectForwarded = false; - - /** - * Track the client's pending `terminate` request. debugpy can drop the - * response when the debuggee exits as a result (events `exited`/`terminated` - * arrive, the socket closes, no response) — VS Code then rejects - * `stopDebugging()` with "Canceled". The proxy answers it itself on - * termination and swallows debugpy's late duplicate, mirroring the - * disconnect/attach shims. - */ - private pendingTerminateSeq: number | undefined; - private terminateAnswered = false; - - constructor( - private readonly debugpyHost: string, - private readonly debugpyPort: number, - ) {} - - /** - * Start the proxy: connect to debugpy and listen on a random local port. - * Returns the port number VS Code should connect to. - */ - public async start(): Promise { - // First connect to debugpy - await this.connectToDebugpy(); - - // Then start our server for VS Code to connect to - return this.startServer(); - } - - private async connectToDebugpy(): Promise { - return new Promise((resolve, reject) => { - this.debugpySocket = net.createConnection(this.debugpyPort, this.debugpyHost, () => { - Logger.info(`[DAP Proxy] connected to debugpy at ${this.debugpyHost}:${this.debugpyPort}`); - resolve(); - }); - this.debugpySocket.on("data", (chunk) => { this.onDebugpyData(asBuffer(chunk)); }); - this.debugpySocket.on("error", (err) => { - Logger.error(`[DAP Proxy] debugpy socket error: ${err.message}`); - reject(err); - }); - this.debugpySocket.on("close", () => { - Logger.info("[DAP Proxy] debugpy socket closed"); - this.completeTermination("debugpy socket closed"); - }); - }); - } - - private async startServer(): Promise { - return new Promise((resolve, reject) => { - this.server = net.createServer((socket) => { - Logger.info("[DAP Proxy] VS Code connected to proxy"); - this.clientSocket = socket; - socket.on("data", (chunk) => { this.onClientData(asBuffer(chunk)); }); - socket.on("error", (err) => { - Logger.error(`[DAP Proxy] client socket error: ${err.message}`); - }); - socket.on("close", () => { - Logger.info("[DAP Proxy] client socket closed"); - }); - }); - this.server.listen(0, "127.0.0.1", () => { - const addr = this.server?.address(); - if (addr !== undefined && addr !== null && typeof addr !== "string") { - Logger.info(`[DAP Proxy] listening on port ${addr.port}`); - resolve(addr.port); - } else { - reject(new Error("Failed to get proxy server address")); - } - }); - this.server.on("error", (err) => { - Logger.error(`[DAP Proxy] server error: ${err.message}`); - reject(err); - }); - }); - } - - public dispose(): void { - Logger.info("[DAP Proxy] disposing"); - if (this.attachResponseTimer) { - clearTimeout(this.attachResponseTimer); - } - this.clientSocket?.destroy(); - this.debugpySocket?.destroy(); - this.server?.close(); - } - - // ── Message framing: client → proxy ────────────────────────────────── - - private onClientData(chunk: Buffer): void { - this.clientBuffer = Buffer.concat([this.clientBuffer, chunk]); - for (;;) { - const headerEnd = this.clientBuffer.indexOf("\r\n\r\n"); - if (headerEnd < 0) {break;} - const headerStr = this.clientBuffer.subarray(0, headerEnd).toString("utf-8"); - const match = /Content-Length:\s*(\d+)/i.exec(headerStr); - if (!match) { - this.clientBuffer = this.clientBuffer.subarray(1); - continue; - } - const bodyLen = parseInt(match[1], 10); - const bodyStart = headerEnd + DAP_HEADER_SEPARATOR_LEN; - if (this.clientBuffer.length < bodyStart + bodyLen) {break;} - const body = this.clientBuffer.subarray(bodyStart, bodyStart + bodyLen).toString("utf-8"); - this.clientBuffer = this.clientBuffer.subarray(bodyStart + bodyLen); - - const msg = parseDapMessage(body); - if (msg === undefined) { continue; } - this.processFromClient(msg); - } - } - - // ── Message framing: debugpy → proxy ───────────────────────────────── - - private onDebugpyData(chunk: Buffer): void { - this.debugpyBuffer = Buffer.concat([this.debugpyBuffer, chunk]); - for (;;) { - const headerEnd = this.debugpyBuffer.indexOf("\r\n\r\n"); - if (headerEnd < 0) {break;} - const headerStr = this.debugpyBuffer.subarray(0, headerEnd).toString("utf-8"); - const match = /Content-Length:\s*(\d+)/i.exec(headerStr); - if (!match) { - this.debugpyBuffer = this.debugpyBuffer.subarray(1); - continue; - } - const bodyLen = parseInt(match[1], 10); - const bodyStart = headerEnd + DAP_HEADER_SEPARATOR_LEN; - if (this.debugpyBuffer.length < bodyStart + bodyLen) {break;} - const body = this.debugpyBuffer.subarray(bodyStart, bodyStart + bodyLen).toString("utf-8"); - this.debugpyBuffer = this.debugpyBuffer.subarray(bodyStart + bodyLen); - - const msg = parseDapMessage(body); - if (msg === undefined) { continue; } - this.processFromDebugpy(msg); - } - } - - // ── Send helpers ───────────────────────────────────────────────────── - - private sendToDebugpy(msg: DapMessage): void { - if (!this.debugpySocket || this.debugpySocket.destroyed) {return;} - const json = JSON.stringify(msg); - const header = `Content-Length: ${Buffer.byteLength(json, "utf-8")}\r\n\r\n`; - this.debugpySocket.write(header + json); - } - - private sendToClient(msg: DapMessage): void { - if (!this.clientSocket || this.clientSocket.destroyed) {return;} - const json = JSON.stringify(msg); - const header = `Content-Length: ${Buffer.byteLength(json, "utf-8")}\r\n\r\n`; - this.clientSocket.write(header + json); - } - - // ── Process messages from VS Code (client → debugpy) ───────────────── - - /** - * Record whether this session is a profiling run, read from the `launch` - * request's `profileOnLaunch` argument ([PROFILE-LAUNCH-NOSTOP], #145). - */ - private maybeRecordProfilingLaunch(msg: DapMessage): void { - if (msg.type === "request" && msg.command === "launch") { - this.profilingLaunch = msg.arguments?.profileOnLaunch === true; - } - } - - private processFromClient(msg: DapMessage): void { - if (msg.type === "request") { - Logger.debug(`[DAP Proxy] client → debugpy: ${msg.command} seq=${msg.seq}`); - } - - // After terminated event, respond to disconnect immediately without - // round-tripping to debugpy (which may already be dead). - // After responding, close the client socket so VS Code's adapter-exit - // path runs, which clears activeDebugSession synchronously. - if (msg.type === "request" && msg.command === "disconnect" && this.sawTerminatedEvent) { - Logger.info("[DAP Proxy] fast disconnect response (post-termination)"); - this.sendToClient({ - type: "response", - command: "disconnect", - request_seq: msg.seq, - seq: 0, - success: true, - body: {}, - }); - Logger.info("[DAP Proxy] disconnect response sent to client, forwarding to debugpy"); - // Also forward to debugpy for cleanup, swallowing its duplicate response. - this.sawDisconnectForwarded = true; - this.sendToDebugpy(msg); - this.closeClientConnection(); - return; - } - - if (msg.type === "request" && msg.command === "terminate") { - this.pendingTerminateSeq = msg.seq; - this.terminateAnswered = false; - } - - if (msg.type === "request" && msg.command === "stepOut") { - this.pendingStepOutSeq = msg.seq; - this.stepOutThreadId = numberField(msg.arguments, "threadId"); - this.awaitingStepOutStop = false; - } - - if (msg.type === "request" && msg.command === "next") { - this.pendingNextSeq = msg.seq; - this.nextThreadId = numberField(msg.arguments, "threadId"); - this.awaitingNextStop = false; - Logger.debug(`[DAP Proxy] outgoing next seq=${msg.seq}`); - } - - // A "Run & Profile" launch must run to completion, not present as an - // interactive debug session — record it so user breakpoints are neutralised - // ([PROFILE-LAUNCH-NOSTOP], #145). - this.maybeRecordProfilingLaunch(msg); - - if (msg.type === "request" && msg.command === "attach") { - this.pendingAttachSeq = msg.seq; - // Set a timeout: if debugpy doesn't respond within 3s, fake a response. - this.attachResponseTimer = setTimeout(() => { - if (this.pendingAttachSeq !== undefined) { - Logger.warn("[DAP Proxy] attach response timeout — injecting success"); - this.sendToClient({ - type: "response", - command: "attach", - request_seq: this.pendingAttachSeq, - seq: 0, - success: true, - body: {}, - }); - this.pendingAttachSeq = undefined; - } - }, WAIT_MS); - } - - this.sendToDebugpy(suppressBreakpointsForProfiling(msg, this.profilingLaunch)); - } - - // ── Process messages from debugpy (debugpy → client) ───────────────── - - private processFromDebugpy(msg: DapMessage): void { - this.handleStepOutResponse(msg); - - if (this.handleStepOutStop(msg)) {return;} - if (this.handleNextResponse(msg)) {return;} - if (this.handleStoppedAfterNext(msg)) {return;} - if (this.handleStructuralLineCheck(msg)) {return;} - if (this.handleSwallowedResponses(msg)) {return;} - - this.handleAttachResponse(msg); - if (this.handleTerminationEvents(msg)) {return;} - - this.sendToClient(msg); - } - - // ── stepOut auto-next ────────────────────────────────────────────── - - // Implements [VSIX-PYTHON-DEBUGGER-DAP-PROXY] Quirk 1 — stepOut lands before - // assignment: arm an auto-next on the stepOut response, then inject `next` - // (handleStepOutStop) on the next stop, swallowing the intermediate stop. - /** Arm auto-next when stepOut response arrives. */ - private handleStepOutResponse(msg: DapMessage): void { - if ( - msg.type === "response" && - msg.command === "stepOut" && - msg.request_seq === this.pendingStepOutSeq && - msg.success - ) { - this.awaitingStepOutStop = true; - Logger.debug("[DAP Proxy] stepOut ok — arming auto-next"); - } - } - - /** After stepOut, inject an extra `next` to complete the assignment. */ - private handleStepOutStop(msg: DapMessage): boolean { - if (msg.type !== "event" || msg.event !== "stopped" || !this.awaitingStepOutStop) { - return false; - } - this.awaitingStepOutStop = false; - const tid = numberField(msg.body, "threadId") ?? this.stepOutThreadId; - Logger.info(`[DAP Proxy] injecting next after stepOut (thread ${tid})`); - this.injectedSeq++; - this.sendToDebugpy({ - type: "request", - command: "next", - seq: this.injectedSeq, - arguments: { threadId: tid }, - }); - return true; - } - - // ── stepOver structural line skipping ────────────────────────────── - - /** Arm structural line check when next response arrives. Returns true if stopped event was logged. */ - private handleNextResponse(msg: DapMessage): boolean { - if (msg.type === "response" && msg.command === "next" && msg.success) { - Logger.debug(`[DAP Proxy] next response: request_seq=${msg.request_seq}, pending=${this.pendingNextSeq}`); - if (msg.request_seq === this.pendingNextSeq) { - this.awaitingNextStop = true; - Logger.debug("[DAP Proxy] next ok — arming structural line check"); - } - } - if (msg.type === "event" && msg.event === "stopped") { - Logger.debug(`[DAP Proxy] stopped: awaitNext=${this.awaitingNextStop}, awaitStepOut=${this.awaitingStepOutStop}`); - } - return false; - } - - /** When stopped after stepOver, hold the event and request stackTrace. */ - private handleStoppedAfterNext(msg: DapMessage): boolean { - if (msg.type !== "event" || msg.event !== "stopped" || !this.awaitingNextStop) { - return false; - } - this.awaitingNextStop = false; - const tid = numberField(msg.body, "threadId") ?? this.nextThreadId; - this.pendingStoppedMsg = msg; - this.injectedSeq++; - this.pendingStackTraceSeq = this.injectedSeq; - Logger.debug(`[DAP Proxy] holding stopped, requesting stackTrace seq=${this.injectedSeq} thread=${tid}`); - this.sendToDebugpy({ - type: "request", - command: "stackTrace", - seq: this.injectedSeq, - arguments: { threadId: tid, startFrame: 0, levels: 1 }, - }); - return true; - } - - /** Handle stackTrace response: skip structural lines or forward the stopped event. */ - private handleStructuralLineCheck(msg: DapMessage): boolean { - if (msg.type === "response" && msg.command === "stackTrace") { - Logger.debug(`[DAP Proxy] stackTrace response: req=${msg.request_seq}, pending=${this.pendingStackTraceSeq}`); - } - if ( - msg.type !== "response" || - msg.command !== "stackTrace" || - msg.request_seq !== this.pendingStackTraceSeq - ) { - return false; - } - - this.pendingStackTraceSeq = undefined; - const stoppedMsg = this.pendingStoppedMsg; - this.pendingStoppedMsg = undefined; - - if (stoppedMsg && msg.success && this.trySkipStructuralLine(msg, stoppedMsg)) { - return true; - } - - if (stoppedMsg) { - this.sendToClient(stoppedMsg); - } - return true; // always consume the stackTrace response - } - - // Implements [VSIX-PYTHON-DEBUGGER-DAP-PROXY] Quirk 2 — structural line stops: - // after a stepOver stop, requests a stackTrace and skips `try:` lines - // (STRUCTURAL_LINE_RE) by injecting another `next`. except:/finally: are NOT - // skipped, matching the spec. - /** Check if the top frame is a structural line and inject a skip if so. */ - private trySkipStructuralLine(stackMsg: DapMessage, stoppedMsg: DapMessage): boolean { - const frames = recordArrayField(stackMsg.body, "stackFrames"); - const top = frames[0]; - if (top === undefined) {return false;} - - const line = numberField(top, "line"); - const filePath = stringField(recordField(top, "source"), "path"); - if (line === undefined || filePath === undefined || filePath === "") {return false;} - if (!this.isStructuralLine(filePath, line)) {return false;} - - const tid = numberField(stoppedMsg.body, "threadId") ?? this.nextThreadId; - Logger.info(`[DAP Proxy] skipping structural line ${line} in ${filePath.split("/").pop()}`); - this.awaitingNextStop = true; - this.injectedSeq++; - this.pendingNextSeq = this.injectedSeq; - this.sendToDebugpy({ - type: "request", - command: "next", - seq: this.injectedSeq, - arguments: { threadId: tid }, - }); - return true; - } - - // ── Response swallowing ──────────────────────────────────────────── - - /** Swallow injected next responses and duplicate disconnect responses. */ - private handleSwallowedResponses(msg: DapMessage): boolean { - if (msg.type === "response" && msg.command === "next" && msg.request_seq !== undefined && msg.request_seq >= DapTcpProxy.INJECTED_SEQ_BASE) { - Logger.debug("[DAP Proxy] swallowed injected next response"); - return true; - } - if (msg.type === "response" && msg.command === "disconnect" && this.sawDisconnectForwarded) { - Logger.debug("[DAP Proxy] swallowed duplicate disconnect response"); - this.sawDisconnectForwarded = false; - return true; - } - if (msg.type === "response" && msg.command === "terminate") { - if (this.terminateAnswered) { - Logger.debug("[DAP Proxy] swallowed duplicate terminate response"); - return true; - } - // debugpy answered it itself — nothing for the proxy to guarantee. - this.pendingTerminateSeq = undefined; - } - return false; - } - - // Implements [VSIX-PYTHON-DEBUGGER-DAP-PROXY] Quirk 5 — dropped terminate - // response: debugpy can close the socket without answering `terminate` when - // the debuggee exits as a result, leaving VS Code to reject stopDebugging() - // with "Canceled". - /** - * Answer the client's pending `terminate` request when debugpy never will — - * the debuggee is gone (terminated event or socket death), so the request - * has succeeded in every way that matters. Without this, VS Code cancels - * the request when the connection closes and `stopDebugging()` rejects. - */ - private answerPendingTerminate(): void { - if (this.pendingTerminateSeq === undefined) {return;} - Logger.info("[DAP Proxy] answering pending terminate (debuggee gone)"); - this.terminateAnswered = true; - this.sendToClient({ - type: "response", - command: "terminate", - request_seq: this.pendingTerminateSeq, - seq: 0, - success: true, - body: {}, - }); - this.pendingTerminateSeq = undefined; - } - - // ── Attach and termination handling ──────────────────────────────── - - /** Clear attach timeout when debugpy responds. */ - private handleAttachResponse(msg: DapMessage): void { - if ( - msg.type === "response" && - msg.command === "attach" && - msg.request_seq === this.pendingAttachSeq - ) { - if (this.attachResponseTimer) { - clearTimeout(this.attachResponseTimer); - this.attachResponseTimer = undefined; - } - this.pendingAttachSeq = undefined; - } - } - - // Implements [VSIX-PYTHON-DEBUGGER-DAP-PROXY] Quirk 4 — session termination - // timing: ensure the `exited` event is sent before `terminated` (injecting a - // synthetic `exited` if debugpy never sent one) so VS Code clears - // activeDebugSession in the right order. - /** Handle exited, thread, and terminated events. Returns true if consumed. */ - private handleTerminationEvents(msg: DapMessage): boolean { - if (msg.type !== "event") {return false;} - - if (msg.event === "exited") { - this.sawExitedEvent = true; - Logger.info(`[DAP Proxy] exited, code=${numberField(msg.body, "exitCode")}`); - } - - if (msg.event === "thread") { - const reason = stringField(msg.body, "reason"); - const threadId = numberField(msg.body, "threadId"); - Logger.info(`[DAP Proxy] thread: reason=${reason}, id=${threadId}`); - } - - if (msg.event === "terminated") { - Logger.info(`[DAP Proxy] terminated, sawExited=${this.sawExitedEvent}`); - if (!this.sawExitedEvent) { - Logger.info("[DAP Proxy] injecting exited event before terminated"); - this.sendToClient({ type: "event", event: "exited", seq: 0, body: { exitCode: 0 } }); - } - this.sawTerminatedEvent = true; - this.sendToClient(msg); - this.answerPendingTerminate(); - return true; - } - - return false; - } - - /** Ensure VS Code observes termination even if debugpy closes before sending `terminated`. */ - private completeTermination(reason: string): void { - if (!this.sawTerminatedEvent) { - Logger.warn(`[DAP Proxy] synthesizing terminated event: ${reason}`); - if (!this.sawExitedEvent) { - this.sawExitedEvent = true; - this.sendToClient({ type: "event", event: "exited", seq: 0, body: { exitCode: 0 } }); - } - this.sawTerminatedEvent = true; - this.sendToClient({ type: "event", event: "terminated", seq: 0, body: {} }); - } - this.answerPendingTerminate(); - this.closeClientConnection(); - } - - /** Close the VS Code side after queued DAP responses/events have been written. */ - private closeClientConnection(): void { - this.clientSocket?.end(); - this.server?.close(); - } - - /** - * Check if a line in a source file is a structural line (try:) - * that debugpy stops on but doesn't execute meaningful code. - */ - private isStructuralLine(filePath: string, lineNumber: number): boolean { - let lines = this.sourceCache.get(filePath); - if (!lines) { - try { - const content = fs.readFileSync(filePath, "utf-8"); - lines = content.split("\n"); - this.sourceCache.set(filePath, lines); - } catch { - return false; - } - } - const idx = lineNumber - 1; // DAP lines are 1-based - if (idx < 0 || idx >= lines.length) {return false;} - return STRUCTURAL_LINE_RE.test(lines[idx]); - } -} diff --git a/vscode-extension/src/debug-adapter.ts b/vscode-extension/src/debug-adapter.ts deleted file mode 100644 index 6fe7bd3e8..000000000 --- a/vscode-extension/src/debug-adapter.ts +++ /dev/null @@ -1,561 +0,0 @@ -// Implements [VSIX-PYTHON-DEBUGGER-DAP]. See docs/specs/VSIX-SPEC.md#VSIX-PYTHON-DEBUGGER-DAP -/** - * Debug adapter factory, DAP tracker, and logging utilities for Basilisk. - */ - -import { asRecord, booleanField, isRecord, numberField, rawField, recordArrayField, recordField, stringField } from "./unknown-shape"; -import * as vscode from "vscode"; -import * as net from "net"; -import { type LanguageClient } from "vscode-languageclient/node"; -import { Logger } from "./logger"; -import { DapTcpProxy } from "./dap-proxy"; -import type { Result } from "./result"; -import { - appendDebugOutput, - clearDebugOutput, - trackResumeRequest, - trackResumeResponse, - trackSuspensionEvent, -} from "./dap-output"; - -/** Max number of variables to log inline before switching to a count summary. */ -const MAX_INLINE_VARS = 10; - -/** Length of an abbreviated session ID prefix. */ -const SESSION_ID_PREFIX_LEN = 8; - -// ── DAP message summarization ───────────────────────────────────────────── - -/** Compact summary of DAP request arguments for logging. */ -export function summarizeArgs(args: unknown): string { - if (!isRecord(args)) {return "";} - const obj = args; - const parts: string[] = []; - if ("threadId" in obj) {parts.push(`thread=${String(obj.threadId)}`);} - if ("expression" in obj) {parts.push(`expr="${String(obj.expression)}"`);} - if ("frameId" in obj) {parts.push(`frame=${String(obj.frameId)}`);} - if ("context" in obj) {parts.push(`ctx=${String(obj.context)}`);} - if ("program" in obj) {parts.push(`program=${String(obj.program).split("/").pop()}`);} - if ("lines" in obj) {parts.push(`lines=${JSON.stringify(obj.lines)}`);} - summarizeBreakpointsAndSource(obj, parts); - return parts.length > 0 ? `{${parts.join(", ")}}` : ""; -} - -function summarizeBreakpointsAndSource(obj: Record, parts: string[]): void { - if ("breakpoints" in obj) { - const bps = recordArrayField(obj, "breakpoints"); - parts.push(`bps=[${bps.map((b) => numberField(b, "line")).join(",")}]`); - } - if ("source" in obj) { - const path = stringField(recordField(obj, "source"), "path"); - if (path !== undefined && path !== "") {parts.push(`src=${path.split("/").pop()}`);} - } -} - -/** Compact summary of DAP response/event body for logging. */ -export function summarizeBody(body: unknown): string { - if (!isRecord(body)) {return "";} - const obj = body; - const parts: string[] = []; - summarizeScalarFields(obj, parts); - summarizeCollectionFields(obj, parts); - return parts.length > 0 ? `{${parts.join(", ")}}` : ""; -} - -function summarizeScalarFields(obj: Record, parts: string[]): void { - if ("reason" in obj) {parts.push(`reason=${String(obj.reason)}`);} - if ("threadId" in obj) {parts.push(`thread=${String(obj.threadId)}`);} - if ("allThreadsStopped" in obj) {parts.push(`allStopped=${String(obj.allThreadsStopped)}`);} - if ("line" in obj) {parts.push(`line=${String(obj.line)}`);} - if ("name" in obj) {parts.push(`name=${String(obj.name)}`);} - if ("result" in obj) {parts.push(`result=${String(obj.result)}`);} -} - -function summarizeCollectionFields(obj: Record, parts: string[]): void { - if ("stackFrames" in obj) { - const frames = recordArrayField(obj, "stackFrames"); - if (frames.length > 0) { - parts.push(`frames=[${frames.map((f) => `${String(stringField(f, "name"))}:${String(numberField(f, "line"))}`).join(", ")}]`); - } - } - if ("scopes" in obj) { - const scopes = recordArrayField(obj, "scopes"); - parts.push(`scopes=[${scopes.map((sc) => String(stringField(sc, "name"))).join(", ")}]`); - } - if ("variables" in obj) { - const vars = recordArrayField(obj, "variables"); - if (vars.length <= MAX_INLINE_VARS) { - parts.push(`vars=[${vars.map((v) => `${String(stringField(v, "name"))}=${String(stringField(v, "value"))}`).join(", ")}]`); - } else { - parts.push(`vars=[${vars.length} items]`); - } - } - if ("threads" in obj) { - const threads = recordArrayField(obj, "threads"); - parts.push(`threads=[${threads.map((t) => `${String(numberField(t, "id"))}:${String(stringField(t, "name"))}`).join(", ")}]`); - } -} - -// ── DAP message tracker ─────────────────────────────────────────────────── - -/** Callbacks the DAP tracker fires on profiler-relevant debuggee events. */ -export interface DebugTrackerCallbacks { - /** Receives `(sessionId, pid)` once debugpy emits its `process` event. */ - readonly onDebuggeeProcessId?: DebuggeeProcessIdCallback; - /** - * Receives `(sessionId, body)` on every `stopped` event — the memory autopilot - * captures on pause off this signal ([PROFILE-MEMORY-AUTOPILOT-PAUSE]). Fired - * AFTER the suspension bookkeeping is recorded, so a handler can immediately - * resolve the stopped frame. - */ - readonly onStopped?: (sessionId: string, body: unknown) => void; -} - -// Implements [VSIX-PYTHON-DEBUGGER-DAP-TRACKER] — single observability point for -// debugpy → VS Code traffic. Captures the `process` event (systemProcessId, used -// by the CPU profiler) and `output` events (__BASILISK_MEM*__ payloads for the -// memory round-trip). -/** - * Factory that creates per-session DAP message trackers. - * - * The tracker is the single observability point for debugpy → VS Code traffic, - * so it captures the debuggee `process` event (the PID the CPU profiler targets — - * "same process"), `output` events (the marker payloads the memory round-trip - * recovers), and `stopped` events (suspension bookkeeping + the autopilot's - * pause trigger). Callbacks, when supplied, route those out. - */ -export class BasiliskDebugAdapterTrackerFactory - implements vscode.DebugAdapterTrackerFactory -{ - constructor(private readonly callbacks: DebugTrackerCallbacks = {}) {} - - public createDebugAdapterTracker( - session: vscode.DebugSession - ): vscode.ProviderResult { - return new BasiliskDebugAdapterTracker(session, this.callbacks); - } -} - -class BasiliskDebugAdapterTracker implements vscode.DebugAdapterTracker { - private readonly sessionId: string; - private readonly fullSessionId: string; - private readonly sessionName: string; - - constructor( - session: vscode.DebugSession, - private readonly callbacks: DebugTrackerCallbacks - ) { - this.sessionId = session.id.slice(0, SESSION_ID_PREFIX_LEN); - this.fullSessionId = session.id; - this.sessionName = session.name; - } - - public onWillStartSession(): void { - Logger.info(`[DAP ${this.sessionId}] session "${this.sessionName}" starting`); - } - - public onWillStopSession(): void { - Logger.info(`[DAP ${this.sessionId}] session "${this.sessionName}" stopping`); - clearDebugOutput(this.fullSessionId); - } - - public onWillReceiveMessage(message: unknown): void { - if (stringField(message, "type") === "request") { - const command = stringField(message, "command"); - const seq = numberField(message, "seq"); - Logger.debug(`[DAP ${this.sessionId}] --> ${command} #${seq} ${summarizeArgs(rawField(message, "arguments"))}`); - // Resume bookkeeping: a successful continue/step RESPONSE implies the - // thread runs (the `continued` event is optional per the DAP spec), so - // in-flight resume requests are remembered here and matched below. - trackResumeRequest(this.fullSessionId, message); - } - } - - public onDidSendMessage(message: unknown): void { - const msg = asRecord(message); - const type = stringField(msg, "type"); - const success = booleanField(msg, "success"); - if (type === "response") { - const command = stringField(msg, "command"); - const requestSeq = numberField(msg, "request_seq"); - const text = `[DAP ${this.sessionId}] <-- ${command} #${requestSeq} success=${success} ${summarizeBody(rawField(msg, "body"))}`; - if (success === true) { - Logger.debug(text); - } else { - Logger.warn(text); - } - // A successful resume response clears the stopped bookkeeping NOW — - // waiting for the optional `continued` event leaves a stale window - // where couriers evaluate against a sampled frame of a running thread. - trackResumeResponse(this.fullSessionId, message); - } else if (type === "event") { - this.handleEvent(stringField(msg, "event"), rawField(msg, "body")); - } - } - - /** Capture profiler-relevant events; log the rest. */ - private handleEvent(event: string | undefined, body: unknown): void { - if (event === "output") { - // Capture debuggee stdout/stderr so the memory round-trip can recover - // the `__BASILISK_MEM*__` marker its injection scripts print (debugpy - // delivers print() output here, not in the evaluate result). - const text = stringField(body, "output"); - if (text !== undefined) { - appendDebugOutput(this.fullSessionId, text); - } - return; - } - if (event === "process") { - // The debuggee's OS PID — captured so the CPU profiler can attach to the - // SAME process the debugger drives (DAP: body.systemProcessId). - const pid = numberField(body, "systemProcessId"); - if (pid !== undefined && this.callbacks.onDebuggeeProcessId !== undefined) { - Logger.info(`[DAP ${this.sessionId}] debuggee systemProcessId=${pid}`); - this.callbacks.onDebuggeeProcessId(this.fullSessionId, pid); - } - return; - } - Logger.debug(`[DAP ${this.sessionId}] <-- event:${event} ${summarizeBody(body)}`); - if (event === "stopped" || event === "continued") { - // Pause bookkeeping for the memory/cooperative couriers — see - // `currentStoppedFrameId` (dap-evaluate.ts) for why this can't be probed. - trackSuspensionEvent(this.fullSessionId, event, body); - } - if (event === "stopped") { - // The memory autopilot captures on every genuine user pause - // ([PROFILE-MEMORY-AUTOPILOT-PAUSE]). Fired after the bookkeeping above so - // the handler can resolve the now-stopped frame straight away. - this.callbacks.onStopped?.(this.fullSessionId, body); - } - if (event === "terminated") { - Logger.info(`[DAP ${this.sessionId}] program terminated`); - } - } - - public onError(error: Error): void { - Logger.error(`[DAP ${this.sessionId}] ${error.message}`); - } - - public onExit(code: number | undefined, signal: string | undefined): void { - Logger.warn(`[DAP ${this.sessionId}] exit code=${code ?? "?"}, signal=${signal ?? "none"}`); - } -} - -// ── Debug adapter factory ───────────────────────────────────────────────── - -// Implements [VSIX-PYTHON-DEBUGGER-DAP-PROXY] Quirk 3 — single-connection slot -// protection: a bind-based liveness probe (EADDRINUSE = alive) is non-destructive -// (it does not consume debugpy's one TCP slot). handleAttachMode respawns debugpy -// via the LSP when the port is dead. -/** - * Non-destructive port check — attempts to bind to the port. - * If binding fails with EADDRINUSE, something is listening. - */ -async function isPortAlive(_host: string, port: number): Promise { - return new Promise((resolve) => { - const server = net.createServer(); - server.once("error", (err: NodeJS.ErrnoException) => { - resolve(err.code === "EADDRINUSE"); - }); - server.listen(port, "127.0.0.1", () => { - server.close(() => { resolve(false); }); - }); - }); -} - -/** Callback that receives the debuggee OS PID once debugpy emits its `process` event. */ -export type DebuggeeProcessIdCallback = (sessionId: string, pid: number) => void; - -// Implements [VSIX-PYTHON-DEBUGGER-DAP-FEATURES] (Attach) + [VSIX-PYTHON-DEBUGGER- -// DAP-LAUNCH-CONFIGURATIONS] (request:"attach", connect:{host,port}) — connects to -// the user-specified debugpy host:port via the proxy, respawning debugpy through -// the LSP if the slot is dead (Quirk 3). -/** Handle attach mode: connect to user-specified host:port, respawning if needed. */ -async function handleAttachMode( - config: vscode.DebugConfiguration, - lspClient: LanguageClient | undefined -): Promise { - const connectInfo = asRecord(config.connect); - // Falls back to the IPv4 literal, never the name `localhost`: the server - // side binds `127.0.0.1`, and on Windows `localhost` resolves to `::1` - // first, where nothing listens ([LSPDEBUG-START]). - let host = stringField(connectInfo, "host") ?? "127.0.0.1"; - let port = numberField(connectInfo, "port") ?? 0; - Logger.info(`[Basilisk Debug] Attach mode → ${host}:${port}`); - - const alive = await isPortAlive(host, port); - if (!alive && lspClient) { - Logger.warn(`[Basilisk Debug] Port ${port} is dead — respawning debugpy adapter`); - try { - const result = await lspClient.sendRequest<{ host: string; port: number } | null>( - "workspace/executeCommand", - { - command: "basilisk.startDebugSession", - arguments: [{ python: stringField(config, "python") ?? null }], - } - ); - if (result !== undefined && result !== null && typeof result.port === "number") { - Logger.info(`[Basilisk Debug] Respawned debugpy on ${result.host}:${result.port}`); - host = result.host; - port = result.port; - } - } catch (err: unknown) { - const msg = err instanceof Error ? err.message : String(err); - Logger.error(`[Basilisk Debug] Respawn failed: ${msg}`); - } - } - - const proxy = new DapTcpProxy(host, port); - const proxyPort = await proxy.start(); - Logger.info(`[Basilisk Debug] attach proxy listening on port ${proxyPort}`); - return new vscode.DebugAdapterServer(proxyPort); -} - -/** Send startDebugSession to LSP and handle errors. */ -async function requestDebugSession( - lspClient: LanguageClient, - python: string | null -): Promise<{ host: string; port: number; sessionId: string }> { - try { - const result = await lspClient.sendRequest<{ host: string; port: number; sessionId: string } | null>( - "workspace/executeCommand", - { command: "basilisk.startDebugSession", arguments: [{ python }] } - ); - if (!result || typeof result.port !== "number") { - throw new Error( - "LSP returned null for basilisk.startDebugSession. " + - "Check the Basilisk output channel for details." - ); - } - return result; - } catch (err: unknown) { - const msg = err instanceof Error ? err.message : String(err); - Logger.error(`Debug session start failed: ${msg}`); - showDebugError(msg); - throw new Error(`Basilisk: ${msg}`); - } -} - -/** Show user-facing error messages for debug session failures. */ -function showDebugError(msg: string): void { - if (msg.includes("debugpy not found") || msg.includes("pip install debugpy")) { - void vscode.window.showErrorMessage( - `Basilisk Debug: debugpy is not installed. Run: pip install debugpy`, - "Install debugpy" - ).then((choice) => { - if (choice === "Install debugpy") { - const terminal = vscode.window.createTerminal("Basilisk"); - terminal.show(); - terminal.sendText("pip install debugpy"); - } - }); - } else if (msg.includes("No Python interpreter") || msg.includes("python")) { - vscode.window.showErrorMessage( - `Basilisk Debug: No Python interpreter found. Set basilisk.python or create a virtualenv.` - ); - } else { - vscode.window.showErrorMessage(`Basilisk Debug: Failed to start debug session: ${msg}`); - } -} - -// Implements [VSIX-PYTHON-DEBUGGER-DAP-ARCHITECTURE] — the LSP spawns -// `debugpy.adapter --port ` via basilisk.startDebugSession; the proxy then -// connects to that port and is returned to VS Code as a DebugAdapterServer. -/** Handle launch mode: ask LSP to spawn debugpy. */ -async function handleLaunchMode( - config: vscode.DebugConfiguration, - lspClient: LanguageClient -): Promise { - const configuredPython = - stringField(config, "python") ?? - vscode.workspace.getConfiguration("basilisk").get("python") ?? - null; - - Logger.info(`Requesting LSP to spawn debugpy (python: ${configuredPython ?? "auto-detect"})...`); - const result = await requestDebugSession(lspClient, configuredPython); - Logger.info(`LSP spawned debugpy on ${result.host}:${result.port} (session: ${result.sessionId})`); - - const proxy = new DapTcpProxy(result.host, result.port); - const proxyPort = await proxy.start(); - Logger.info(`[Basilisk Debug] launch proxy listening on port ${proxyPort}`); - return new vscode.DebugAdapterServer(proxyPort); -} - -/** - * How long a debug launch waits for the language server to come up. - * - * Sized for a cold start, not a warm one: on win32 spawning the server binary - * and completing the handshake takes ~10s, and a user who opens a project and - * immediately presses F5 is inside that window every time. - */ -const LSP_READY_FOR_DEBUG_MS = 60_000; - -/** - * Create a debug adapter factory bound to the given LSP readiness accessor. - * - * The accessor waits for the client to reach Running rather than handing back - * whatever reference exists. A client that merely EXISTS may still be - * `Starting`, and a request sent into that state is never answered and never - * rejected — the debug session just hangs, with nothing written anywhere to - * say why ([VSIX-CI-PLATFORM-COVERAGE-CLASSES]). - */ -export function createDebugAdapterFactory( - ensureLspReady: (timeoutMs: number) => Promise> -): vscode.DebugAdapterDescriptorFactory { - return { - async createDebugAdapterDescriptor( - session: vscode.DebugSession - ): Promise { - const config = session.configuration; - Logger.info( - `[Basilisk Debug] createDebugAdapterDescriptor called — ` + - `type=${config.type}, request=${config.request}, ` + - `program=${config.program ?? "(none)"}` - ); - - const ready = await ensureLspReady(LSP_READY_FOR_DEBUG_MS); - if (!ready.ok) { - // Attach mode tolerates a missing client (it can connect to an - // already-running debugpy), so only a launch is fatal here. - if (config.request === "attach" && config.connect !== undefined && config.connect !== null) { - Logger.warn(`[Basilisk Debug] attaching without a ready LSP: ${ready.error.message}`); - return handleAttachMode(config, undefined); - } - Logger.error(`[Basilisk Debug] LSP not ready: ${ready.error.message}`); - throw new Error( - `Basilisk: the language server is not running, so the debug session cannot start ` + - `(${ready.error.message}). Check the Basilisk output channel.` - ); - } - - if (config.request === "attach" && config.connect !== undefined && config.connect !== null) { - return handleAttachMode(config, ready.value); - } - return handleLaunchMode(config, ready.value); - }, - }; -} - -// ── Debug configuration provider ────────────────────────────────────────── - -/** A config field is "blank" when undefined (VS Code's empty `{}`) or empty. */ -function isBlank(value: string | undefined): boolean { - return value === undefined || value === ""; -} - -/** - * VS Code's own substitution variable for the active editor's file, resolved by - * VS Code before the config reaches the adapter. It is a literal `${file}` on - * the wire, never a JavaScript template placeholder — hence the one disable. - */ -// eslint-disable-next-line no-template-curly-in-string -- VS Code variable syntax, not a template literal -export const ACTIVE_FILE_VARIABLE = "${file}"; - -// Implements [VSIX-PYTHON-DEBUGGER-DAP-LAUNCH-CONFIGURATIONS] (launch shape) — -// the zero-config "launch" configuration (type/request/program) offered in the -// Run-and-Debug picker and used to fill an empty/partial config. -/** The default launch config for the current file. */ -function defaultLaunchConfig(): vscode.DebugConfiguration { - return { - name: "Python: Current File (Basilisk)", - type: "basilisk-debug", - request: "launch", - program: ACTIVE_FILE_VARIABLE, - console: "internalConsole", - redirectOutput: true, - justMyCode: true, - }; -} - -/** - * Synthesize/complete a runnable `basilisk-debug` config (program defaulting). - * - * This is what makes "Run and Debug" / F5 work **without a launch.json**: VS - * Code calls the provider with an empty config (no type), and for a Python file - * we synthesize a launch of the current file. A partial config missing - * `program` defaults to `${file}`. Pure (no VS Code APIs). - */ -function withProgramDefaults( - config: vscode.DebugConfiguration, - activeLanguageId: string | undefined, -): vscode.DebugConfiguration { - // Empty config (F5 / "Run and Debug" with no launch.json — VS Code passes `{}`): - // only synthesize one for a Python file, else leave it for VS Code to report - // "open a file". Falsy check also tolerates blank fields from a stub config. - if (isBlank(config.type) && isBlank(config.request) && isBlank(config.name)) { - return activeLanguageId === "python" ? defaultLaunchConfig() : config; - } - // A launch config missing `program` targets the active file. - if ( - config.type === "basilisk-debug" && - config.request === "launch" && - isBlank(stringField(config, "program")) - ) { - return { ...config, program: ACTIVE_FILE_VARIABLE }; - } - return config; -} - -// Implements [VSIX-PYTHON-DEBUGGER-START] — pure config defaulting for the -// factory-based `basilisk-debug` debugger: fills an empty/partial config so F5 / -// "Run and Debug" launch the active Python file with no launch.json. -/** - * Resolve a runnable `basilisk-debug` config, defaulting `program` and marking - * profiling runs. - * - * When the global `basilisk.profiler.profileOnLaunch` setting is on, every - * CPU-profilable basilisk-debug launch is a profiling run, so it is marked - * `profileOnLaunch: true`. That flag makes the DAP proxy neutralise the user's - * breakpoints so the run completes instead of stopping interactively - * ([PROFILE-LAUNCH-NOSTOP], #145) — matching `shouldProfileOnLaunch`'s two - * equivalent triggers (the explicit launch arg, or this global setting). - * - * A "Run & Track Memory" launch (`memoryTrackOnLaunch`) is explicitly excluded: - * it is not a CPU run, and stamping it would (a) strip its breakpoints and - * (b) make the CPU sampler auto-start alongside tracemalloc, the two fighting - * over the single entry pause (dap-1). Pure (the setting is passed in) so it - * stays unit-testable; the active language id is passed in too. - */ -export function applyDebugConfigDefaults( - config: vscode.DebugConfiguration, - activeLanguageId: string | undefined, - profileOnLaunchGlobal = false, -): vscode.DebugConfiguration { - const resolved = withProgramDefaults(config, activeLanguageId); - if ( - profileOnLaunchGlobal && - resolved.type === "basilisk-debug" && - resolved.request === "launch" && - resolved.profileOnLaunch !== true && - resolved.memoryTrackOnLaunch !== true - ) { - return { ...resolved, profileOnLaunch: true }; - } - return resolved; -} - -// Implements [VSIX-PYTHON-DEBUGGER-START] — the DebugConfigurationProvider for -// `basilisk-debug` (registered Dynamic + default in extension.ts), offering a -// "Python: Current File (Basilisk)" entry and resolving empty/partial configs. -/** - * Provider that lets `basilisk-debug` start with no `launch.json`: it offers a - * default configuration in the Run-and-Debug picker and resolves empty/partial - * configs to a launch of the current file. - */ -export function createBasiliskDebugConfigProvider(): vscode.DebugConfigurationProvider { - return { - provideDebugConfigurations(): vscode.DebugConfiguration[] { - return [defaultLaunchConfig()]; - }, - resolveDebugConfiguration( - _folder: vscode.WorkspaceFolder | undefined, - config: vscode.DebugConfiguration, - ): vscode.DebugConfiguration { - return applyDebugConfigDefaults( - config, - vscode.window.activeTextEditor?.document.languageId, - vscode.workspace.getConfiguration("basilisk").get("profiler.profileOnLaunch", false), - ); - }, - }; -} diff --git a/vscode-extension/src/editor-path-key.ts b/vscode-extension/src/editor-path-key.ts deleted file mode 100644 index 5702a3e70..000000000 --- a/vscode-extension/src/editor-path-key.ts +++ /dev/null @@ -1,76 +0,0 @@ -// Implements [VSIX-CI-PLATFORM-COVERAGE] path keying. See docs/specs/VSIX-SPEC.md#VSIX-CI-PLATFORM-COVERAGE -/** - * Keying a file path so a runtime's paths and the editor's agree. - * - * Every in-editor overlay Basilisk paints — the CPU heat map, the memory track, - * the leak badges — matches rows produced by a Python runtime against the - * editors the user has open. Those two paths come from different producers and - * are only textually identical on POSIX: the runtime reports the interpreter's - * own filename, while `Uri.fsPath` hands back what VS Code resolved. - * - * On Windows they disagree in two ways — the drive letter's case, and 8.3 short - * components — so a raw string compare NEVER matches there and the overlay - * silently paints nothing: the data is correct, the editor just stays blank. - * That is a whole-feature outage that looks like "no results", which is exactly - * why it survived until win32 ran in CI. - * - * Shared rather than copied: the profiler learned this first, and the memory - * decorations had the identical latent bug. - */ - -import * as path from "path"; -import * as fs from "fs"; - -/** - * Key a path for cross-producer comparison. - * - * Windows paths are case-insensitive, so folding case there is sound; POSIX - * paths are case-SENSITIVE, so it must not fold there. - */ -export function editorPathKey(file: string): string { - return process.platform === "win32" ? expandedWindowsPath(file).toLowerCase() : path.resolve(file); -} - -/** - * A Windows path with any 8.3 short component expanded to its real name. - * - * Case-folding alone is not enough. The two producers reach the same file by - * different routes: `os.tmpdir()` yields the short form Windows keeps for - * legacy callers (`C:\Users\RUNNER~1\…`), while a path the debug adapter or the - * editor resolved carries the long one (`C:\Users\runneradmin\…`). Those differ - * in more than case, so `path.resolve` leaves them unequal and the overlay - * matches nothing. - * - * `realpathSync.native` is what collapses the two spellings — it asks the - * filesystem for the name it actually records. It therefore touches disk and - * throws for a path that no longer exists, so a file that has since been - * deleted falls back to the resolved-but-unexpanded form rather than taking the - * whole decoration pass down with it. Callers memoise (see `pathKeyer`) so this - * costs one lookup per distinct file, not one per hot line. - */ -function expandedWindowsPath(file: string): string { - try { - return fs.realpathSync.native(file); - } catch { - return path.resolve(file); - } -} - -/** - * `editorPathKey` memoised for the length of one decoration pass. - * - * A profile or snapshot carries many rows across few files, and the expansion - * above hits the filesystem — so the same handful of paths would otherwise be - * looked up hundreds of times. The cache is per-pass rather than module-level - * so a file that moves between runs is never matched against a stale name. - */ -export function pathKeyer(): (file: string) => string { - const cache = new Map(); - return (file: string): string => { - const hit = cache.get(file); - if (hit !== undefined) { return hit; } - const key = editorPathKey(file); - cache.set(file, key); - return key; - }; -} diff --git a/vscode-extension/src/extension.ts b/vscode-extension/src/extension.ts index 61087c11e..4c72856c2 100644 --- a/vscode-extension/src/extension.ts +++ b/vscode-extension/src/extension.ts @@ -1,497 +1,126 @@ -// Implements [VSIX]. See docs/specs/VSIX-SPEC.md#VSIX +// Implements [WITHDRAWAL-SURFACES] and [WITHDRAWAL-INERT]. +// See docs/specs/DOCS-WITHDRAWAL-MESSAGING-SPEC.md#WITHDRAWAL-SURFACES /** - * Basilisk VS Code Extension + * Basilisk for VS Code — a notice. * - * Supports both subprocess mode (basilisk check --output json) and - * LSP mode (basilisk lsp) based on configuration. + * Basilisk's type checker was producing incorrect results, so this extension no + * longer contains one. It bundles no binary, starts no language server, + * publishes no diagnostic, and contributes no setting: there is nothing left + * for a user to configure or an editor to run. It exists only so that an + * already-installed copy tells its owner what happened and how to remove it. + * + * The statement is NOT authored here. `withdrawal-notice.ts` is generated from + * the messaging spec by scripts/gen_withdrawal_copy.py and drift-gated in CI + * ([WITHDRAWAL-INERT-TEXT]), so this extension cannot say its own version of it. */ import * as vscode from "vscode"; -import * as path from "path"; -import * as fs from "fs"; -import { Logger, bindLogger, CompositeSink, FileLogSink, nullSink } from "./logger"; -import type { LogSink } from "./logger"; -import { startLspClient } from "./lsp-client"; -import { stopClientSettled } from "./lsp-client-stop"; -import { createDebugAdapterFactory, BasiliskDebugAdapterTrackerFactory, createBasiliskDebugConfigProvider } from "./debug-adapter"; -import { startSubprocessMode } from "./subprocess-mode"; -import { registerTestExplorer } from "./test-explorer"; -import { registerModuleExplorer } from "./module-explorer"; -import { registerInfoPanel } from "./info-panel"; -import { registerPythonProcesses } from "./process-explorer"; -import { registerConfigurationEditor } from "./configuration-editor-registration"; -import { createStore, type Store } from "./store"; -import { registerProfiler, disposeProfiler } from "./profiler"; -import { registerMemoryProfiler, disposeMemoryProfiler } from "./memory-profiler"; -import { registerMemoryAutopilot, disposeMemoryAutopilot, notifyDebuggeePause } from "./memory-autopilot"; -import { reportRuntimeFailure, resolveBasiliskRuntime } from "./shipwright-runtime"; +import { WITHDRAWAL_NOTICE } from "./withdrawal-notice"; -/** Priority for the Basilisk status bar item (higher = further left). */ -const STATUS_BAR_PRIORITY = 100; +/** The full statement lives here; the notice points at it. */ +export const STATEMENT_URL = "https://www.basilisk-python.dev/"; -/** Length of an abbreviated session ID prefix for logging. */ -const SESSION_ID_PREFIX_LEN = 8; +/** Virtual-document scheme for the read-only statement. */ +export const NOTICE_SCHEME = "basilisk-notice"; -let store: Store | undefined; +/** The statement, opened by `basilisk.showStatement`. */ +export const NOTICE_URI = vscode.Uri.parse(`${NOTICE_SCHEME}:Basilisk is unlisted.md`); -/** - * Saved extension context — retained across deactivate/activate cycles so - * that re-activation can re-initialize the extension without a fresh - * context from VS Code. - */ -let savedContext: vscode.ExtensionContext | undefined; +/** Command that opens the statement in the editor. */ +export const SHOW_STATEMENT_COMMAND = "basilisk.showStatement"; -/** - * Set to true by deactivate(). While true, getStore() returns undefined. - * Cleared by the next call to activate() or by the lazy re-init path - * in getStore(). - */ -let pendingReactivation = false; +/** `globalState` key holding the version whose notice has been shown. */ +export const ANNOUNCED_KEY = "basilisk.announcedVersion"; +/** Label of the notification action that opens the statement. */ +export const READ_ACTION = "Read the statement"; -/** - * Disposables for one-time-only registrations (debug adapter factories, - * lifecycle event listeners) that must be disposed before re-init. - * Unlike context.subscriptions, we control disposal timing. - */ -let singletonDisposables: vscode.Disposable[] = []; +/** One line, because the rest of the message is a click away. */ +export const ANNOUNCEMENT = + "Basilisk is unlisted. Its type checker was producing incorrect results and is no longer part of this extension — it checks nothing. Uninstall Basilisk."; -/** Adapts a VS Code LogOutputChannel to our LogSink interface. */ -class VscodeLogSink implements LogSink { - constructor(private readonly channel: vscode.LogOutputChannel) {} - public trace(message: string): void { this.channel.trace(message); } - public debug(message: string): void { this.channel.debug(message); } - public info(message: string): void { this.channel.info(message); } - public warn(message: string): void { this.channel.warn(message); } - public error(message: string): void { this.channel.error(message); } -} +/** Asks the user something and resolves with the action they chose. */ +export type Prompt = (message: string, action: string) => Thenable; /** - * Returns the store — available after activate(). - * - * Handles two recovery paths for cross-session testing: - * - * 1. After deactivate(): first call returns undefined (proves cleanup). - * The NEXT call lazily re-initializes using the saved context. - * - * 2. After store.reset(): the store exists but is gutted (idle, no - * client). We null it and re-init so the caller gets a working store. - * - * Both paths are needed because VS Code's ext.activate() is a no-op - * for an already-active extension — our activate() won't be re-called. + * The slice of `vscode.Memento` the announcement needs. Narrowing the + * dependency to two methods is what lets the once-per-version rule be tested + * without a fabricated `ExtensionContext`. */ -export function getStore(): Store | undefined { - // After deactivate(): first call returns undefined to prove cleanup. - if (pendingReactivation) { - pendingReactivation = false; - return undefined; - } - - // Lazy re-init after deactivate() or store.reset(). - // store.reset() sets store = undefined via its onReset callback. - if (store === undefined && savedContext !== undefined) { - initExtension(savedContext); - } - - return store; -} - -export function activate(context: vscode.ExtensionContext): void { - savedContext = context; - pendingReactivation = false; - initExtension(context); +export interface AnnouncementState { + get(key: string): string | undefined; + update(key: string, value: string): Thenable; } /** - * Core initialization — extracted so both the initial activate() and - * post-deactivate re-activation can share the same code path. + * The document text: the approved notice, plus a pointer to the full statement. + * The pointer is a link, not a restatement — no surface writes its own version + * of the message. */ -/** Whether this is the first call to initExtension (full setup). */ -let firstInit = true; - -function initExtension(context: vscode.ExtensionContext): void { - store = createStore(() => { - // When store.reset() is called (e.g. test teardown), restart the - // LSP client so commands get re-registered when the server reaches - // Running state. This keeps the same store object alive. - if (store !== undefined && savedContext !== undefined) { - void startRuntime(savedContext, store); - } - }); - - if (firstInit) { - initLogging(context, store); - initStatusBar(context, store); - } - - const useLsp = vscode.workspace.getConfiguration("basilisk").get("useLsp") ?? true; - - if (firstInit) { - registerPanelsAndCommands(context, store); - } - - if (useLsp) { - if (firstInit) { - // Debug adapter factories and test controller can only be registered - // once. On re-init they are disposed+re-created via singletonDisposables. - registerDebugSupport(context, store); - const testController = registerTestExplorer(context, store); - singletonDisposables.push(testController); - } - } else { - updateStatusBar("starting"); - } - - void startRuntime(context, store); - - if (firstInit) { - context.subscriptions.push( - vscode.languages.onDidChangeDiagnostics(() => { updateStatusBarDiagnostics(); }) - ); - context.subscriptions.push( - vscode.window.onDidChangeActiveTextEditor(() => { updateStatusBarDiagnostics(); }) - ); - firstInit = false; - } +export function statementText(): string { + return `${WITHDRAWAL_NOTICE}\nThe full statement: ${STATEMENT_URL}\n`; } -// Implements [EXTACT] — wires up the Basilisk activity sidebar (Modules + Basilisk -// info panels) plus the profiling/memory UI and the Getting Started walkthrough. /** - * Register activity panels, profiler UI, memory profiler, and walkthrough. - * Called once on the first activation only. + * Whether this activation should interrupt the user. + * + * Once per installed version. Silence would leave the checker's owner none the + * wiser, and re-announcing on every window would be nagging about something + * they cannot fix from here. */ -function registerPanelsAndCommands(context: vscode.ExtensionContext, s: Store): void { - // Set context key so panel visibility conditions work. - const hasWorkspace = (vscode.workspace.workspaceFolders?.length ?? 0) > 0; - void vscode.commands.executeCommand("setContext", "basilisk.hasWorkspace", hasWorkspace); - - // Activity bar panels — register once (tree view IDs must be unique). - // The Modules panel (module-explorer) now carries the folded type-health - // rollup, so there is no separate Type Health panel [EXTACT-MODULES]. - const moduleResult = registerModuleExplorer(context, s); - singletonDisposables.push(...moduleResult.disposables); - - const infoPanelResult = registerInfoPanel(context, s); - singletonDisposables.push(...infoPanelResult.disposables); - - // Editor-area configuration shell. Capability gating and all mutations are - // delegated to the LSP; this registration owns only VS Code lifecycle/UI. - const configurationEditor = registerConfigurationEditor(s); - singletonDisposables.push(...configurationEditor.disposables); - - // Python Processes panel — LSP-driven process picker for one-click profiling (#62). - const processesResult = registerPythonProcesses(context, s); - singletonDisposables.push(...processesResult.disposables); - - // Profiler UI — status bar, commands, decorations, flamegraph webview. - const profilerDisposables = registerProfiler(s); - singletonDisposables.push(...profilerDisposables); - - // Memory profiler UI — commands, reference graph webview, memory dashboard. - const memoryDisposables = registerMemoryProfiler(s); - singletonDisposables.push(...memoryDisposables); - - // Memory autopilot — auto snapshot+diff on every pause / interval, so the leak - // hunt is "set a breakpoint and press Continue" ([PROFILE-MEMORY-AUTOPILOT]). - singletonDisposables.push(...registerMemoryAutopilot(s)); - - // Implements [EXTACT-INFO-GETTING-STARTED] — the Getting Started items open the - // built-in `basilisk.gettingStarted` walkthrough (contributes.walkthroughs in - // package.json) directly via this command. - // Walkthrough command. - singletonDisposables.push( - vscode.commands.registerCommand("basilisk.openWalkthrough", () => { - void vscode.commands.executeCommand( - "workbench.action.openWalkthrough", - "Nimblesite.basilisk#basilisk.gettingStarted", - ); - }), - ); - - // Implements [VSIX-STATUS-BAR] — clicking the always-visible status bar item - // opens a quick-pick so configuration is reachable from anywhere in the UI, - // not only the settings cog buried in the BASILISK info panel title bar. - singletonDisposables.push( - vscode.commands.registerCommand("basilisk.statusMenu", async () => handleStatusMenu()), - ); -} - -// Implements [VSIX-STATUS-BAR] — quick-pick shown when the status bar item is -// clicked. Configuration is listed first (the primary reason a user reaches for -// it); Show Output and Restart Server remain one keystroke away. -async function handleStatusMenu(): Promise { - const items: readonly { label: string; command: string }[] = [ - { label: "$(settings-gear) Open Configuration Editor", command: "basilisk.openConfigurationEditor" }, - { label: "$(output) Show Output", command: "basilisk.showOutput" }, - { label: "$(debug-restart) Restart Language Server", command: "basilisk.restartServer" }, - ]; - const pick = await vscode.window.showQuickPick(items, { placeHolder: "Basilisk" }); - if (pick !== undefined) { - await vscode.commands.executeCommand(pick.command); - } -} - -export function deactivate(): Promise | undefined { - disposeProfiler(); - disposeMemoryProfiler(); - disposeMemoryAutopilot(); - // A client caught mid-start cannot be stopped directly — `stop()` rejects - // for any state but Running, which is how a deactivate landing inside a slow - // server spawn used to throw out of here. stopClientSettled waits for the - // start to settle first, and store.reset() below joins this same shutdown - // rather than starting a competing one. - const dyingClient = store?.client.value; - const result = dyingClient === undefined ? undefined : stopClientSettled(dyingClient); - // Set store = undefined BEFORE calling reset() so the onReset callback - // (which checks `store !== undefined`) does NOT restart the LSP client. - // Without this, reset() → onReset → startLspClient re-registers commands - // on the dying store, and the new activate() gets "command already exists". - const dyingStore = store; - store = undefined; - dyingStore?.reset(); - pendingReactivation = true; - - // Dispose singleton registrations (debug adapter factories, etc.) - // so they can be re-registered on the next activation cycle. - for (const d of singletonDisposables) { - d.dispose(); - } - singletonDisposables = []; - - // Allow full re-initialization on next activate() — without this, - // initExtension() skips debug adapters, test explorer, activity - // panels, logging, status bar, and event listeners. - firstInit = true; - - return result; +export function shouldAnnounce(announced: string | undefined, version: string): boolean { + return announced !== version; } -// ── Initialization helpers ──────────────────────────────────────────────── - -// Implements [VSIX-OUTPUT-CHANNELS] — creates the main "Basilisk" output channel -// and the file log sink. DEVIATION: the spec names the file sink -// "/tmp/basilisk-debug-trace.log", but for security (js/insecure-temporary-file) -// the log lives at context.logUri/basilisk-debug-trace.log (per-extension private -// dir), not world-writable /tmp. The "Basilisk LSP Trace" channel is created in -// lsp-client.ts. -function initLogging(context: vscode.ExtensionContext, s: Store): void { - const logChannel = vscode.window.createOutputChannel("Basilisk", { log: true }); - s.setOutputChannel(logChannel); - // Logs live in the extension's PRIVATE per-extension log directory - // (context.logUri) — never the world-writable OS temp dir. A predictable name - // in shared /tmp is open to symlink redirection and cross-user disclosure - // (js/insecure-temporary-file); the per-extension dir is owned by this user - // and is where [VSIX-OUTPUT-CHANNELS] expects logs to live. VS Code may not - // have created logUri on disk yet, so ensure it exists first. - const logDir = context.logUri.fsPath; - fs.mkdirSync(logDir, { recursive: true }); - const logFilePath = path.join(logDir, "basilisk-debug-trace.log"); - const fileSink = new FileLogSink(logFilePath); - const compositeSink = new CompositeSink([new VscodeLogSink(logChannel), fileSink]); - s.setLogSink(compositeSink); - bindLogger(() => s.logSink.value ?? nullSink); - logChannel.info(`Log file: ${logFilePath}`); - context.subscriptions.push(logChannel); +/** Narrows an unknown value to an indexable object. */ +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; } -// Implements [VSIX-STATUS-BAR] — creates the persistent status bar item whose -// text/state is driven by updateStatusBar (server state) and -// updateStatusBarDiagnostics (per-file error/warning counts). -function initStatusBar(context: vscode.ExtensionContext, s: Store): void { - const item = vscode.window.createStatusBarItem( - vscode.StatusBarAlignment.Left, - STATUS_BAR_PRIORITY - ); - item.command = "basilisk.statusMenu"; - s.setStatusBarItem(item); - context.subscriptions.push(item); +/** The installed version, or `"unknown"` when the manifest cannot be read. */ +export function extensionVersion(packageJson: unknown): string { + if (isRecord(packageJson) && typeof packageJson.version === "string") { + return packageJson.version; + } + return "unknown"; } -// Implements [VSIX-PYTHON-DEBUGGER-DAP-ARCHITECTURE] / [VSIX-PYTHON-DEBUGGER-START] -// — registers the `basilisk-debug` adapter-descriptor factory, the (Dynamic + -// default) config provider, and the tracker factory. The matching activation -// events (onDebug, onDebugResolve/onDebugDynamicConfigurations:basilisk-debug) -// are declared in vscode-extension/package.json so these register before a Python -// file is opened. [VSIX-PYTHON-DEBUGGER-DAP-TRACKER]: tracker callbacks feed PID + -// pause signals to the store. -function registerDebugSupport(context: vscode.ExtensionContext, s: Store): void { - // Debug adapter factories can only be registered once per type. - // Push to singletonDisposables so deactivate() can dispose them - // before re-init (context.subscriptions disposal is not in our control). - singletonDisposables.push( - vscode.debug.registerDebugAdapterDescriptorFactory( - "basilisk-debug", - createDebugAdapterFactory(async (ms) => s.ensureLspReadyPromise(ms)) - ) - ); - // Let users start debugging with NO launch.json: the Dynamic provider lists a - // "Python (Basilisk)" config in the Run-and-Debug picker, and resolve fills in - // the current file for an empty/partial config (F5 / the big Run button). - const debugConfigProvider = createBasiliskDebugConfigProvider(); - singletonDisposables.push( - vscode.debug.registerDebugConfigurationProvider( - "basilisk-debug", - debugConfigProvider, - vscode.DebugConfigurationProviderTriggerKind.Dynamic - ), - vscode.debug.registerDebugConfigurationProvider("basilisk-debug", debugConfigProvider) - ); - singletonDisposables.push( - vscode.debug.registerDebugAdapterTrackerFactory( - "basilisk-debug", - new BasiliskDebugAdapterTrackerFactory({ - // The tracker captures the debuggee's PID (from the DAP `process` event) - // so the CPU profiler can attach to the SAME process the debugger drives. - onDebuggeeProcessId: (sessionId, pid) => { s.setDebuggeeProcessId(sessionId, pid); }, - // …and every pause drives the memory autopilot ([PROFILE-MEMORY-AUTOPILOT-PAUSE]). - onStopped: (sessionId) => { notifyDebuggeePause(sessionId); }, - }) - ) - ); - // Forget the debuggee PID when its session ends so stale mappings can't - // misdirect a later profile attach. - context.subscriptions.push( - vscode.debug.onDidTerminateDebugSession((session) => { - s.clearDebuggeeProcessId(session.id); - }) - ); - registerDebugLifecycleLogging(context); +/** Open the statement beside whatever the user was doing. */ +export async function showStatement(): Promise { + const document = await vscode.workspace.openTextDocument(NOTICE_URI); + await vscode.window.showTextDocument(document, { preview: false }); } -function registerDebugLifecycleLogging(context: vscode.ExtensionContext): void { - context.subscriptions.push( - vscode.debug.onDidStartDebugSession((session) => { - Logger.info(`Debug session started: id=${session.id}, name=${session.name}, type=${session.type}`); - // Gate debug-only commands (memory profiling needs a paused debuggee). - if (session.type === "basilisk-debug") { - void vscode.commands.executeCommand("setContext", "basilisk.debugging", true); - } - }) - ); - context.subscriptions.push( - vscode.debug.onDidTerminateDebugSession((session) => { - const activeId = vscode.debug.activeDebugSession?.id ?? "undefined"; - Logger.info( - `[Lifecycle] onDidTerminateDebugSession: terminated=${session.id.slice(0, SESSION_ID_PREFIX_LEN)}, ` + - `active=${activeId === "undefined" ? "correctly undefined" : `STILL SET (${activeId.slice(0, SESSION_ID_PREFIX_LEN)})`}` - ); - // Clear the debug context once no Basilisk debug session remains active. - // Symmetric with the type-gated set above: stays true only while a - // basilisk-debug session is active (ignores other debuggers' sessions). - if (vscode.debug.activeDebugSession?.type !== "basilisk-debug") { - void vscode.commands.executeCommand("setContext", "basilisk.debugging", false); - } - }) - ); - context.subscriptions.push( - vscode.debug.onDidChangeActiveDebugSession((session) => { - Logger.info( - `[Lifecycle] onDidChangeActiveDebugSession: ${session ? `id=${session.id.slice(0, SESSION_ID_PREFIX_LEN)}, name="${session.name}"` : "→ NONE"}` - ); - }) - ); +/** The real prompt: a warning, not a tip — their build changed. */ +function warn(message: string, action: string): Thenable { + return vscode.window.showWarningMessage(message, action); } -// ── Status bar ──────────────────────────────────────────────────────────── - -// Implements [VSIX-STATUS-BAR] — server-state faces: starting → $(sync~spin) -// ("analyzing"), ready → $(check), error → $(error) (server failed/not running), -// stopped → $(circle-slash). Note: the spec lists only check/warning/error/ -// sync~spin; "stopped" uses $(circle-slash) (not in the spec's enumerated list). -function updateStatusBar(state: "starting" | "ready" | "error" | "stopped"): void { - // Set context key for panel visibility conditions. - void vscode.commands.executeCommand("setContext", "basilisk.serverState", state === "ready" ? "running" : state); - - const item = store?.statusBarItem.value; - if (!item) {return;} - switch (state) { - case "starting": - item.text = "$(sync~spin) Basilisk"; - item.tooltip = "Basilisk language server starting..."; - item.backgroundColor = undefined; - break; - case "ready": - item.text = "$(check) Basilisk"; - item.tooltip = "Basilisk language server running — click to configure"; - item.backgroundColor = undefined; - break; - case "error": - item.text = "$(error) Basilisk"; - item.tooltip = "Basilisk language server error"; - item.backgroundColor = new vscode.ThemeColor("statusBarItem.errorBackground"); - break; - case "stopped": - item.text = "$(circle-slash) Basilisk"; - item.tooltip = "Basilisk language server stopped"; - item.backgroundColor = undefined; - break; - } - item.show(); +/** Tell the user once per version, then stop. */ +export async function announce( + state: AnnouncementState, + version: string, + prompt: Prompt = warn, +): Promise { + if (!shouldAnnounce(state.get(ANNOUNCED_KEY), version)) { + return; + } + await state.update(ANNOUNCED_KEY, version); + if ((await prompt(ANNOUNCEMENT, READ_ACTION)) === READ_ACTION) { + await showStatement(); + } } -// Implements [VSIX-STATUS-BAR] — per-file diagnostic count face. DEVIATION from -// spec text: the spec shows "$(warning) Basilisk (3) — errors in current file", -// but errors use the $(error) icon (red errorBackground) and warnings use -// $(warning) (warningBackground); no issues → $(check). The spec's example -// conflates the warning icon with an error count. -function updateStatusBarDiagnostics(): void { - const item = store?.statusBarItem.value; - if (!item) {return;} - const editor = vscode.window.activeTextEditor; - if (editor?.document.languageId !== "python") {return;} - const diagnostics = vscode.languages.getDiagnostics(editor.document.uri); - const basiliskDiags = diagnostics.filter((d) => d.source === "basilisk"); - const errorCount = basiliskDiags.filter((d) => d.severity === vscode.DiagnosticSeverity.Error).length; - const warnCount = basiliskDiags.filter((d) => d.severity === vscode.DiagnosticSeverity.Warning).length; - - if (errorCount > 0) { - item.text = `$(error) Basilisk (${errorCount})`; - item.tooltip = `Basilisk: ${errorCount} error(s), ${warnCount} warning(s)`; - item.backgroundColor = new vscode.ThemeColor("statusBarItem.errorBackground"); - } else if (warnCount > 0) { - item.text = `$(warning) Basilisk (${warnCount})`; - item.tooltip = `Basilisk: ${warnCount} warning(s)`; - item.backgroundColor = new vscode.ThemeColor("statusBarItem.warningBackground"); - } else { - item.text = "$(check) Basilisk"; - item.tooltip = "Basilisk: No issues"; - item.backgroundColor = undefined; - } +export function activate(context: vscode.ExtensionContext): void { + context.subscriptions.push( + vscode.workspace.registerTextDocumentContentProvider(NOTICE_SCHEME, { + provideTextDocumentContent: statementText, + }), + vscode.commands.registerCommand(SHOW_STATEMENT_COMMAND, showStatement), + ); + void announce(context.globalState, extensionVersion(context.extension.packageJSON)); } -// ── Runtime resolution ──────────────────────────────────────────────────── - -// Implements [VSIX-ERROR-RECOVERY] — resolves the binary then starts LSP mode or, -// when basilisk.useLsp is false, the subprocess fallback ([VSIX-CONFIGURATION- -// SETTINGS-VS-CODE-ONLY]). On failure it surfaces a user-visible error -// (reportRuntimeFailure) and flips the status bar to the error face. -// [VSIX-BINARY-RESOLUTION] is delegated to resolveBasiliskRuntime (Shipwright). -async function startRuntime(context: vscode.ExtensionContext, s: Store): Promise { - try { - const runtime = await resolveBasiliskRuntime(context); - s.setRuntimeResolution({ - componentId: runtime.componentId, - path: runtime.executablePath, - source: runtime.source, - version: runtime.version, - }); - Logger.info( - `Basilisk executable: ${runtime.executablePath} ` + - `(source=${runtime.source}, version=${runtime.version ?? "unknown"})` - ); - if (vscode.workspace.getConfiguration("basilisk").get("useLsp") ?? true) { - startLspClient( - { context, executablePath: runtime.executablePath, outputChannel: s.outputChannel.value }, - s, - updateStatusBar - ); - } else { - startSubprocessMode(context, runtime.executablePath); - updateStatusBar("ready"); - } - } catch (error: unknown) { - reportRuntimeFailure(error); - updateStatusBar("error"); - } +export function deactivate(): void { + // Nothing is started, so nothing needs stopping. } diff --git a/vscode-extension/src/info-panel.ts b/vscode-extension/src/info-panel.ts deleted file mode 100644 index 3219bc3fd..000000000 --- a/vscode-extension/src/info-panel.ts +++ /dev/null @@ -1,518 +0,0 @@ -// Implements [EXTACT-INFO]. See docs/specs/EXTENSION-ACTIVITY-PANEL-SPEC.md#EXTACT-INFO -/** - * Basilisk Info Panel — TreeDataProvider for the Basilisk sidebar. - * - * Slimmed per issue #103: one analyzer toggle followed by flat, read-only - * server details. Read-only data does not need collapsible tree structure. - * Quick actions live elsewhere — Fix All / Organize Imports / Restart are - * toolbar buttons on the Modules panel (when-gated on the server running), - * Show Output is the status-bar click action, and everything remains in the - * command palette. The live server state row was dropped: the status bar - * already shows it. - * - * This panel is always visible regardless of workspace state. - */ - -import { nested, stringField } from "./unknown-shape"; -import * as vscode from "vscode"; -import { effect } from "@preact/signals-core"; -import type { LanguageClient } from "vscode-languageclient/node"; -import { type Store } from "./store"; -import type { TypeshedStatusState } from "./configuration-editor-model"; -import { CONFIGURATION_EDITOR_COMMAND, supportsConfigurationEditor } from "./configuration-editor"; - -// ── Tree node types ────────────────────────────────────────────────────── - -type InfoItem = FeatureItem | InfoTextItem; - -/** - * Feature toggle — clicking toggles the corresponding setting. - * - * Actionable affordance per [EXTACT-INFO-AFFORDANCE]: carries a `command`, an - * imperative tooltip, and a toggle-state icon. Paired with the inline action - * button contributed for `viewItem == feature`. - */ -class FeatureItem extends vscode.TreeItem { - constructor( - label: string, - public readonly settingKey: string, - enabled: boolean, - ) { - super(label, vscode.TreeItemCollapsibleState.None); - this.iconPath = enabled - ? new vscode.ThemeIcon("check", new vscode.ThemeColor("testing.iconPassed")) - : new vscode.ThemeIcon("circle-slash", new vscode.ThemeColor("disabledForeground")); - this.description = enabled ? "Enabled" : "Disabled"; - this.tooltip = `Click to ${enabled ? "disable" : "enable"} ${label}`; - this.contextValue = "feature"; - this.command = { - command: "basilisk.toggleFeature", - title: "Toggle Feature", - arguments: [settingKey, !enabled], - }; - } -} - -// Implements the read-only affordance of [EXTACT-INFO-AFFORDANCE] / -// [EXTACT-INFO-SERVER-INFO]: contextValue `info`, no command, no inline button, -// value shown in the row description. -/** Read-only info text. */ -class InfoTextItem extends vscode.TreeItem { - constructor(label: string, value: string, icon?: string) { - super(label, vscode.TreeItemCollapsibleState.None); - this.description = value; - if (icon !== undefined) { - this.iconPath = new vscode.ThemeIcon(icon); - } - this.contextValue = "info"; - } -} - -// ── Feature definitions ────────────────────────────────────────────────── - -interface FeatureDef { - readonly label: string; - readonly settingKey: string; -} - -// Implements [EXTACT-INFO-FEATURE-STATUS]: only features whose toggle has a -// real, observable effect belong here. A toggle that writes a setting the server -// (or extension) never reads is a lie to the user and must not exist. -// - Diagnostics (basilisk.enabled): the LSP is authoritative for diagnostics -// and honours this setting — disabling clears published diagnostics and -// suppresses new ones; re-enabling re-scans. See [ANALYSIS-ENABLED] -// (crates/basilisk-lsp/src/server/init.rs) and GitHub #65 / #119. -// Removed because the setting is silently dropped by the LSP server (it parses -// analysisMode + testExplorer + enabled in did_change_configuration) and nothing -// else acts on it: -// - uv Integration (basilisk.uv.enabled): NO server code reads it, so the -// toggle never disabled uv integration — a no-op affordance. Removed per -// GitHub #190; the uv commands remain in the palette / code actions and the -// read-only "uv" Server Info row still reports uv status. -// - Inlay Hints (Params/Types), Ruff Integration, Test Explorer, Debugger -// (never even declared), AI Typing — all dropped server-side likewise. -// See EXTACT-INFO-FEATURE-STATUS. -// [EXTACT-INFO-STRUCTURE] names this row "Diagnostics"; [EXTACT-INFO-FEATURE-STATUS] -// forbids "Checker"/"Analyzer" because those name distinct rule sets and this -// toggle governs both. -const FEATURES: readonly FeatureDef[] = [ - { label: "Diagnostics", settingKey: "basilisk.enabled" }, -]; - -// ── Resolved environment ([LSPARCH-RESOLVED-ENV]) ──────────────────────── - -/** One resolved tool from the server's initialize payload. */ -interface ResolvedTool { - readonly path: string; - readonly version?: string | null; -} - -/** The server-resolved python/uv/binary environment (`null` = none found). */ -interface ResolvedEnvironment { - readonly python?: ResolvedTool | null; - readonly uv?: ResolvedTool | null; - readonly binary?: ResolvedTool | null; -} - -/** - * Read the resolved environment the server reported in its initialize - * response ([LSPARCH-RESOLVED-ENV]) — the LSP is authoritative for what - * auto-detection found (issue #153); the extension never re-derives it. - * `undefined` while no live server data exists (starting/stopped). - */ -function resolvedEnvironment(client: LanguageClient | undefined): ResolvedEnvironment | undefined { - const resolved = nested( - client?.initializeResult?.capabilities.experimental, - "basilisk", - "resolvedEnvironment", - ); - return resolved === undefined - ? undefined - : { - python: resolvedTool(resolved.python), - uv: resolvedTool(resolved.uv), - binary: resolvedTool(resolved.binary), - }; -} - -/** One `path` + optional `version` pair from the server's initialize result. */ -function resolvedTool(value: unknown): ResolvedTool | null { - const path = stringField(value, "path"); - return path === undefined ? null : { path, version: stringField(value, "version") ?? null }; -} - -/** Render a tool as `version (path)` — bare path when the probe failed. */ -function formatResolvedTool(tool: ResolvedTool): string { - const { path, version } = tool; - return version !== undefined && version !== null && version !== "" ? `${version} (${path})` : path; -} - -/** - * Row value for a setting whose `""` default means auto-detect (issue #153): - * an explicit setting shows what the server resolved it to (the raw setting - * before the server is up, an explicit `none found (…)` when the server - * couldn't use it), and auto-detect always shows its outcome — - * `auto-detect → ()`, `→ none found` on failure, or - * `→ awaiting server…` before any authoritative data exists. The bare - * placeholder never renders. - */ -function resolutionDescription( - configured: string, - env: ResolvedEnvironment | undefined, - key: "python" | "uv", -): string { - const tool = env?.[key]; - if (configured !== "") { - if (env === undefined) { return configured; } - return tool === undefined || tool === null - ? `none found (${configured})` - : formatResolvedTool(tool); - } - if (env === undefined) { - return "auto-detect → awaiting server…"; - } - return tool === undefined || tool === null - ? "auto-detect → none found" - : `auto-detect → ${formatResolvedTool(tool)}`; -} - -// ── Provider ───────────────────────────────────────────────────────────── - -/** - * Build the top-level feature toggles. Rendered directly at the root — two - * toggles do not justify a "Feature Status" section header (issue #103). - * - * Implements [EXTACT-INFO-STRUCTURE] (toggles at root, no section header) and - * the actionable half of [EXTACT-INFO-FEATURE-STATUS]. - */ -function buildFeatureToggles(): FeatureItem[] { - const cfg = vscode.workspace.getConfiguration(); - return FEATURES.map((f) => { - const enabled = cfg.get(f.settingKey) ?? true; - return new FeatureItem(f.label, f.settingKey, enabled); - }); -} - -/** - * Build the single compact uv info row. The verbose sub-settings (auto-sync, - * stub suggestions) live in the tooltip, not as their own rows (issue #103). - * The row value is the RESOLVED uv binary from the server, never the bare - * `auto-detect` placeholder (issue #153). - * - * Implements the one-uv-row rule of [EXTACT-INFO-SERVER-INFO]. - */ -function buildUvInfoItem( - cfg: vscode.WorkspaceConfiguration, - env: ResolvedEnvironment | undefined, -): InfoTextItem { - const uvEnabled = cfg.get("uv.enabled") ?? true; - const uvPath = cfg.get("uv.executablePath") ?? ""; - const uvAutoSync = cfg.get("uv.autoSync") ?? false; - - const item = new InfoTextItem( - "uv", - uvEnabled ? resolutionDescription(uvPath, env, "uv") : "disabled", - "package", - ); - item.tooltip = [ - `uv Integration: ${uvEnabled ? "enabled" : "disabled"}`, - `Executable: ${uvPath === "" ? "auto-detect" : uvPath}`, - `Resolved: ${resolutionDescription(uvPath, env, "uv")}`, - `Auto-Sync: ${uvAutoSync ? "on" : "off"}`, - ].join("\n"); - return item; -} - -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 === "Downloading") { return "loading~spin"; } - if (status.lifecycle.kind === "NoSource") { return "error"; } - return "database"; -} - -function rootLabel(rootUri: string): string { - try { - const parts = new URL(rootUri).pathname.split("/").filter((part) => part !== ""); - return decodeURIComponent(parts[parts.length - 1] ?? rootUri); - } catch { - return rootUri; - } -} - -// 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"}`, - `License: ${statusKind(status.licenseStatus)}`, - ].join("\n"); -} - -function typeshedSourceItem( - prefix: string, - rootUri: string, - status: TypeshedStatusState, -): InfoTextItem { - const commit = status.commitIdentity === undefined ? "" : ` · ${status.commitIdentity}`; - const item = new InfoTextItem( - `${prefix} Source`, - `${statusKind(status.activeSource)}${commit}`, - "repo", - ); - item.tooltip = typeshedTooltip(rootUri, status); - return item; -} - -/** - * One typeshed warning row ([LSPCFGED-TYPESHED-SERVICE-INFO]). - * - * 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. - * - * The command is attached only when the editor is genuinely reachable — - * `basilisk.openConfigurationEditor` is capability-gated and registered only - * while the server runs AND advertises it (configuration-editor-registration.ts), - * and a shown-but-dead command is exactly issue #103 defect 1. The caller - * computes `editorSupported` with that SAME predicate so the two can never - * drift apart. - */ -function typeshedWarningItem( - prefix: string, - warning: TypeshedStatusState["warnings"][number], - editorSupported: boolean, -): InfoTextItem { - const item = new InfoTextItem( - `${prefix} ${warning.code}`, - warning.message, - statusKind(warning.severity) === "High" ? "warning" : "info", - ); - if (editorSupported) { - item.contextValue = "typeshed-warning"; - item.tooltip = "Click to open the Configuration Editor, where the typeshed fixes live"; - item.command = { - command: CONFIGURATION_EDITOR_COMMAND, - title: "Open Configuration Editor", - }; - } - 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, -): InfoTextItem[] { - const entries = [...statuses.entries()].sort(([left], [right]) => left.localeCompare(right)); - return entries.flatMap(([rootUri, status]) => { - const prefix = entries.length === 1 ? "Typeshed" : `Typeshed (${rootLabel(rootUri)})`; - const rows = [ - new InfoTextItem( - `${prefix} State`, - statusKind(status.lifecycle), - typeshedLifecycleIcon(status), - ), - typeshedSourceItem(prefix, rootUri, status), - ...typeshedNoSourceItems(prefix, status, editorSupported), - ...status.warnings.map((warning) => typeshedWarningItem(prefix, warning, editorSupported)), - ]; - return rows; - }); -} - -export class InfoPanelProvider implements vscode.TreeDataProvider, vscode.Disposable { - private readonly emitter = new vscode.EventEmitter(); - public readonly onDidChangeTreeData = this.emitter.event; - - private readonly disposables: vscode.Disposable[] = []; - - constructor(private readonly store: Store) { - this.disposables.push( - vscode.workspace.onDidChangeConfiguration((e) => { - if (e.affectsConfiguration("basilisk")) { - this.emitter.fire(undefined); - } - }), - ); - // Implements the freshness rule of [EXTACT-INFO-STRUCTURE] / - // [EXTACT-INFO-SERVER-INFO] — defect 3 (issue #103): Server Info must not go - // stale. Re-render whenever the LSP lifecycle signals change (e.g. the - // Version row appears once the client's initializeResult arrives) — same - // signals pattern as bindLspStateEffects in lsp-client.ts. - const disposeEffect = effect(() => { - // Subscribe to both signals; the values themselves are read in - // buildServerInfoSection on the re-render this triggers. - void this.store.lspState.value; - void this.store.client.value; - void this.store.typeshedStatuses.value; - this.emitter.fire(undefined); - }); - this.disposables.push({ dispose: disposeEffect }); - } - - public refresh(): void { - this.emitter.fire(undefined); - } - - public dispose(): void { - for (const d of this.disposables) { d.dispose(); } - this.emitter.dispose(); - } - - public getTreeItem(element: InfoItem): vscode.TreeItem { - return element; - } - - public getChildren(element?: InfoItem): InfoItem[] { - if (element !== undefined) { return []; } - - // Implements [EXTACT-INFO-STRUCTURE] / [EXTACT-INFO-QUICK-ACTIONS]: flat - // layout — the analyzer toggle, then compact read-only server details. No - // Quick Actions section: those are Modules-toolbar buttons, the status bar, - // and the command palette. - return [ - ...buildFeatureToggles(), - ...this.buildServerInfoItems(), - ]; - } - - // Implements [EXTACT-INFO-SERVER-INFO]: compact read-only Version / Analysis - // Mode / Python / uv / Binary rows; no live server-state row. Python, uv, - // and Binary render the server-RESOLVED values from [LSPARCH-RESOLVED-ENV] - // (issue #153) — never a bare `auto-detect` placeholder or a blank row. - private buildServerInfoItems(): InfoTextItem[] { - // No live "Server" state row: the status bar already shows it (issue - // #103). The lspState/client effect in the constructor still re-renders - // this section so the Version row appears as soon as the server is up. - const client = this.store.client.value; - const serverInfo = client?.initializeResult?.serverInfo; - const env = resolvedEnvironment(client); - const cfg = vscode.workspace.getConfiguration("basilisk"); - - const mode = cfg.get("analysisMode") ?? "wholeModule"; - const pythonPath = cfg.get("python") ?? ""; - const binary = env?.binary; - - const items: InfoTextItem[] = [ - ...(serverInfo !== undefined ? [new InfoTextItem("Version", serverInfo.version ?? "unknown", "versions")] : []), - new InfoTextItem("Analysis Mode", mode, "symbol-keyword"), - new InfoTextItem("Python", resolutionDescription(pythonPath, env, "python"), "symbol-namespace"), - buildUvInfoItem(cfg, env), - // The running server binary, from the server itself (current_exe) — the - // one source that cannot lie about which basilisk is answering. Absent - // (never blank) while no server is running (issue #153 defect 3). - ...(binary !== undefined && binary !== null - ? [new InfoTextItem("Binary", formatResolvedTool(binary), "file-binary")] - : []), - // The SAME predicate registerConfigurationEditor uses to register the - // command (configuration-editor-registration.ts). Gating on the - // capability alone would attach the command during `starting`/`stopped`, - // when the command is not registered — a shown-but-dead row. - ...typeshedInfoItems( - this.store.typeshedStatuses.value, - this.store.lspState.value === "running" && supportsConfigurationEditor(client), - ), - ]; - - return items; - } -} - -// ── Registration ───────────────────────────────────────────────────────── - -/** - * Register the info panel. - * - * Returns an array of Disposables for command registrations that must be - * tracked in `singletonDisposables` so `deactivate()` can dispose them - * before re-init. Tree views and provider go to `context.subscriptions`. - */ -export function registerInfoPanel( - context: vscode.ExtensionContext, - store: Store, -): { provider: InfoPanelProvider; disposables: vscode.Disposable[] } { - const provider = new InfoPanelProvider(store); - - const treeView = vscode.window.createTreeView("basilisk.info", { - treeDataProvider: provider, - }); - - context.subscriptions.push(treeView); - context.subscriptions.push(provider); - - // Feature toggle command. - const disposables = [ - vscode.commands.registerCommand( - "basilisk.toggleFeature", - async (settingKey: string, newValue: boolean) => { - const cfg = vscode.workspace.getConfiguration(); - const target = featureToggleTarget(vscode.workspace.workspaceFolders?.length ?? 0); - await cfg.update(settingKey, newValue, target); - }, - ), - // Inline action-button dispatcher [EXTACT-INFO-AFFORDANCE]. The literal - // button contributed for feature-toggle rows (viewItem == feature) - // invokes this with the clicked row; it forwards to the row's own command - // so the button and the whole-row click share a single source of truth. - vscode.commands.registerCommand( - "basilisk.info.runAction", - async (item: vscode.TreeItem | undefined) => { - const command = item?.command; - if (command === undefined) { return; } - const commandArgs: unknown[] = command.arguments ?? []; - await vscode.commands.executeCommand(command.command, ...commandArgs); - }, - ), - ]; - - return { provider, disposables }; -} - -/** - * Configuration target for feature toggles — defect 2 of issue #103. - * - * Implements the write-target rule of [EXTACT-INFO-STRUCTURE] / - * [EXTACT-INFO-FEATURE-STATUS]: Workspace when a folder is open, else Global. - * - * The info panel is always visible (`visibility: "visible"`, no `when`), so - * toggles can be clicked with no folder open. Writing to - * `ConfigurationTarget.Workspace` is invalid without a workspace folder and - * rejects the update, so fall back to `Global` in that case. Pure in the - * folder count so both branches are testable in the e2e host (which always - * launches with a folder). - */ -export function featureToggleTarget(workspaceFolderCount: number): vscode.ConfigurationTarget { - return workspaceFolderCount > 0 - ? vscode.ConfigurationTarget.Workspace - : vscode.ConfigurationTarget.Global; -} diff --git a/vscode-extension/src/logger.ts b/vscode-extension/src/logger.ts deleted file mode 100644 index b86a965ea..000000000 --- a/vscode-extension/src/logger.ts +++ /dev/null @@ -1,103 +0,0 @@ -// Implements [VSIX-OUTPUT-CHANNELS]. See docs/specs/VSIX-SPEC.md#VSIX-OUTPUT-CHANNELS -/** - * Logging abstraction for the Basilisk VS Code extension. - * - * Provides a backend-agnostic `Logger` interface so callers never depend on - * VS Code APIs or any concrete sink; only the `FileLogSink` in this module - * touches the filesystem. The active sink is stored in the centralized Store — - * no global mutable state lives in this module. - */ - -import * as fsModule from "fs"; - -// ── Public interface ───────────────────────────────────────────────────── - -export enum LogLevel { - Trace = 0, - Debug = 1, - Info = 2, - Warn = 3, - Error = 4, -} - -/** Backend-agnostic logging interface. */ -export interface LogSink { - trace(message: string): void; - debug(message: string): void; - info(message: string): void; - warn(message: string): void; - error(message: string): void; -} - -// ── Built-in sinks ────────────────────────────────────────────────────── - -/** Sink that silently discards all messages. */ -export const nullSink: LogSink = { - trace(): void { /* noop */ }, - debug(): void { /* noop */ }, - info(): void { /* noop */ }, - warn(): void { /* noop */ }, - error(): void { /* noop */ }, -}; - -/** Sink that fans out to multiple backends. */ -export class CompositeSink implements LogSink { - constructor(private readonly sinks: LogSink[]) {} - public trace(message: string): void { for (const s of this.sinks) {s.trace(message);} } - public debug(message: string): void { for (const s of this.sinks) {s.debug(message);} } - public info(message: string): void { for (const s of this.sinks) {s.info(message);} } - public warn(message: string): void { for (const s of this.sinks) {s.warn(message);} } - public error(message: string): void { for (const s of this.sinks) {s.error(message);} } -} - -/** - * Log file permission bits: owner read+write only. The log can contain - * workspace paths, so it must never be group/world-readable - * (defense-in-depth for js/insecure-temporary-file). - */ -const LOG_FILE_MODE = 0o600; - -/** Sink that appends to a file on disk via synchronous writes. */ -export class FileLogSink implements LogSink { - private readonly fd: number; - constructor(filePath: string) { - // Create+truncate with OWNER-ONLY (0o600) permissions so the log — which - // can contain workspace paths — is never world-readable, even if a caller - // ever points this at a shared directory (defense-in-depth for - // js/insecure-temporary-file; callers pass the extension's private logUri). - this.fd = fsModule.openSync(filePath, "w", LOG_FILE_MODE); - } - public trace(message: string): void { this.write("TRACE", message); } - public debug(message: string): void { this.write("DEBUG", message); } - public info(message: string): void { this.write("INFO ", message); } - public warn(message: string): void { this.write("WARN ", message); } - public error(message: string): void { this.write("ERROR", message); } - private write(level: string, message: string): void { - const timestamp = new Date().toISOString(); - fsModule.writeSync(this.fd, `${timestamp} [${level}] ${message}\n`); - } -} - -// ── Logger with injectable sink ───────────────────────────────────────── - -/** Sink accessor — set once at startup by extension.ts via bindLogger(). */ -// eslint-disable-next-line func-style -let sinkAccessor: () => LogSink = () => nullSink; - -/** - * Bind the Logger to a sink accessor function. Called once during - * activate() so the Logger reads from the store without importing it - * (avoiding circular deps). - */ -export function bindLogger(accessor: () => LogSink): void { - sinkAccessor = accessor; -} - -/** The global logger. Always safe to call — defaults to a no-op sink. */ -export const Logger = { - trace(message: string): void { sinkAccessor().trace(message); }, - debug(message: string): void { sinkAccessor().debug(message); }, - info(message: string): void { sinkAccessor().info(message); }, - warn(message: string): void { sinkAccessor().warn(message); }, - error(message: string): void { sinkAccessor().error(message); }, -} as const; diff --git a/vscode-extension/src/lsp-client-stop.ts b/vscode-extension/src/lsp-client-stop.ts deleted file mode 100644 index dc3584d32..000000000 --- a/vscode-extension/src/lsp-client-stop.ts +++ /dev/null @@ -1,95 +0,0 @@ -// Implements [LSPARCH-CMDREG] client lifecycle — see docs/specs/LSP-ARCHITECTURE-SPEC.md#LSPARCH-CMDREG -/** - * Shutting down a `LanguageClient` that may still be starting. - * - * `vscode-languageclient`'s `shutdown` rejects unless the state is exactly - * `Running`, and `isRunning()` reports `false` for a client that is `Starting` - * — one that has already spawned its server process. A shutdown path guarded - * on `isRunning()` therefore has two failure modes on the same window: - * - * - it calls `stop()` anyway and the rejection escapes (`deactivate()` threw - * "Client is not running and can't be stopped. It's current state is: - * starting"), or - * - it skips the client entirely and the server process it spawned outlives - * the client that owns it — the zombie publisher of GitHub #264, in the - * state where it is hardest to notice. - * - * `needsStop()` is the client's own name for "Starting or Running", and - * `start()` returns the in-flight start promise rather than beginning a second - * one, so a starting client can be settled and then shut down properly. - * - * The window is not theoretical: on win32 spawning the server binary is slow - * enough that a deactivate/activate cycle routinely lands inside it, which is - * what the Windows CI job reported ([VSIX-CI-PLATFORM-COVERAGE]). - */ - -import type { LanguageClient } from "vscode-languageclient/node"; -import { Logger } from "./logger"; - -/** How to tear the client down once its start has settled. */ -export type StopMode = "stop" | "dispose"; - -/** - * Shutdowns already in flight, keyed by client. - * - * `deactivate()` stops the client and then calls `store.reset()`, which also - * wants it gone. Without this the second caller shuts down a client that the - * first has already moved to `Stopping` and gets a rejection for its trouble. - * A `WeakMap` keeps no client alive past its own lifetime. - */ -const inFlight = new WeakMap>(); - -/** - * Stop a client that may still be starting, joining any shutdown already in - * flight for it. - * - * Never rejects: a client that failed to start is already stopped, and a - * shutdown error must not take down the deactivation around it. - */ -export async function stopClientSettled( - client: LanguageClient, - mode: StopMode = "stop", -): Promise { - // Everything up to the first await runs synchronously, so a second caller - // in the same tick — store.reset() right behind deactivate() — always finds - // the entry this call registers. - const existing = inFlight.get(client); - if (existing !== undefined) { - return existing; - } - const shutdown = settleThenStop(client, mode).finally(() => { - inFlight.delete(client); - }); - inFlight.set(client, shutdown); - return shutdown; -} - -async function settleThenStop(client: LanguageClient, mode: StopMode): Promise { - if (!client.needsStop()) { - return; - } - if (!client.isRunning() && !(await settleStart(client))) { - return; - } - // The state can still have moved on while the start settled — an error - // handler inside the client stops it on a failed handshake. - if (!client.isRunning()) { - return; - } - try { - await (mode === "dispose" ? client.dispose() : client.stop()); - } catch (err: unknown) { - Logger.warn(`Failed to ${mode} the LSP client: ${String(err)}`); - } -} - -/** Await an in-flight start. Returns false when it failed — nothing to stop. */ -async function settleStart(client: LanguageClient): Promise { - try { - await client.start(); - return true; - } catch (err: unknown) { - Logger.warn(`LSP client failed to start; nothing to shut down: ${String(err)}`); - return false; - } -} diff --git a/vscode-extension/src/lsp-client.ts b/vscode-extension/src/lsp-client.ts deleted file mode 100644 index 921c91003..000000000 --- a/vscode-extension/src/lsp-client.ts +++ /dev/null @@ -1,534 +0,0 @@ -// Implements [VSIX-LSP-CLIENT-CONFIGURATION]. See docs/specs/VSIX-SPEC.md#VSIX-LSP-CLIENT-CONFIGURATION -/** - * LSP client setup and lifecycle for the Basilisk VS Code extension. - * - * State transitions are handled inside the Store via onDidChangeState. - * This module handles IO side effects (logging, config forwarding, tab - * tracking) by reacting to the store's lspState signal. - */ - -import { arrayField, asRecord, rawField, recordField, stringField } from "./unknown-shape"; -import * as vscode from "vscode"; -import { - LanguageClient, - type LanguageClientOptions, - type ServerOptions, - CloseAction, - ErrorAction, - RevealOutputChannelOn, -} from "vscode-languageclient/node"; -import { effect } from "@preact/signals-core"; -import { Logger } from "./logger"; -import { createLspTraceChannel } from "./lsp-trace"; -import { BASILISK_DOCUMENT_SELECTOR } from "./lsp-document-selector"; -import { CONFIGURATION_EDITOR_COMMAND } from "./configuration-editor"; -import { type Store, type LspState } from "./store"; - -/** Maximum LSP errors before shutting down the server. */ -const MAX_LSP_ERRORS_BEFORE_SHUTDOWN = 3; - -/** Read all Basilisk settings from the VS Code configuration. */ -function readInlayHints(cfg: vscode.WorkspaceConfiguration): Record { - return { - parameterNames: cfg.get("inlayHints.parameterNames") ?? true, - variableTypes: cfg.get("inlayHints.variableTypes") ?? true, - }; -} - -function readUvSettings(cfg: vscode.WorkspaceConfiguration): Record { - return { - enabled: cfg.get("uv.enabled") ?? true, - executablePath: cfg.get("uv.executablePath") ?? "", - autoSync: cfg.get("uv.autoSync") ?? false, - }; -} - -function readTestExplorerSettings(cfg: vscode.WorkspaceConfiguration): Record { - return { - enabled: cfg.get("testExplorer.enabled") ?? true, - framework: cfg.get("testExplorer.framework") ?? "auto", - pytestPath: cfg.get("testExplorer.pytestPath") ?? "pytest", - args: cfg.get("testExplorer.args") ?? [], - autoDiscoverOnSave: cfg.get("testExplorer.autoDiscoverOnSave") ?? true, - useUvRun: cfg.get("testExplorer.useUvRun") ?? true, - }; -} - -// Implements [VSIX-CONFIGURATION-SETTINGS] — reads the basilisk.* settings whose -// package.json schema is declared in vscode-extension/package.json (contributes. -// configuration); these are forwarded to the LSP server as initializationOptions -// and on didChangeConfiguration. [VSIX-CONFIGURATION-SETTINGS-VS-CODE-ONLY]: -// basilisk.useLsp / basilisk.trace.server are consumed here and in extension.ts, -// not sent to the server. -// Implements the editor-setting source of [ANALYSIS-CONFIG-SRC] — `analysisMode` -// (default "wholeModule") is read from the workspace setting, the highest-priority -// config source ([ANALYSIS-CONFIG-PRI]), and forwarded to the server. -export function readBasiliskSettings(): Record { - const cfg = vscode.workspace.getConfiguration("basilisk"); - // The "Type Checking" toggle (`basilisk.enabled`) MUST reach the server — the - // LSP is authoritative for diagnostics, so it clears/suppresses them when the - // toggle is off. Omitting it here left the toggle a cosmetic no-op (GitHub - // #65 / #119). Implements [ANALYSIS-ENABLED] (server side) and the - // [EXTACT-INFO-FEATURE-STATUS] "Type Checking" effect. - const enabled = cfg.get("enabled") ?? true; - // [LSPARCH-DIAGNOSTIC-SCOPE]: `basilisk.analyze` is the per-user editor - // opt-out that restricts publication to check scope (pep rules only). It is - // relayed as initializationOptions.basilisk.analyze; project configuration - // grades rules and never selects commands. - const analyze = cfg.get("analyze") ?? true; - return { - enabled, - analysisMode: cfg.get("analysisMode") ?? "wholeModule", - basilisk: { - enabled, - analyze, - python: cfg.get("python") ?? "", - analysisMode: cfg.get("analysisMode") ?? "wholeModule", - inlayHints: readInlayHints(cfg), - formatter: cfg.get("formatter") ?? "ruff", - }, - formatter: cfg.get("formatter") ?? "ruff", - uv: readUvSettings(cfg), - testExplorer: readTestExplorerSettings(cfg), - }; -} - -function buildServerSettings(): Record { - return { basilisk: readBasiliskSettings() }; -} - -export type StatusBarUpdater = (state: "starting" | "ready" | "error" | "stopped") => void; - -interface LspClientOptions { - context: vscode.ExtensionContext; - executablePath: string; - outputChannel: vscode.LogOutputChannel | undefined; -} - -// Implements [VSIX-LSP-CLIENT-CONFIGURATION] — builds ServerOptions/clientOptions -// and starts the LanguageClient ("basilisk lsp" over stdio). The executablePath -// arrives from binary resolution ([VSIX-BINARY-RESOLUTION], delegated to -// Shipwright in shipwright-runtime.ts). -export function startLspClient( - options: LspClientOptions, - store: Store, - updateStatusBar: StatusBarUpdater -): void { - const { context, executablePath, outputChannel } = options; - const serverOptions: ServerOptions = { - command: executablePath, - args: ["lsp"], - options: { - // Point the server at the bundled debugpy so debugging works without the - // user installing debugpy into their interpreter. The server ignores this - // when the directory is absent (e.g. a dev build), falling back to the - // interpreter's own debugpy. - env: { ...process.env, BASILISK_DEBUGPY_PATH: context.asAbsolutePath("bundled/debugpy") }, - }, - }; - - // [VSIX-OUTPUT-CHANNELS] "Basilisk LSP Trace" channel — surfaces LSP - // communication when basilisk.trace.server is enabled. - const traceChannel = createLspTraceChannel(); - context.subscriptions.push(traceChannel); - - const clientOptions = buildClientOptions(outputChannel, traceChannel, updateStatusBar); - - const lspClient = new LanguageClient( - "basilisk", - "Basilisk Type Checker", - serverOptions, - clientOptions - ); - - // Remove the built-in ExecuteCommandFeature to prevent it from calling - // vscode.commands.registerCommand for server-advertised commands. - // This avoids "command already exists" crashes on extension reload. - // All command execution is handled by the executeCommand middleware. - removeExecuteCommandFeature(lspClient); - - // setClient wires up onDidChangeState internally — the store owns - // all state transitions (server commands, ready handle, lspState). - store.setClient(context, lspClient); - - updateStatusBar("starting"); - bindLspStateEffects(store, updateStatusBar); - registerConfigForwarding(context, store); - registerTabTracking(context, store); - - lspClient.start().catch((error: unknown) => { - const errorMessage = error instanceof Error ? error.message : String(error); - const msg = - `Basilisk: Failed to start language server. ` + - `Shipwright selected '${executablePath}'. ${errorMessage}`; - vscode.window.showErrorMessage(msg); - Logger.error(msg); - updateStatusBar("error"); - }); - - context.subscriptions.push(lspClient); -} - -/** Map store lspState to status bar + logging side effects. */ -const LSP_STATE_LOG: Record = { - idle: "", - starting: "Basilisk language server is starting...", - running: "Basilisk language server is running.", - stopped: "Basilisk language server stopped.", -}; - -const LSP_STATE_TO_STATUS: Record = { - idle: undefined, - starting: "starting", - running: "ready", - stopped: "stopped", -}; - -function bindLspStateEffects(store: Store, updateStatusBar: StatusBarUpdater): void { - effect(() => { - const state = store.lspState.value; - const logMsg = LSP_STATE_LOG[state]; - if (logMsg !== "") { - Logger.info(logMsg); - } - const statusBarState = LSP_STATE_TO_STATUS[state]; - if (statusBarState !== undefined) { - updateStatusBar(statusBarState); - } - // [EXTACT-EDITORS-VSCODE] basilisk.serverState context key — gates the - // server-dependent Modules-toolbar buttons (Fix All / Organize Imports / - // Restart) so they only render with a live handler behind them (#103). - void vscode.commands.executeCommand("setContext", "basilisk.serverState", state); - }); -} - -/** - * Build the LanguageClient options — documentSelector, synchronize, - * middleware (hover trust, executeCommand UI, configuration merge), and - * error recovery. Exported so tests can exercise the middleware wiring. - */ -export function buildClientOptions( - outputCh: vscode.LogOutputChannel | undefined, - traceCh: vscode.LogOutputChannel, - updateStatusBar: StatusBarUpdater -): LanguageClientOptions { - // Implements [VSIX-LSP-CLIENT-CONFIGURATION] — documentSelector (python files), - // synchronize.configurationSection "basilisk", initializationOptions, and the - // trace channel wiring per the spec's client-options shape. - return { - documentSelector: BASILISK_DOCUMENT_SELECTOR, - synchronize: { - configurationSection: "basilisk", - fileEvents: vscode.workspace.createFileSystemWatcher("**/*.{py,pyi}"), - }, - initializationOptions: readBasiliskSettings(), - traceOutputChannel: traceCh, - outputChannel: outputCh, - revealOutputChannelOn: RevealOutputChannelOn.Never, - // Implements [VSIX-ERROR-RECOVERY] — errorHandler shuts the server down after - // MAX_LSP_ERRORS_BEFORE_SHUTDOWN (3) errors and auto-restarts on close. - errorHandler: { - error: (error, _message, count) => { - Logger.error(`LSP error: ${error.message ?? error}`); - if (count !== undefined && count < MAX_LSP_ERRORS_BEFORE_SHUTDOWN) { - return { action: ErrorAction.Continue }; - } - updateStatusBar("error"); - return { action: ErrorAction.Shutdown }; - }, - closed: () => { - Logger.warn("LSP connection closed. Restarting..."); - return { action: CloseAction.Restart }; - }, - }, - middleware: { - executeCommand: executeCommandMiddleware, - // eslint-disable-next-line max-params -- vscode-languageclient fixes the 4-arg middleware signature. - provideHover: async (document, position, token, next) => - trustConfigureSeverityLinks(await next(document, position, token)), - workspace: { - configuration: async (params, token, next) => { - const results = await next(params, token); - if (!Array.isArray(results)) { - return results; - } - return results.map((item: unknown, idx: number) => { - const section = params.items[idx]?.section; - if (section === "basilisk" || section?.startsWith("basilisk.")) { - return { - ...asRecord(item), - ...readBasiliskSettings(), - }; - } - return asRecord(item); - }); - }, - }, - }, - }; -} - -// Implements the client wiring of [EXTACT-HEALTH-CONTEXT-MENU] (Fix All in File / -// Adopt File / Un-adopt File), the file-scoped half of [AUTOFIX-MASS-VSCODE] -// (`basilisk.fixFile` Safe tier / `basilisk.fixFileAll` all tier), and -// [AUTOFIX-ADOPTION-VSCODE] (`basilisk.adoptFile` / `basilisk.unadoptFile`) — -// these server-advertised, file-scoped commands get the active editor's URI -// injected so they act on the right file. -/** Commands that need the active editor URI injected as the first arg. */ -const EDITOR_URI_COMMANDS = new Set([ - "basilisk.fixFile", - "basilisk.fixFileAll", - "basilisk.adoptFile", - "basilisk.unadoptFile", -]); - -// Implements the client UI of [LSPUV-COMMANDS] (the `{package}`-taking uv -// commands) — prompts the user for the package name before the server runs uv. -/** Commands that prompt the user for a package name before execution. */ -const PACKAGE_COMMANDS: Record = { - "basilisk.uv.add": { prompt: "Package name to add", placeholder: "e.g. requests" }, - "basilisk.uv.addDev": { prompt: "Dev package name to add", placeholder: "e.g. pytest" }, - "basilisk.uv.remove": { prompt: "Package name to remove", placeholder: "e.g. requests" }, -}; - -/** Post-execution toast messages keyed by command name. */ -const TOAST_MESSAGES: Record = { - "basilisk.uv.sync": "Basilisk: uv sync complete.", - "basilisk.uv.lock": "Basilisk: uv lock complete.", - "basilisk.uv.createEnv": "Basilisk: Virtual environment created.", -}; - -type NextFn = (command: string, args: unknown[]) => Thenable; - -/** - * The LSP embeds `command:basilisk.openConfigurationEditor` links in hover - * markdown for non-PEP diagnostics (the Configure Severity deep link, - * [CONFIGEDITOR-VSIX-EXPERIENCE]). VS Code renders LSP hover markdown - * untrusted by default, which strips command links — so trust hover content - * for exactly that one command and nothing else. - */ -export function trustConfigureSeverityLinks( - hover: T, -): T { - if (hover === null || hover === undefined) { return hover; } - for (const content of hover.contents) { - if (content instanceof vscode.MarkdownString) { - content.isTrusted = { enabledCommands: [CONFIGURATION_EDITOR_COMMAND] }; - } - } - return hover; -} - -function activeOrVisibleFileEditor(): vscode.TextEditor | undefined { - const active = vscode.window.activeTextEditor; - if (active?.document.uri.scheme === "file") { - return active; - } - - return vscode.window.visibleTextEditors.find( - (editor) => editor.document.uri.scheme === "file" && editor.document.languageId === "python" - ) ?? vscode.window.visibleTextEditors.find((editor) => editor.document.uri.scheme === "file"); -} - -/** - * Middleware for `workspace/executeCommand`. Injects client-side UI (editor - * URI resolution, input prompts, toast notifications) around server-advertised - * commands. This is the correct place for client-side behavior — server - * commands are never pre-registered with `registerCommand()`. - * - * See LSP-ARCHITECTURE-SPEC.md § Command Registration Rule. - */ -async function executeCommandMiddleware( - command: string, - args: unknown[], - next: NextFn -): Promise { - if (EDITOR_URI_COMMANDS.has(command)) { - const editor = activeOrVisibleFileEditor(); - if (editor === undefined) { return undefined; } - args = [editor.document.uri.toString()]; - } - - const pkgCmd = PACKAGE_COMMANDS[command]; - if (pkgCmd !== undefined && (args.length === 0 || args[0] === undefined)) { - // Only prompt if the LSP didn't already provide the package name - // (e.g. when invoked from the command palette, not from a code action). - const packageName = await vscode.window.showInputBox({ - prompt: pkgCmd.prompt, - placeHolder: pkgCmd.placeholder, - }); - if (packageName === undefined || packageName === "") { return undefined; } - args = [{ package: packageName }]; - } - - const result: unknown = await next(command, args); - - // Only show an optimistic success toast when the LSP reports the command - // actually succeeded. A failed uv command (e.g. `uv add _pydevd_bundle`) - // already surfaces its own error toast from the server; showing "Added X" - // as well would contradict it and lie about the outcome. - // Implements [LSPUV-ACTIONS-EXECUTION]. See issue #84. - if (uvCommandSucceeded(result)) { - showUvSuccessToast(command, args, pkgCmd !== undefined); - } - - return result; -} - -/** - * True when an LSP `basilisk.uv.*` result reports success. - * - * Every uv command handler returns `{ success, stdout, stderr }`; failures and - * "no pyproject.toml" no-ops set `success: false`. Treat anything that isn't an - * explicit `success: true` as a non-success so we never show a misleading - * success toast over the server's error toast (issue #84). - */ -function uvCommandSucceeded(result: unknown): boolean { - return ( - typeof result === "object" && - result !== null && - (result as { success?: unknown }).success === true - ); -} - -/** Show the post-execution success toast for a uv command, if it warrants one. */ -function showUvSuccessToast(command: string, args: unknown[], isPackageCmd: boolean): void { - const staticToast = TOAST_MESSAGES[command]; - if (staticToast !== undefined) { - vscode.window.showInformationMessage(staticToast); - return; - } - if (isPackageCmd && args.length > 0) { - // A code action passes the package as a bare string (e.g. ["types-six"]); - // the command-palette prompt path passes [{ package: "..." }]. Accept both. - const arg = args[0]; - const pkg = typeof arg === "string" ? arg : stringField(arg, "package"); - const verb = command === "basilisk.uv.remove" ? "Removed" : - command === "basilisk.uv.addDev" ? "Added dev dependency" : "Added"; - vscode.window.showInformationMessage(`Basilisk: ${verb} ${pkg}.`); - } -} - -/** - * Create a VS Code command handler that routes through the executeCommand - * middleware and then sends `workspace/executeCommand` to the LSP server. - * - * This replaces the vscode-languageclient `ExecuteCommandFeature` which was - * removed to prevent double-registration crashes. The store calls this for - * each server-advertised command and registers the result with - * `vscode.commands.registerCommand`. - */ -export function createServerCommandHandler( - client: LanguageClient, - command: string -): (...args: unknown[]) => Promise { - return async (...args: unknown[]) => { - async function next(cmd: string, a: unknown[]): Promise { - return client.sendRequest("workspace/executeCommand", { - command: cmd, - arguments: a, - }); - } - return executeCommandMiddleware(command, args, next); - }; -} - -function registerConfigForwarding(context: vscode.ExtensionContext, store: Store): void { - context.subscriptions.push( - vscode.workspace.onDidChangeConfiguration((e) => { - const lspClient = store.client.value; - if (e.affectsConfiguration("basilisk") && lspClient?.isRunning() === true) { - void lspClient.sendNotification("workspace/didChangeConfiguration", { - settings: buildServerSettings(), - }); - } - }) - ); -} - -function registerTabTracking(context: vscode.ExtensionContext, store: Store): void { - let knownOpenUris = collectOpenPythonUris(); - - context.subscriptions.push( - vscode.window.tabGroups.onDidChangeTabs(() => { - const lspClient = store.client.value; - if (lspClient?.isRunning() !== true) {return;} - - const currentUris = collectOpenPythonUris(); - - const mode = vscode.workspace.getConfiguration("basilisk").get("analysisMode") ?? "wholeModule"; - for (const uriStr of knownOpenUris) { - if (currentUris.has(uriStr)) { - continue; - } - const uri = vscode.Uri.parse(uriStr); - // Implements the tab-close clause of [ANALYSIS-PUBLISH] - // (docs/specs/LSP-ANALYSIS-MODES-SPEC.md). VS Code disposes closed - // documents lazily, so the language client's own didClose can lag a - // tab close by an unbounded amount. Send a synthetic didClose - // whenever the server would clear diagnostics on close: every file in - // openFilesOnly, and out-of-workspace files in the whole-workspace - // modes — otherwise their stale diagnostics linger in the Problems - // panel (GitHub #264). In-workspace files in wholeModule/crossModule - // keep their diagnostics by design. - const inWorkspace = vscode.workspace.getWorkspaceFolder(uri) !== undefined; - if (mode === "openFilesOnly" || !inWorkspace) { - void lspClient.sendNotification("textDocument/didClose", { - textDocument: { uri: uri.toString() }, - }); - } - } - - knownOpenUris = currentUris; - }) - ); -} - -/** - * Remove the built-in ExecuteCommandFeature from a LanguageClient. - * - * The library's ExecuteCommandFeature calls vscode.commands.registerCommand - * for every server-advertised command. On extension reload, the old - * registrations persist and the re-registration throws "command already - * exists", killing the client. Since all command execution flows through - * our executeCommand middleware, the feature is unnecessary. - */ -function removeExecuteCommandFeature(client: LanguageClient): void { - const METHOD = "workspace/executeCommand"; - // `_features` and `_dynamicFeatures` are library internals with no public - // type. Asserting a shape onto `client` would make the compiler vouch for - // fields a vscode-languageclient upgrade can rename without warning, so they - // are reached through runtime checks instead — a rename then quietly skips - // the removal rather than throwing on a missing member. - const features = arrayField(client, "_features"); - const idx = features.findIndex( - (feature) => stringField(recordField(feature, "registrationType"), "method") === METHOD - ); - if (idx !== -1) { - features.splice(idx, 1); - } - - // The feature is also stored in _dynamicFeatures — if left there, - // the client's handleRegistrationRequest path can still call register() - // on it, which triggers vscode.commands.registerCommand and crashes - // with "command already exists" on reload. - const dynamicFeatures: unknown = rawField(client, "_dynamicFeatures"); - if (dynamicFeatures instanceof Map) { - dynamicFeatures.delete(METHOD); - } -} - -function collectOpenPythonUris(): Set { - const uris = new Set(); - for (const group of vscode.window.tabGroups.all) { - for (const tab of group.tabs) { - const input = tab.input; - if (input instanceof vscode.TabInputText) { - if (input.uri.scheme === "file" && input.uri.fsPath.endsWith(".py")) { - uris.add(input.uri.toString()); - } - } - } - } - return uris; -} diff --git a/vscode-extension/src/lsp-document-selector.ts b/vscode-extension/src/lsp-document-selector.ts deleted file mode 100644 index 13759f6d0..000000000 --- a/vscode-extension/src/lsp-document-selector.ts +++ /dev/null @@ -1,10 +0,0 @@ -// Implements [CONFIGEDITOR-SOURCES-OPEN-BUFFER] / [VSIX-LSP-CLIENT-CONFIGURATION]. -// Root validation stays in the LSP; patterns merely ensure VS Code synchronizes -// candidate config buffers so the server can own parsing and optimistic locks. - -import type { DocumentSelector } from "vscode-languageserver-protocol"; - -export const BASILISK_DOCUMENT_SELECTOR: DocumentSelector = [ - { scheme: "file", language: "python" }, - { scheme: "file", pattern: "**/pyproject.toml" }, -]; diff --git a/vscode-extension/src/lsp-trace.ts b/vscode-extension/src/lsp-trace.ts deleted file mode 100644 index 464db8d04..000000000 --- a/vscode-extension/src/lsp-trace.ts +++ /dev/null @@ -1,148 +0,0 @@ -// Implements [VSIX-OUTPUT-CHANNELS]. See docs/specs/VSIX-SPEC.md#VSIX-OUTPUT-CHANNELS -/** - * The "Basilisk LSP Trace" output channel handed to vscode-languageclient as - * `traceOutputChannel` — the user-facing observability surface for LSP - * request/response traffic (GitHub #201). - * - * vscode-languageclient 10 only enables tracing while the trace channel's own - * `logLevel` is `Trace` (`refreshTrace` in its client.js); the documented - * `basilisk.trace.server` setting is consulted strictly after that gate. A - * real `LogOutputChannel` defaults to `Info` — leaving the setting a no-op - * and the channel permanently blank. This adapter makes the setting the real - * switch: its `logLevel` derives from `basilisk.trace.server` (`Trace` for - * "messages"/"verbose", `Info` for "off") and it fires `onDidChangeLogLevel` - * on setting changes, which the client observes to re-evaluate trace mode. - * - * VS Code offers no API to read an output channel back, so writes are also - * recorded for the e2e seam (`lspTraceLines`), mirroring the - * `memoryStatusText()` seam pattern in memory-status.ts. - */ - -import * as vscode from "vscode"; - -/** Lines written to the visible trace channel, in write order. */ -const writtenLines: string[] = []; - -/** E2E seam: every line written to the "Basilisk LSP Trace" channel. */ -export function lspTraceLines(): readonly string[] { - return writtenLines; -} - -/** The channel's effective log level: `Trace` iff `basilisk.trace.server` is on. */ -function configuredLevel(): vscode.LogLevel { - const setting = - vscode.workspace.getConfiguration("basilisk").get("trace.server") ?? "off"; - return setting === "off" ? vscode.LogLevel.Info : vscode.LogLevel.Trace; -} - -/** Severity labels rendered into trace lines, keyed by log level. */ -const LEVEL_LABELS: Partial> = { - [vscode.LogLevel.Trace]: "trace", - [vscode.LogLevel.Debug]: "debug", - [vscode.LogLevel.Info]: "info", - [vscode.LogLevel.Warning]: "warning", - [vscode.LogLevel.Error]: "error", -}; - -/** Emit one line at `level` iff the channel's current level admits it. */ -function writeAt( - channel: vscode.OutputChannel, - level: vscode.LogLevel, - message: string -): void { - if (level < configuredLevel()) { - return; - } - const line = `${new Date().toISOString()} [${LEVEL_LABELS[level] ?? "info"}] ${message}`; - writtenLines.push(line); - channel.appendLine(line); -} - -/** The level-tagged log methods of the `LogOutputChannel` contract. */ -type LeveledMethods = Pick< - vscode.LogOutputChannel, - "trace" | "debug" | "info" | "warn" | "error" ->; - -/** Build the level-tagged log methods writing through `writeAt`. */ -function leveledMethods(channel: vscode.OutputChannel): LeveledMethods { - return { - trace: (message: string): void => writeAt(channel, vscode.LogLevel.Trace, message), - debug: (message: string): void => writeAt(channel, vscode.LogLevel.Debug, message), - info: (message: string): void => writeAt(channel, vscode.LogLevel.Info, message), - warn: (message: string): void => writeAt(channel, vscode.LogLevel.Warning, message), - error: (error: string | Error): void => - writeAt(channel, vscode.LogLevel.Error, typeof error === "string" ? error : error.message), - }; -} - -/** Fire the log-level event when a `basilisk.trace.server` change flips it. */ -function watchTraceSetting( - emitter: vscode.EventEmitter -): vscode.Disposable { - let lastLevel = configuredLevel(); - return vscode.workspace.onDidChangeConfiguration((event) => { - if (!event.affectsConfiguration("basilisk.trace.server")) { - return; - } - const level = configuredLevel(); - if (level !== lastLevel) { - lastLevel = level; - emitter.fire(level); - } - }); -} - -/** - * Create the "Basilisk LSP Trace" channel for the LanguageClient. - * - * Satisfies the `LogOutputChannel` shape vscode-languageclient 10 requires, - * but the sink is a plain output channel so admitted lines always render — - * a real `LogOutputChannel` would re-filter them by its own UI-set level. - */ -export function createLspTraceChannel(): vscode.LogOutputChannel { - const channel = vscode.window.createOutputChannel("Basilisk LSP Trace"); - const levelEmitter = new vscode.EventEmitter(); - const settingWatcher = watchTraceSetting(levelEmitter); - return { - name: channel.name, - get logLevel(): vscode.LogLevel { - return configuredLevel(); - }, - onDidChangeLogLevel: levelEmitter.event, - ...leveledMethods(channel), - append: (value: string): void => { - writtenLines.push(value); - channel.append(value); - }, - appendLine: (value: string): void => { - writtenLines.push(value); - channel.appendLine(value); - }, - replace: (value: string): void => { - writtenLines.length = 0; - writtenLines.push(value); - channel.replace(value); - }, - clear: (): void => { - writtenLines.length = 0; - channel.clear(); - }, - show: ( - columnOrPreserveFocus?: vscode.ViewColumn | boolean, - preserveFocus?: boolean - ): void => { - const focus = - typeof columnOrPreserveFocus === "boolean" ? columnOrPreserveFocus : preserveFocus; - channel.show(focus); - }, - hide: (): void => { - channel.hide(); - }, - dispose: (): void => { - settingWatcher.dispose(); - levelEmitter.dispose(); - channel.dispose(); - }, - }; -} diff --git a/vscode-extension/src/memory-autopilot.ts b/vscode-extension/src/memory-autopilot.ts deleted file mode 100644 index 0d4c5856f..000000000 --- a/vscode-extension/src/memory-autopilot.ts +++ /dev/null @@ -1,279 +0,0 @@ -// Implements [PROFILE-MEMORY-AUTOPILOT] + [PROFILE-MEMORY-LEAK-ACTIONS]. -// See docs/specs/LSP-PROFILING-SPEC.md#PROFILE-MEMORY-AUTOPILOT -/** - * The memory autopilot: captures snapshots automatically while tracking is - * active, so the interactive leak hunt is "set a breakpoint and press Continue" - * instead of a per-pause click treadmill. - * - * Two triggers, one capture core ([`captureSnapshotAndDiff`](./memory-capture.ts)): - * - every debugger pause — [PROFILE-MEMORY-AUTOPILOT-PAUSE] (on by default) - * - a fixed interval — [PROFILE-MEMORY-AUTOPILOT-INTERVAL] (opt-in) - * - * It also surfaces one proactive leak action the first time a site escalates to - * High/Definite ([PROFILE-MEMORY-LEAK-ACTIONS]). - * - * The interval timer's lifecycle follows the store's tracking signal — no timer - * outlives its session. Guards (a synchronous re-entrancy flag plus the shared - * `isMemoryOperationInFlight` flag) ensure a capture's own transparent pause, an - * in-progress manual op, or two near-simultaneous `stopped` events never trigger - * a duplicate capture. - */ - -import * as vscode from "vscode"; -import * as path from "path"; -import { effect } from "@preact/signals-core"; -import { Logger } from "./logger"; -import type { Store } from "./store"; -import type { ProfilerActivity } from "./profiler-state"; -import { withUserProgress } from "./progress-ops"; -import { captureSnapshotAndDiff, isMemoryOperationInFlight } from "./memory-capture"; -import { - confidenceRank, - type LeakConfidence, - type MemoryDiffResult, - type SuspectedLeak, -} from "./memory-decorations"; - -/** Progress title for an automatic capture (visible feedback each pass). */ -const AUTO_CAPTURE_TITLE = "Basilisk: Auto-capturing memory"; - -/** Confidence at/above which the proactive leak action is offered. */ -const ACTION_THRESHOLD: LeakConfidence = "HIGH"; - -/** Minimum interval (seconds) so a misconfigured tiny value can't busy-loop. */ -const MIN_INTERVAL_SECS = 1; -const DEFAULT_INTERVAL_SECS = 30; -/** Milliseconds per second (interval setting is in seconds). */ -const MS_PER_SECOND = 1000; - -// ── E2e ledgers ───────────────────────────────────────────────────────────── -// Same observability pattern as recordedOperations()/appliedMemoryDecorations(): -// the autopilot fires real captures, and these record what it did so tests can -// assert the automation without driving snapshot/diff themselves. - -/** One recorded automatic capture. */ -export interface AutopilotCapture { - /** What triggered it. */ - readonly trigger: "pause" | "interval"; - /** Suspected-leak count from the diff. */ - readonly suspectedLeakCount: number; - /** Highest leak confidence in the diff, or "none". */ - readonly maxConfidence: LeakConfidence | "none"; - /** 1-based line numbers flagged as suspected leaks. */ - readonly leakLines: readonly number[]; -} - -/** One recorded proactive leak-action offer. */ -export interface LeakActionOffer { - readonly file: string; - readonly line: number; - readonly confidence: LeakConfidence; -} - -let autopilotCaptures: AutopilotCapture[] = []; -let leakActionOffers: LeakActionOffer[] = []; - -/** The automatic captures performed this session (e2e seam). */ -export function recordedAutopilotCaptures(): readonly AutopilotCapture[] { - return autopilotCaptures; -} - -/** The proactive leak-action offers made this session (e2e seam). */ -export function recordedLeakOffers(): readonly LeakActionOffer[] { - return leakActionOffers; -} - -// ── State ───────────────────────────────────────────────────────────────── - -let boundStore: Store | undefined; -/** Synchronous re-entrancy guard — set before the first `await` of a capture. */ -let autopilotBusy = false; -/** Interval timer handle while interval mode is armed. */ -let intervalTimer: ReturnType | undefined; -/** The memory session we have already offered a leak action for (offer once). */ -let leakOfferedForSession: string | undefined; -/** Previous tracking state, to detect active⇄idle transitions in the effect. */ -let prevMemoryState: ProfilerActivity = "idle"; - -// ── Registration ──────────────────────────────────────────────────────────── - -/** - * Wire the autopilot to the store. The returned disposable tears down the - * tracking-signal effect and any live interval timer. - */ -export function registerMemoryAutopilot(store: Store): vscode.Disposable[] { - boundStore = store; - prevMemoryState = store.profiler.value.memory; - const disposeEffect = effect(() => { autopilotLifecycle(store); }); - return [{ dispose: () => { disposeEffect(); disposeMemoryAutopilot(); } }]; -} - -/** Clear all autopilot state (deactivation / test teardown). */ -export function disposeMemoryAutopilot(): void { - stopInterval(); - boundStore = undefined; - autopilotBusy = false; - leakOfferedForSession = undefined; - prevMemoryState = "idle"; - autopilotCaptures = []; - leakActionOffers = []; -} - -// ── Pause trigger ──────────────────────────────────────────────────────────── - -/** - * Called by the DAP tracker on every `stopped` event ([PROFILE-MEMORY-AUTOPILOT-PAUSE]). - * Captures automatically when tracking is active for *this* session, the pause - * is a genuine user pause (no memory op already in flight), and pause-capture is - * enabled. Fire-and-forget — never blocks the DAP tracker. - */ -export function notifyDebuggeePause(sessionId: string): void { - const store = boundStore; - if (store === undefined) { return; } - if (autopilotBusy || isMemoryOperationInFlight()) { return; } - if (store.profiler.value.memory !== "active") { return; } - if (store.profiler.value.memoryDebugSessionId !== sessionId) { return; } - if (!isPauseCaptureEnabled()) { return; } - // Set the synchronous guard NOW so a second `stopped` event in the same tick - // (per-thread + allThreadsStopped) cannot start a duplicate capture. - autopilotBusy = true; - Logger.info(`[Memory] autopilot: capturing on pause (session ${sessionId})`); - void runAutoCapture(store, "pause"); -} - -// ── Interval trigger ───────────────────────────────────────────────────────── - -/** Start/stop the interval timer as tracking turns on and off. */ -function autopilotLifecycle(store: Store): void { - const state = store.profiler.value.memory; - if (state === "active" && prevMemoryState !== "active") { - // A fresh tracking session: reset per-session state, arm the interval timer. - resetSessionState(); - armIntervalIfEnabled(store); - } else if (state !== "active" && prevMemoryState === "active") { - stopInterval(); - } - prevMemoryState = state; -} - -/** Arm the interval timer if interval mode is enabled in settings. */ -function armIntervalIfEnabled(store: Store): void { - const config = vscode.workspace.getConfiguration("basilisk.profiler"); - if (!config.get("autoSnapshot", false)) { return; } - const seconds = Math.max(MIN_INTERVAL_SECS, config.get("autoSnapshotInterval", DEFAULT_INTERVAL_SECS)); - stopInterval(); - intervalTimer = setInterval(() => { void onIntervalTick(store); }, seconds * MS_PER_SECOND); - Logger.info(`[Memory] autopilot interval armed: every ${seconds}s`); -} - -/** Stop the interval timer if running. */ -function stopInterval(): void { - if (intervalTimer !== undefined) { - clearInterval(intervalTimer); - intervalTimer = undefined; - } -} - -/** One interval tick: capture if idle and still tracking. */ -async function onIntervalTick(store: Store): Promise { - if (autopilotBusy || isMemoryOperationInFlight()) { return; } - if (store.profiler.value.memory !== "active") { return; } - autopilotBusy = true; - Logger.info("[Memory] autopilot: capturing on interval"); - await runAutoCapture(store, "interval"); -} - -// ── Capture ────────────────────────────────────────────────────────────────── - -/** - * Run one automatic capture under a progress notification, record it, and offer - * a leak action if a site just escalated. Assumes `autopilotBusy` is already set - * by the caller (synchronously, to close the two-events race); always clears it. - */ -async function runAutoCapture(store: Store, trigger: "pause" | "interval"): Promise { - try { - const result = await withUserProgress(AUTO_CAPTURE_TITLE, async (report) => - captureSnapshotAndDiff(store, report), - ); - recordCapture(trigger, result.diff); - maybeOfferLeakActions(store, result.diff); - } catch (err: unknown) { - Logger.warn(`[Memory] autopilot capture failed: ${err instanceof Error ? err.message : String(err)}`); - } finally { - autopilotBusy = false; - } -} - -/** Record a capture's diff into the ledger (the e2e seam). */ -function recordCapture(trigger: "pause" | "interval", diff: MemoryDiffResult | null): void { - const leaks = diff?.suspectedLeaks ?? []; - const worst = worstLeak(leaks); - autopilotCaptures.push({ - trigger, - suspectedLeakCount: leaks.length, - maxConfidence: worst?.confidence ?? "none", - leakLines: leaks.map((leak) => leak.line), - }); - Logger.info( - `[Memory] autopilot capture (${trigger}): ${leaks.length} suspected leak(s), ` + - `max confidence ${worst?.confidence ?? "none"}`, - ); -} - -/** The highest-confidence leak in a list, or undefined when there are none. */ -function worstLeak(leaks: readonly SuspectedLeak[]): SuspectedLeak | undefined { - return leaks.reduce((worst, leak) => { - if (worst === undefined || confidenceRank(leak.confidence) > confidenceRank(worst.confidence)) { - return leak; - } - return worst; - }, undefined); -} - -/** - * Offer the proactive leak action the first time a site reaches the threshold - * confidence this session ([PROFILE-MEMORY-LEAK-ACTIONS]) — at most once, so the - * Continue loop is never spammed. - */ -function maybeOfferLeakActions(store: Store, diff: MemoryDiffResult | null): void { - const worst = worstLeak(diff?.suspectedLeaks ?? []); - if (worst === undefined || confidenceRank(worst.confidence) < confidenceRank(ACTION_THRESHOLD)) { - return; - } - const sessionId = store.profiler.value.memorySessionId; - if (sessionId === undefined || leakOfferedForSession === sessionId) { return; } - leakOfferedForSession = sessionId; - leakActionOffers.push({ file: worst.file, line: worst.line, confidence: worst.confidence }); - Logger.warn(`[Memory] autopilot: suspected leak ${path.basename(worst.file)}:${worst.line} (${worst.confidence})`); - void offerLeakActions(worst); -} - -/** Show the one-click leak-action notification (Reference Graph / Force GC). */ -async function offerLeakActions(leak: SuspectedLeak): Promise { - const showGraph = "Show Reference Graph"; - const forceGc = "Force Garbage Collection"; - const choice = await vscode.window.showWarningMessage( - `Basilisk: Suspected memory leak at ${path.basename(leak.file)}:${leak.line} (${leak.confidence}). ${leak.reason}`, - showGraph, - forceGc, - ); - if (choice === showGraph) { - await vscode.commands.executeCommand("basilisk.memoryReferences"); - } else if (choice === forceGc) { - await vscode.commands.executeCommand("basilisk.memoryGcCollect"); - } -} - -/** Reset per-session state at the start of a new tracking session. */ -function resetSessionState(): void { - autopilotCaptures = []; - leakActionOffers = []; - leakOfferedForSession = undefined; -} - -/** Whether per-pause auto-capture is enabled (default true). */ -function isPauseCaptureEnabled(): boolean { - return vscode.workspace - .getConfiguration("basilisk.profiler") - .get("autoSnapshotOnPause", true); -} diff --git a/vscode-extension/src/memory-capture.ts b/vscode-extension/src/memory-capture.ts deleted file mode 100644 index f44e1e43c..000000000 --- a/vscode-extension/src/memory-capture.ts +++ /dev/null @@ -1,365 +0,0 @@ -// Implements [PROFILE-MEMORY-HOWTO] + [PROFILE-MEMORY-AUTOPILOT]. -// See docs/specs/LSP-PROFILING-SPEC.md#PROFILE-MEMORY-HOWTO -/** - * The editor-as-courier memory round-trip and result presentation. - * - * Extracted from memory-profiler.ts (which was over the 500 LOC limit) so the - * command/lifecycle layer and the capture engine each stay small and focused. - * Both the manual commands ([`memory-profiler.ts`](./memory-profiler.ts)) and the - * autopilot ([`memory-autopilot.ts`](./memory-autopilot.ts)) drive memory through - * the same primitives here, so an auto-capture is byte-for-byte the same flow as a - * hand-clicked one — same purple track, leak decorations, dashboard, and timeline. - * - * Round-trip (one operation): ask the LSP for the injection script (leg 1), run it - * in the paused debuggee via DAP `evaluate`, post the raw output to - * `basilisk.memory.ingest` (leg 2), and present the structured result. A running - * program is transparently paused for the script and resumed after - * ([PROFILE-MEMORY-HOWTO]); a user's own breakpoint pause is left untouched. - */ - -import { delay } from "./timeouts"; -import * as vscode from "vscode"; -import * as fs from "fs"; -import { Logger } from "./logger"; -import type { Store } from "./store"; -import { acquireStoppedFrame, evaluateInDebugSession } from "./dap-evaluate"; -import { withUserProgress } from "./progress-ops"; -import { - toDashboardDiff, - toDashboardSnapshot, - toDiffResult, - toSnapshotResult, - asNumber, - type MemoryIngestResult, -} from "./memory-dashboard-mapping"; -import { - openMemoryDashboard, - type MemoryDashboardSnapshot, - type MemoryTimelinePoint, -} from "./memory-dashboard"; -import { - applyLeakDecorations, - applyMemoryDecorations, - type MemoryDiffResult, -} from "./memory-decorations"; -// ── LSP command ids ───────────────────────────────────────────────────────── - -/** The `basilisk.memory.*` LSP command names (one round-trip leg each). */ -export const LSP_MEM_CMD = { - start: "basilisk.memory.start", - snapshot: "basilisk.memory.snapshot", - diff: "basilisk.memory.diff", - references: "basilisk.memory.references", - objectsByType: "basilisk.memory.objectsByType", - gcCollect: "basilisk.memory.gcCollect", - ingest: "basilisk.memory.ingest", -} as const; - -/** tracemalloc traceback depth injected at start. */ -export const TRACEBACK_DEPTH = 25; - -/** The progress title for starting memory tracking (shared by both entry points). */ -export const MEM_START_TITLE = "Basilisk: Starting memory tracking"; - -/** Milliseconds per second (timeline x-axis is in seconds). */ -const MS_PER_SECOND = 1000; - -/** [PROFILE-UX-PROGRESS] Progress-notification titles, one per memory operation. */ -const MEM_OP_TITLE: Readonly> = { - [LSP_MEM_CMD.snapshot]: "Basilisk: Taking memory snapshot", - [LSP_MEM_CMD.diff]: "Basilisk: Comparing memory snapshots", - [LSP_MEM_CMD.gcCollect]: "Basilisk: Forcing garbage collection", - [LSP_MEM_CMD.references]: "Basilisk: Building the reference graph", -}; - -/** [PROFILE-MEMORY-FINAL] How long to wait for the at-exit snapshot file after the - * program exits (covers the terminate-event/final-flush race), and the poll - * cadence while waiting. */ -const FINAL_SNAPSHOT_WAIT_MS = 3000; -const FINAL_SNAPSHOT_POLL_MS = 100; - -// ── In-flight guard ───────────────────────────────────────────────────────── -// -// [PROFILE-MEMORY-AUTOPILOT-PAUSE] A capture transparently pauses a running -// program, which emits its own `stopped` event; the autopilot must NOT treat that -// (or any in-progress manual op) as a fresh user pause. Every operation brackets -// itself with begin/end, so `isMemoryOperationInFlight()` is true for the whole -// pause→evaluate→resume window — the one synchronous fact both layers agree on. - -let memoryOpsInFlight = 0; - -/** True while any memory round-trip (manual or auto) is mid-flight. */ -export function isMemoryOperationInFlight(): boolean { - return memoryOpsInFlight > 0; -} - -function beginMemoryOp(): void { - memoryOpsInFlight += 1; -} - -function endMemoryOp(): void { - memoryOpsInFlight = Math.max(0, memoryOpsInFlight - 1); -} - -// ── Presentation state ────────────────────────────────────────────────────── - -/** Most recent snapshot, so a later "Compare" can show it alongside the diff. */ -let lastDashboardSnapshot: MemoryDashboardSnapshot | undefined; - -/** - * Rolling timeline of every snapshot captured this session. The dashboard chart - * comes alive across repeated (especially autopilot) captures — "watch the leak - * grow" — instead of always reading "take multiple snapshots". Reset on stop. - */ -let captureTimeline: MemoryTimelinePoint[] = []; - -/** Whether a snapshot has been captured (so "stop" can report honestly). */ -export function hasCapturedSnapshot(): boolean { - return lastDashboardSnapshot !== undefined; -} - -/** Drop per-session presentation state (called when tracking stops). */ -export function resetCaptureState(): void { - lastDashboardSnapshot = undefined; - captureTimeline = []; -} - -// ── Single operation round-trip ───────────────────────────────────────────── - -/** The running LSP client handle. */ -type LspClient = NonNullable; - -/** Everything one staged memory round-trip needs. */ -interface MemoryOperation { - readonly store: Store; - readonly command: string; - readonly extraArgs: Record; - readonly report: (message: string) => void; - /** Suppress user-facing warnings (the autopilot captures silently). */ - readonly quiet: boolean; -} - -/** - * Resolve the running client + active memory session, or null (warning unless - * `quiet`) when the LSP is down or tracking is not active. - */ -function resolveActiveSession(op: MemoryOperation): { client: LspClient; memorySessionId: string } | null { - const client = op.store.client.value; - function notConnected(): null { - if (!op.quiet) { void vscode.window.showErrorMessage("Basilisk LSP not connected"); } - return null; - } - if (client === undefined) { return notConnected(); } - if (!client.isRunning()) { return notConnected(); } - const memorySessionId = op.store.profiler.value.memorySessionId; - if (op.store.profiler.value.memory !== "active" || memorySessionId === undefined) { - if (!op.quiet) { void vscode.window.showWarningMessage("Basilisk: Start memory tracking first."); } - return null; - } - return { client, memorySessionId }; -} - -/** - * Run one memory operation's round-trip and return the LSP's structured result. - * - * Returns null (with an actionable message unless `quiet`) when there is no - * session, the debuggee cannot be paused, or evaluation fails. - */ -async function runMemoryOperation(op: MemoryOperation): Promise { - const active = resolveActiveSession(op); - if (active === null) { return null; } - - beginMemoryOp(); - op.report("Pausing the program…"); - const acquired = await acquireStoppedFrame(); - if (acquired === null) { - endMemoryOp(); - if (!op.quiet) { - void vscode.window.showWarningMessage( - "Basilisk: Could not pause the program for memory inspection — pause at a breakpoint and retry.", - ); - } - return null; - } - - try { - return await evaluateAndIngest(op, { - client: active.client, - memorySessionId: active.memorySessionId, - frameId: acquired.frameId, - }); - } catch (err: unknown) { - const msg = err instanceof Error ? err.message : String(err); - Logger.warn(`[Memory] ${op.command} round-trip failed: ${msg}`); - if (!op.quiet) { void vscode.window.showWarningMessage(`Basilisk: ${msg}`); } - return null; - } finally { - await acquired.release(); - endMemoryOp(); - } -} - -/** The acquired-frame context one round-trip evaluates against. */ -interface OperationContext { - readonly client: LspClient; - readonly memorySessionId: string; - readonly frameId: number; -} - -/** Legs 1+2: fetch the injection script, run it in the frame, post the output to ingest. */ -async function evaluateAndIngest( - op: MemoryOperation, - ctx: OperationContext, -): Promise { - const phase1 = await ctx.client.sendRequest<{ script?: string } | null>("workspace/executeCommand", { - command: op.command, - arguments: [{ memorySessionId: ctx.memorySessionId, ...op.extraArgs }], - }); - const script = phase1?.script; - if (script === undefined || script === "") { return null; } - - op.report("Inspecting the debuggee…"); - const output = await evaluateInDebugSession(script, ctx.frameId); - if (output === null) { - if (!op.quiet) { - void vscode.window.showWarningMessage("Basilisk: Could not run the memory script in the debuggee."); - } - return null; - } - - op.report("Analyzing…"); - return ctx.client.sendRequest("workspace/executeCommand", { - command: LSP_MEM_CMD.ingest, - arguments: [{ memorySessionId: ctx.memorySessionId, output }], - }); -} - -/** - * Run one memory operation under a user-facing progress notification (the manual - * command path: snapshot / diff / gc / references). The pause → evaluate → analyze - * round-trip takes a beat, so its stages narrate under one notification - * ([PROFILE-UX-PROGRESS]). - */ -export async function runMemoryScript( - store: Store, - command: string, - extraArgs: Record = {}, -): Promise { - return withUserProgress( - MEM_OP_TITLE[command] ?? "Basilisk: Inspecting memory", - async (report) => runMemoryOperation({ store, command, extraArgs, report, quiet: false }), - ); -} - -// ── Presentation ──────────────────────────────────────────────────────────── - -/** - * Land a snapshot result: paint the purple allocation track, append a timeline - * point, and retain it for a later "Compare". With `openResultsView` (the - * manual "Take Memory Snapshot" affordance), open the Basilisk memory dashboard - * — the raw V8 `.heapprofile` stays one click away on its own button - * ([PROFILE-NATIVE]). Without it (the autopilot's quiet per-pass capture) only - * the decorations + timeline update — the diff step surfaces the dashboard. - */ -export function presentSnapshot( - result: MemoryIngestResult, - options: { openResultsView: boolean }, -): void { - applyMemoryDecorations(toSnapshotResult(result)); - const dashboard = toDashboardSnapshot(result); - recordTimelinePoint(dashboard); - dashboard.timeline = [...captureTimeline]; - lastDashboardSnapshot = dashboard; - Logger.info(`Memory snapshot: ${dashboard.currentMemory} bytes current`); - if (options.openResultsView) { - openMemoryDashboard(dashboard); - } -} - -/** - * Land a diff result: paint the leak decorations (confidence-coloured) and refresh - * the dashboard with the leak analysis alongside the last snapshot. Returns the - * typed diff so callers (the autopilot) can read suspected-leak confidence. - */ -export function presentDiff(result: MemoryIngestResult): MemoryDiffResult { - const diff = toDiffResult(result); - applyLeakDecorations(diff); - if (lastDashboardSnapshot !== undefined) { - openMemoryDashboard(lastDashboardSnapshot, toDashboardDiff(result)); - } - const leaks = diff.suspectedLeaks; - Logger.info(`Memory diff: ${leaks.length} suspected leak(s)`); - return diff; -} - -/** The result of one combined autopilot capture. */ -export interface CaptureResult { - readonly snapshot: MemoryIngestResult | null; - readonly diff: MemoryDiffResult | null; -} - -/** - * The autopilot's combined capture: a snapshot then a diff, presented quietly - * (decorations + dashboard + timeline, no new `.heapprofile` tab on each pass). - * Brackets the whole pair as one in-flight op so the transparent pauses it may - * perform never trigger a second auto-capture ([PROFILE-MEMORY-AUTOPILOT-PAUSE]). - */ -export async function captureSnapshotAndDiff( - store: Store, - report: (message: string) => void, -): Promise { - beginMemoryOp(); - try { - const snapshotResult = await runMemoryOperation({ - store, command: LSP_MEM_CMD.snapshot, extraArgs: {}, report, quiet: true, - }); - if (snapshotResult?.kind === "snapshot") { - presentSnapshot(snapshotResult, { openResultsView: false }); - } - const diffResult = await runMemoryOperation({ - store, command: LSP_MEM_CMD.diff, extraArgs: {}, report, quiet: true, - }); - const diff = diffResult?.kind === "diff" ? presentDiff(diffResult) : null; - return { snapshot: snapshotResult, diff }; - } finally { - endMemoryOp(); - } -} - -/** Append a timeline point for a snapshot (seconds-epoch x-axis the chart reads). */ -function recordTimelinePoint(snapshot: MemoryDashboardSnapshot): void { - captureTimeline.push({ - timestamp: Date.now() / MS_PER_SECOND, - currentMemory: snapshot.currentMemory, - peakMemory: snapshot.peakMemory, - gcObjects: snapshot.gcObjects, - }); -} - -// ── At-exit final snapshot ────────────────────────────────────────────────── - -/** - * Read the debuggee's at-exit snapshot payload, deleting the file once it is read - * intact ([PROFILE-MEMORY-FINAL]). The debuggee writes atomically (sibling temp + - * `os.replace`), so a readable marker-bearing file is whole. A short poll covers - * the terminate-event/flush race. The file is unlinked ONLY once a complete - * (marker-bearing) payload is read — a missing or marker-less read is never - * destructive. Returns null when no usable payload arrived by the deadline. - */ -export async function readFinalSnapshot(path: string): Promise { - const deadline = Date.now() + FINAL_SNAPSHOT_WAIT_MS; - for (;;) { - const contents = await fs.promises.readFile(path, "utf8").catch(() => null); - if (contents?.includes("__BASILISK_MEM__") === true) { - await fs.promises.unlink(path).catch(() => undefined); - return contents; - } - if (Date.now() >= deadline) { return null; } - await delay(FINAL_SNAPSHOT_POLL_MS); - } -} - -/** Read a `currentMemory` figure off an ingest result (for logging/summaries). */ -export function snapshotCurrentMemory(result: MemoryIngestResult): number { - return asNumber(result.currentMemory); -} diff --git a/vscode-extension/src/memory-dashboard-mapping.ts b/vscode-extension/src/memory-dashboard-mapping.ts deleted file mode 100644 index b74be6a49..000000000 --- a/vscode-extension/src/memory-dashboard-mapping.ts +++ /dev/null @@ -1,134 +0,0 @@ -// Implements [LSPPROF]. See docs/specs/LSP-PROFILING-SPEC.md#PROFILE-MEMORY-INGEST -/** - * Mapping from raw `basilisk.memory.ingest` results to the dashboard's - * strongly typed shapes. Pure converters — no VS Code APIs, no state. - * Extracted from memory-profiler.ts to satisfy the 500 LOC file limit. - */ - -import { numberArrayField, recordArrayField } from "./unknown-shape"; -import type { MemoryDashboardSnapshot, MemoryDiffData } from "./memory-dashboard"; -import type { - LeakConfidence, - MemoryAllocation, - MemoryDiffResult, - MemorySnapshotResult, -} from "./memory-decorations"; - -/** A tagged ingest result returned by `basilisk.memory.ingest`. */ -export interface MemoryIngestResult { - kind: "snapshot" | "diff" | "gc" | "refs" | "objects" | "ack"; - [field: string]: unknown; -} - -/** Coerce an `unknown` JSON field to a string (never an object stringification). */ -export function asString(value: unknown, fallback = ""): string { - return typeof value === "string" ? value : fallback; -} - -/** The four leak-confidence grades the dashboard renders badges for. */ -const CONFIDENCE_GRADES = ["definite", "high", "medium", "low"] as const; - -/** A leak confidence grade, or `"low"` when the server sent something else. */ -export function toConfidence(value: string): MemoryDiffData["suspectedLeaks"][number]["confidence"] { - const lowered = value.toLowerCase(); - return CONFIDENCE_GRADES.find((grade) => grade === lowered) ?? "low"; -} - -/** Coerce an `unknown` JSON field to a finite number. */ -export function asNumber(value: unknown, fallback = 0): number { - return typeof value === "number" && Number.isFinite(value) ? value : fallback; -} - -/** The four `LeakConfidence` grades the LSP reports, uppercase on the wire. */ -const LEAK_CONFIDENCES = ["LOW", "MEDIUM", "HIGH", "DEFINITE"] as const; - -/** A wire leak-confidence grade, or `"LOW"` when the server sent something else. */ -function toLeakConfidence(value: string): LeakConfidence { - const upper = value.toUpperCase(); - return LEAK_CONFIDENCES.find((grade) => grade === upper) ?? "LOW"; -} - -/** Read one allocation entry, defaulting every absent field. */ -function toAllocation(raw: Record): MemoryAllocation { - return { - file: asString(raw.file), - line: asNumber(raw.line), - size: asNumber(raw.size), - count: asNumber(raw.count), - }; -} - -/** - * Decode the decoration-facing snapshot shape from a raw ingest result. - * - * `MemoryIngestResult` is an index-signature bag, so it structurally overlaps - * `MemorySnapshotResult` without actually guaranteeing any of its fields. - * Building the value field by field means a server that stops sending - * `topAllocations` yields an empty list rather than an `undefined` the - * decorations layer would iterate. - */ -export function toSnapshotResult(result: MemoryIngestResult): MemorySnapshotResult { - return { - memorySessionId: asString(result.memorySessionId), - snapshotId: asString(result.snapshotId), - currentMemory: asNumber(result.currentMemory), - peakMemory: asNumber(result.peakMemory), - topAllocations: recordArrayField(result, "topAllocations").map(toAllocation), - }; -} - -/** Decode the decoration-facing diff shape from a raw ingest result. */ -export function toDiffResult(result: MemoryIngestResult): MemoryDiffResult { - return { - totalGrowth: asNumber(result.totalGrowth), - totalFreed: asNumber(result.totalFreed), - netGrowth: asNumber(result.netGrowth), - suspectedLeaks: recordArrayField(result, "suspectedLeaks").map((leak) => ({ - file: asString(leak.file), - line: asNumber(leak.line), - sizeGrowth: asNumber(leak.sizeGrowth), - countGrowth: asNumber(leak.countGrowth), - currentSize: asNumber(leak.currentSize), - confidence: toLeakConfidence(asString(leak.confidence, "LOW")), - reason: asString(leak.reason), - })), - }; -} - -/** Map an ingest snapshot result to the dashboard's snapshot shape. */ -export function toDashboardSnapshot(result: MemoryIngestResult): MemoryDashboardSnapshot { - return { - memorySessionId: asString(result.memorySessionId), - snapshotId: asString(result.snapshotId), - currentMemory: asNumber(result.currentMemory), - peakMemory: asNumber(result.peakMemory), - gcObjects: asNumber(result.gcObjects), - gcCounts: numberArrayField(result, "gcCounts"), - topAllocations: recordArrayField(result, "topAllocations").map(toAllocation), - timeline: [], - heapProfilePath: asString(result.heapProfilePath), - }; -} - -/** Map an ingest diff result to the dashboard's diff shape (lowercasing confidence). */ -export function toDashboardDiff(result: MemoryIngestResult): MemoryDiffData { - const leaks = recordArrayField(result, "suspectedLeaks"); - return { - totalGrowth: asNumber(result.totalGrowth), - totalFreed: asNumber(result.totalFreed), - netGrowth: asNumber(result.netGrowth), - grownAllocations: [], - suspectedLeaks: leaks.map((leak) => { - return { - file: asString(leak.file), - line: asNumber(leak.line), - sizeGrowth: asNumber(leak.sizeGrowth), - countGrowth: asNumber(leak.countGrowth), - currentSize: asNumber(leak.currentSize), - currentCount: asNumber(leak.currentCount), - confidence: toConfidence(asString(leak.confidence, "low")), - reason: asString(leak.reason), - }; - }), - }; -} diff --git a/vscode-extension/src/memory-dashboard.ts b/vscode-extension/src/memory-dashboard.ts deleted file mode 100644 index 6d3654208..000000000 --- a/vscode-extension/src/memory-dashboard.ts +++ /dev/null @@ -1,487 +0,0 @@ -// Implements [LSPPROF]. See docs/specs/LSP-PROFILING-SPEC.md#LSPPROF -/** - * Memory dashboard webview for Basilisk memory profiling. - * - * Provides: - * - Summary cards: current memory, peak memory, gc objects, gc counts - * - Timeline chart: memory usage over time (Canvas 2D line chart) - * - Top allocations table: file, line, size, count — click to navigate - * - Leak confidence badges: Definite, High, Medium, Low - * - Dual heat map mode toggle: CPU (orange) + Memory (purple) - * - * All data comes from the LSP via memory snapshot/diff commands. - * This module handles only the client-side visualization. - */ - -import * as vscode from "vscode"; -import { Logger } from "./logger"; -import type { MemoryAllocation } from "./memory-decorations"; -import { openNativeTraceViewer, openSpeedscopeImport } from "./profiler-flamegraph-html"; -import { - PROFILER_CSS_VARS, - PROFILER_CSS_RESET, - PROFILER_CSS_CARDS, - PROFILER_CSS_TABLE, - PROFILER_CSS_HEADING, - PROFILER_JS_UTILS, - formatBytes as formatBytesShared, -} from "./profiler-styles"; -import { - buildWebviewDocument, - embedJson, - handleSourceNavigation, - SingletonWebviewPanel, - type WebviewMessage, -} from "./profiler-webview"; - -// ── Types ───────────────────────────────────────────────────────────────── - -/** A single time-series data point for the memory timeline. */ -export interface MemoryTimelinePoint { - timestamp: number; - currentMemory: number; - peakMemory: number; - gcObjects: number; -} - -/** Snapshot data extended with timeline and gc info. */ -export interface MemoryDashboardSnapshot { - memorySessionId: string; - snapshotId: string; - currentMemory: number; - peakMemory: number; - gcObjects: number; - gcCounts: number[]; - topAllocations: MemoryAllocation[]; - timeline: MemoryTimelinePoint[]; - /** On-disk V8 `.heapprofile` for the built-in viewer; empty when not written. */ - heapProfilePath: string; -} - -/** A suspected leak from a snapshot diff. */ -export interface SuspectedLeak { - file: string; - line: number; - sizeGrowth: number; - countGrowth: number; - currentSize: number; - currentCount: number; - confidence: "definite" | "high" | "medium" | "low"; - reason: string; -} - -/** Diff data comparing two memory snapshots. */ -export interface MemoryDiffData { - totalGrowth: number; - totalFreed: number; - netGrowth: number; - suspectedLeaks: SuspectedLeak[]; - grownAllocations: MemoryAllocation[]; -} - -// ── State ───────────────────────────────────────────────────────────────── - -// One panel, one message handler — the shared host guarantees a re-opened -// dashboard (the autopilot re-renders it on every pause) never stacks a second -// navigation handler ([PROFILE-WEBVIEW-HOST]). -const memoryDashboardPanel = new SingletonWebviewPanel("basilisk.memoryDashboard", (msg) => { - if (!handleMemoryDashboardMessage(msg)) { - handleSourceNavigation(msg); - } -}); - -/** The dashboard action buttons' message → command routing ([PROFILE-MEMORY-DISCOVERY]). */ -const DASHBOARD_ACTION_COMMANDS: Readonly> = { - takeSnapshot: "basilisk.memorySnapshot", - compareSnapshots: "basilisk.memoryDiff", -}; - -/** - * Route a dashboard action-button message to its real memory command, so the - * dashboard's "take more snapshots" advice is a button, not homework - * ([PROFILE-MEMORY-DISCOVERY], #263). Returns whether the message was one of - * the dashboard's actions (source-navigation clicks fall through). - */ -export function handleMemoryDashboardMessage(msg: WebviewMessage): boolean { - if (msg.type === "openHeapProfile" && msg.file !== undefined && msg.file !== "") { - // The dashboard is the landing view; the raw V8 `.heapprofile` opens in - // VS Code's built-in viewer on demand ([PROFILE-NATIVE]). - void openNativeTraceViewer(msg.file, msg.file); - return true; - } - if (msg.type === "openSpeedscope" && msg.file !== undefined && msg.file !== "") { - // speedscope imports V8 `.heapprofile` too — same loopback-served deep - // link as the CPU panel ([PROFILE-VIEWER-DELIVERY]). - void openSpeedscopeImport(msg.file); - return true; - } - const command = DASHBOARD_ACTION_COMMANDS[msg.type]; - if (command === undefined) { - return false; - } - void vscode.commands.executeCommand(command); - return true; -} - -// ── Public API ──────────────────────────────────────────────────────────── - -/** - * Open or reveal the memory dashboard webview. - * If diffData is provided, the leak analysis section is shown. - */ -export function openMemoryDashboard( - snapshotData: MemoryDashboardSnapshot, - diffData?: MemoryDiffData, -): void { - memoryDashboardPanel.show( - "Basilisk Memory Dashboard", - buildMemoryDashboardHtml(snapshotData, diffData), - ); - const leakCount = diffData?.suspectedLeaks.length ?? 0; - Logger.info( - `Memory dashboard opened: ${formatBytesShared(snapshotData.currentMemory)} current, ` + - `${snapshotData.topAllocations.length} allocations, ${leakCount} suspected leaks`, - ); -} - -/** Dispose the memory dashboard panel if open. */ -export function disposeMemoryDashboard(): void { - memoryDashboardPanel.dispose(); -} - -// ── HTML builder ────────────────────────────────────────────────────────── - -/** Build the complete dashboard HTML (exported as an e2e seam). */ -export function buildMemoryDashboardHtml( - snapshot: MemoryDashboardSnapshot, - diff?: MemoryDiffData, -): string { - return buildWebviewDocument({ - title: "Basilisk Memory Dashboard", - css: buildDashboardHeadCss(), - body: buildDashboardBodyHtml(snapshot.heapProfilePath !== ""), - script: buildDashboardScriptTag(snapshot, diff), - }); -} - -function buildDashboardHeadCss(): string { - return `${PROFILER_CSS_VARS}${PROFILER_CSS_RESET} - body { padding: 16px; overflow-y: auto; } - h1 .accent { color: var(--prof-mem-critical); } - .card .value.purple { color: var(--prof-mem-critical); } - ${PROFILER_CSS_HEADING}${PROFILER_CSS_CARDS}${PROFILER_CSS_TABLE}${buildDashboardCss()}`; -} - -function buildDashboardBodyHtml(hasHeapProfile: boolean): string { - // The dashboard is the landing view; the raw V8 `.heapprofile` opens on - // demand rather than by default ([PROFILE-NATIVE]) — in the built-in viewer - // or in speedscope.app ([PROFILE-VIEWER-DELIVERY]). - const heapProfileButtons = hasHeapProfile - ? ` - - ` - : ""; - return ` -

BASILISK MEMORY

-
- - ${heapProfileButtons} -
- ${buildSummaryCardsHtml()} -

Memory Timeline

-
- -
-
-

Heat Map Mode

-
- - -
-

Top Allocations

- - - -
LocationSizeObjects
-
-

Suspected Leaks

-
-
`; -} - -function buildDashboardScriptTag( - snapshot: MemoryDashboardSnapshot, - diff?: MemoryDiffData, -): string { - // Allocation paths and leak reasons come from the profiled program — embed - // them so they can never close the inline ` would - * close the script element early; escaping `<` keeps it an opaque JS string. - */ -export function embedJson(value: unknown): string { - return JSON.stringify(value).split("<").join("\\u003c"); -} - -// ── Document shell ──────────────────────────────────────────────────────── - -/** The parts every profiler webview document is assembled from. */ -export interface WebviewDocument { - readonly title: string; - readonly css: string; - readonly body: string; - readonly script: string; -} - -/** - * Build a complete, CSP-locked webview HTML document. A fresh nonce gates the - * (self-generated) inline script; `default-src 'none'` blocks every external - * resource; `img-src data:` admits only inline data URIs (the embedded flame - * graph SVG). Even if profiled-program data slipped an escape, the browser - * refuses to run any inline script without the nonce. - */ -export function buildWebviewDocument(doc: WebviewDocument): string { - const nonce = randomBytes(CSP_NONCE_BYTES).toString("base64"); - const csp = `default-src 'none'; img-src data:; style-src 'nonce-${nonce}'; script-src 'nonce-${nonce}';`; - return ` - - - - - - ${doc.title} - - -${doc.body} - - -`; -} - -// ── Singleton panel ─────────────────────────────────────────────────────── - -/** A message posted from a profiler webview back to the extension. */ -export interface WebviewMessage { - readonly type: string; - readonly file?: string; - readonly line?: number; -} - -/** Lifecycle options for a singleton editor-area webview. */ -export interface SingletonWebviewPanelOptions { - readonly viewColumn?: vscode.ViewColumn; - readonly retainContextWhenHidden?: boolean; - readonly enableFindWidget?: boolean; - readonly onDidReveal?: () => void; - readonly onDidDispose?: () => void; -} - -/** - * Route the message every profiler panel shares: a click on a source location - * opens that file beside the panel. Returns whether the message was handled so - * panels can layer their own message types on top. - */ -export function handleSourceNavigation(msg: WebviewMessage): boolean { - if (msg.type !== "navigateToSource" || msg.file === undefined || msg.line === undefined) { - return false; - } - const position = new vscode.Position(msg.line - 1, 0); - void vscode.window.showTextDocument(vscode.Uri.file(msg.file), { - selection: new vscode.Range(position, position), - viewColumn: vscode.ViewColumn.One, - }); - return true; -} - -/** - * A create-once / reveal-after webview panel whose message handler is bound - * exactly once per panel instance — re-opening with fresh data re-renders the - * HTML but never stacks a second handler. - */ -export class SingletonWebviewPanel { - private panel: vscode.WebviewPanel | undefined; - private visible = false; - - constructor( - private readonly viewType: string, - private readonly onMessage: (msg: WebviewMessage) => void, - private readonly options: SingletonWebviewPanelOptions = {}, - ) {} - - /** Open the panel (or reveal the existing one) and swap in the new document. */ - public show(title: string, html: string): void { - if (this.panel !== undefined) { - this.panel.title = title; - this.panel.reveal(this.options.viewColumn ?? vscode.ViewColumn.Beside); - } else { - this.panel = vscode.window.createWebviewPanel( - this.viewType, - title, - this.options.viewColumn ?? vscode.ViewColumn.Beside, - { - enableScripts: true, - retainContextWhenHidden: this.options.retainContextWhenHidden ?? true, - enableFindWidget: this.options.enableFindWidget ?? false, - localResourceRoots: [], - }, - ); - this.panel.onDidDispose(() => { - this.panel = undefined; - this.visible = false; - this.options.onDidDispose?.(); - }); - this.visible = this.panel.visible; - // Bound once per panel instance — the whole reason this class exists. - this.panel.webview.onDidReceiveMessage(this.onMessage); - this.panel.onDidChangeViewState((event) => { - const becameVisible = event.webviewPanel.visible && !this.visible; - this.visible = event.webviewPanel.visible; - if (becameVisible) { - this.options.onDidReveal?.(); - } - }); - } - this.panel.webview.html = html; - } - - /** Post data to the open panel; false when no panel exists. */ - public postMessage(message: unknown): Thenable { - return this.panel?.webview.postMessage(message) ?? Promise.resolve(false); - } - - /** Whether the panel is currently open (e2e seam). */ - public isOpen(): boolean { - return this.panel !== undefined; - } - - /** Whether the live panel is already frontmost in an editor group. */ - public isVisible(): boolean { - return this.visible; - } - - /** Close and forget the panel (extension teardown). */ - public dispose(): void { - this.panel?.dispose(); - this.panel = undefined; - this.visible = false; - } -} diff --git a/vscode-extension/src/profiler.ts b/vscode-extension/src/profiler.ts deleted file mode 100644 index 606bc60c3..000000000 --- a/vscode-extension/src/profiler.ts +++ /dev/null @@ -1,511 +0,0 @@ -// Implements [LSPPROF]. See docs/specs/LSP-PROFILING-SPEC.md#LSPPROF -/** - * Profiler UI module for the Basilisk VS Code extension. - * - * Provides: - * - Profiler status bar item (pulsing orange dot while profiling) - * - Command handlers for start/stop/snapshot/attach-to-debug - * - Flamegraph webview panel - * - Progress notification during active profiling - * - * All profiling logic lives in the LSP server. This module handles - * only the client-side UI and command routing. - */ - -import * as vscode from "vscode"; -import { Logger } from "./logger"; -import type { Store } from "./store"; -import { evaluateInDebugSession, waitForStoppedFrame } from "./dap-evaluate"; -import { withUserProgress } from "./progress-ops"; -import { - applyProfileDecorations, - disposeProfileDecorations, - type ProfileResult, -} from "./profiler-decorations"; -import { - disposeFlamegraphPanel, - openFlamegraphWebview, - presentProfileResult, -} from "./profiler-flamegraph-html"; -import { disposeProfileServer } from "./profile-server"; -import { shouldProfileOnLaunch, waitForDebuggeePid } from "./profiler-launch"; -import { bindProfilerStatusBar, registerProgressListener } from "./profiler-status"; - -// Re-exported so tests keep one import site for the profiler's public seams. -export { profilerStatusText } from "./profiler-status"; -export { shouldProfileOnLaunch } from "./profiler-launch"; - -// ── Constants ───────────────────────────────────────────────────────────── - -/** LSP command names (must match basilisk-common constants). */ -const LSP_CMD = { - start: "basilisk.profiler.start", - stop: "basilisk.profiler.stop", - snapshot: "basilisk.profiler.snapshot", - list: "basilisk.profiler.list", - cooperativeScript: "basilisk.profiler.cooperativeScript", - cooperativeAttach: "basilisk.profiler.cooperativeAttach", -} as const; - -/** Ack printed by the injected cooperative sampler ([PROFILE-COOPERATIVE]). */ -const COOPERATIVE_ACK = "__BASILISK_CPU_ACK__"; - -// ── Constants ───────────────────────────────────────────────────────────── - -/** Default sample rate fallback (Hz). */ -const DEFAULT_SAMPLE_RATE = 100; - -// ── State ───────────────────────────────────────────────────────────────── -// -// Session state (is a profile running? which PID/session?) lives in the store -// as a reactive signal ([PROFILE-PROCESSES-REACTIVE]) so the status bar and the -// Python Processes panel react to it. Only the last result is cached here — a -// UI artifact (re-applied as decorations on editor focus), not session state. - -let lastResult: ProfileResult | undefined; - -// ── Registration ────────────────────────────────────────────────────────── - -/** - * Register profiler UI components. Called once during extension activation. - * Returns disposables for cleanup. - */ -export function registerProfiler(store: Store): vscode.Disposable[] { - const disposables: vscode.Disposable[] = []; - - // Status bar item — renders reactively from the store's profiler signal - // ([PROFILE-UX-PROGRESS] lifecycle lives in profiler-status.ts). - disposables.push(bindProfilerStatusBar(store)); - - // Client-side commands that proxy to LSP. - disposables.push( - vscode.commands.registerCommand("basilisk.profileStart", async () => handleProfileStart()), - vscode.commands.registerCommand("basilisk.profileStop", async () => handleProfileStop(store)), - vscode.commands.registerCommand("basilisk.profileSnapshot", async () => handleProfileSnapshot(store)), - vscode.commands.registerCommand("basilisk.profileAttachToDebug", async () => handleProfileAttachToDebug(store)), - vscode.commands.registerCommand("basilisk.profileShowResults", () => { handleProfileShowResults(); }), - ); - - // Listen for profiler progress notifications from LSP. - disposables.push(registerProgressListener(store)); - - // Clear decorations when active editor changes (optional, re-applies on focus). - disposables.push( - vscode.window.onDidChangeVisibleTextEditors(() => { - if (lastResult !== undefined) { - applyProfileDecorations(lastResult); - } - }), - ); - - // "Profile on Launch" — automatically start profiling when a debug session starts. - disposables.push( - vscode.debug.onDidStartDebugSession((session) => { - if (shouldProfileOnLaunch(session) && store.profiler.value.cpu === "idle") { - Logger.info(`Profile on Launch: auto-profiling debug session ${session.id}`); - notifyBreakpointsSuppressedForProfiling(); - void startProfilerOnLaunch(store, session.id); - } - }), - ); - - // Auto-stop profiling when debug session ends. Only an adopted ("active") - // session has results to collect; a start still in flight is cleaned up by - // startProfilerOnLaunch's own finally, so don't fire a "no session" warning. - disposables.push( - vscode.debug.onDidTerminateDebugSession(() => { - if (store.profiler.value.cpu === "active") { - Logger.info("Debug session ended — auto-stopping profiler"); - void handleProfileStop(store); - } - }), - ); - - return disposables; -} - -// ── Session adoption ────────────────────────────────────────────────────── - -/** The shape every profiling start command resolves to. */ -interface StartedSession { - sessionId: string; - pid: number; - pythonVersion: string; -} - -/** - * Adopt a freshly started session into the shared reactive state. Writing it to - * the store drives the status bar, the panel chrome + button gating, and the - * stop/snapshot/progress routing — all from one signal - * ([PROFILE-PROCESSES-REACTIVE]). - */ -function adoptSession(store: Store, result: StartedSession, announcement: string): void { - store.profilerActive(result.pid, result.sessionId); - Logger.info( - `Profiling started: PID ${result.pid}, Python ${result.pythonVersion}, session ${result.sessionId}`, - ); - vscode.window.showInformationMessage(announcement); -} - -/** - * A "stop the current CPU session first" warning. Only the CPU leg blocks a CPU - * start ([PROFILE-PROCESSES-REACTIVE]), so this names the active CPU profile. - */ -function busyMessage(store: Store): string { - const session = store.profiler.value; - const pid = session.cpuPid !== undefined ? ` PID ${session.cpuPid}` : ""; - return `Basilisk: Already profiling${pid}. Stop the current CPU session first.`; -} - -// ── Launch flows ([PROFILE-COOPERATIVE], [PROFILE-UX-PROGRESS]) ─────────── - -/** The single progress title every CPU-start flow shares. */ -const CPU_START_TITLE = "Basilisk: Starting CPU profiler"; - -/** Shown once per session: a profiling launch neutralises breakpoints (ux-6). */ -let breakpointSuppressionNoticeShown = false; - -/** - * Tell the user, once, that a profiling launch runs to completion with their - * breakpoints disabled — otherwise a Run & Profile (or a plain F5 with the - * global `profiler.profileOnLaunch` setting on) silently never stops at a - * breakpoint, which is baffling while the gutter still shows them armed (ux-6). - * Only fires when breakpoints are actually set, so a breakpoint-free run is - * never narrated. - */ -function notifyBreakpointsSuppressedForProfiling(): void { - if (breakpointSuppressionNoticeShown || vscode.debug.breakpoints.length === 0) { - return; - } - breakpointSuppressionNoticeShown = true; - void vscode.window.showInformationMessage( - "Basilisk: Profiling run — your breakpoints are disabled so the program runs to completion. Launch without profiling to debug with breakpoints.", - ); -} - -/** - * Auto-start dispatcher for a freshly launched debug session: cooperative - * sampler on macOS, py-spy attach elsewhere. The whole start runs under one - * progress notification with stage messages, and the status bar shows a - * spinner until the live sample counter takes over — a click is never - * followed by silence ([PROFILE-UX-PROGRESS]). - */ -async function startProfilerOnLaunch(store: Store, debugSessionId: string): Promise { - store.profilerStarting(); - try { - await withUserProgress(CPU_START_TITLE, async (report) => { - if (process.platform === "darwin") { - // macOS gates task ports behind entitled debuggers — even root - // py-spy gets EPERM — so use the cooperative in-process sampler - // injected at the entry pause ([PROFILE-COOPERATIVE], OOTB). - await startCooperativeProfileOnLaunch(store, report); - return; - } - // The debuggee PID arrives asynchronously via the DAP `process` event, - // so wait for it before attaching (avoids a "not ready yet" race). - report("Waiting for the program to start…"); - const ready = await waitForDebuggeePid(store, debugSessionId); - if (ready && store.profiler.value.cpu === "starting") { - report("Attaching the sampler…"); - await handleProfileAttachToDebug(store); - } - }); - } finally { - // Any failure path above leaves the start unfinished — never strand the - // spinner: only an adopted session reaches "active". - if (store.profiler.value.cpu !== "active") { store.profilerStopped(); } - } -} - -/** - * OOTB CPU profiling for a debug-launched session: inject the in-process - * sampler at the `stopOnEntry` pause via the courier, resume the program, - * then adopt the streamed session. No task ports, no elevation prompt. - */ -async function startCooperativeProfileOnLaunch( - store: Store, - report: (message: string) => void, -): Promise { - const client = store.client.value; - if (client?.isRunning() !== true) { return; } - - report("Waiting for the program to pause at entry…"); - const frameId = await waitForStoppedFrame(); - if (frameId === null) { - vscode.window.showWarningMessage( - "Basilisk: The debuggee did not pause at entry, so the profiler could not be injected.", - ); - return; - } - - const sampleRate = vscode.workspace - .getConfiguration("basilisk") - .get("profiler.sampleRate", DEFAULT_SAMPLE_RATE); - - try { - const leg1 = await client.sendRequest<{ script: string; sampleFile: string } | undefined>( - "workspace/executeCommand", - { command: LSP_CMD.cooperativeScript, arguments: [{ sampleRate }] }, - ); - if (leg1?.script === undefined) { return; } - - report("Injecting the in-process sampler…"); - const ack = await evaluateInDebugSession(leg1.script, frameId); - await vscode.commands.executeCommand("workbench.action.debug.continue"); - if (!ack?.includes(COOPERATIVE_ACK)) { - vscode.window.showWarningMessage("Basilisk: Could not inject the in-process CPU sampler."); - return; - } - - report("Starting the sample stream…"); - const result = await client.sendRequest( - "workspace/executeCommand", - { - command: LSP_CMD.cooperativeAttach, - arguments: [{ sampleFile: leg1.sampleFile, sampleRate }], - }, - ); - if (result !== undefined && result !== null && store.profiler.value.cpu !== "active") { - adoptSession(store, result, `Basilisk: Profiling current file (PID ${result.pid}, in-process sampler)`); - } - } catch (err: unknown) { - const msg = err instanceof Error ? err.message : String(err); - Logger.error(`Cooperative profile start failed: ${msg}`); - vscode.window.showErrorMessage(`Basilisk: ${msg}`); - } -} - -// ── Command handlers ────────────────────────────────────────────────────── - -async function handleProfileStart(): Promise { - // [PROFILE-PROCESSES-LAUNCH] #62: the profiler no longer asks the user to - // hand-type a PID (and there was never a real "auto-detect"). Reveal the - // Python Processes panel so they can pick a process — or run & profile the - // current file — and start profiling with one click. - await vscode.commands.executeCommand("basilisk.pythonProcesses.focus"); - vscode.window.showInformationMessage( - "Basilisk: Pick a Python process in the panel to profile, or run & profile the current file.", - ); -} - -/** - * Start profiling a specific PID — the panel's one-click path. Updates the - * shared session state (status bar, active session) so Stop, Snapshot, and - * live progress work exactly as they do for the debug-attach flow. The LSP - * resolves `preset` server-side; `sampleRate`/`includeNative` apply when the - * preset is `"default"`. Implements [PROFILE-PROCESSES-LAUNCH]. - */ -export async function startProfilingForPid(store: Store, pid: number, preset: string): Promise { - const client = store.client.value; - if (client?.isRunning() !== true) { - vscode.window.showWarningMessage("Basilisk: Language server not running."); - return; - } - // CPU starts gate on the CPU leg only — a CPU profile may begin while memory - // tracking is live, but never a second CPU run on top of an active one - // ([PROFILE-PROCESSES-REACTIVE]). - if (store.cpuBusy.value) { - vscode.window.showWarningMessage(busyMessage(store)); - return; - } - - const cfg = vscode.workspace.getConfiguration("basilisk"); - const args: Record = { - pid, - preset, - sampleRate: cfg.get("profiler.sampleRate", DEFAULT_SAMPLE_RATE), - includeNative: cfg.get("profiler.includeNative", false), - }; - - store.profilerStarting(); - try { - await withUserProgress(CPU_START_TITLE, async (report) => { - report(`Attaching to PID ${pid}…`); - const result = await client.sendRequest< - { sessionId: string; pid: number; pythonVersion: string } | undefined - >("workspace/executeCommand", { - command: LSP_CMD.start, - arguments: [args], - }); - - if (result !== undefined && result !== null) { - adoptSession(store, result, `Basilisk: Profiling PID ${result.pid} (Python ${result.pythonVersion})`); - } - }); - } catch (err: unknown) { - const msg = err instanceof Error ? err.message : String(err); - Logger.error(`Profile start failed: ${msg}`); - vscode.window.showErrorMessage(`Basilisk: ${msg}`); - } finally { - // A failed start never reached "active" — clear the spinner. - if (store.profiler.value.cpu !== "active") { store.profilerStopped(); } - } -} - -async function handleProfileStop(store: Store): Promise { - const client = store.client.value; - const sessionId = store.profiler.value.cpuSessionId; - if (client?.isRunning() !== true || sessionId === undefined) { - vscode.window.showWarningMessage("Basilisk: No active profiling session."); - return; - } - - try { - // Collecting samples + writing artifacts takes a beat — show it - // ([PROFILE-UX-PROGRESS]). - const result = await withUserProgress( - "Basilisk: Stopping profiler", - async (report) => { - report("Collecting results…"); - return client.sendRequest("workspace/executeCommand", { - command: LSP_CMD.stop, - arguments: [{ sessionId, format: "speedscope" }], - }); - }, - ); - - store.profilerStopped(); - - if (result !== undefined && result !== null) { - lastResult = result; - applyProfileDecorations(result); - // Land the user on the self-contained results panel (opened beside so the - // heat-mapped source stays visible); the raw `.cpuprofile` stays one click - // away via the completion toast, the panel's button, or "Show Profile - // Results" ([PROFILE-NATIVE-FALLBACK], #145). - presentProfileResult(result); - } - } catch (err: unknown) { - store.profilerStopped(); - const msg = err instanceof Error ? err.message : String(err); - Logger.error(`Profile stop failed: ${msg}`); - vscode.window.showErrorMessage(`Basilisk: ${msg}`); - } -} - -async function handleProfileSnapshot(store: Store): Promise { - const client = store.client.value; - const sessionId = store.profiler.value.cpuSessionId; - if (client?.isRunning() !== true || sessionId === undefined) { - vscode.window.showWarningMessage("Basilisk: No active profiling session."); - return; - } - - try { - // Snapshots also write the export artifacts \u2014 show the wait like every - // other profiling flow ([PROFILE-UX-PROGRESS]). - const result = await withUserProgress( - "Basilisk: Taking profile snapshot", - async (report) => { - report("Collecting samples so far\u2026"); - return client.sendRequest("workspace/executeCommand", { - command: LSP_CMD.snapshot, - arguments: [{ sessionId }], - }); - }, - ); - - if (result !== undefined && result !== null) { - lastResult = result; - applyProfileDecorations(result); - Logger.info(`Profile snapshot: ${result.totalSamples} samples so far`); - // Fire-and-forget: a toast with a button is sticky and must not block the - // snapshot handler ([PROFILE-NATIVE-FALLBACK]). - void vscode.window - .showInformationMessage( - `Basilisk: Snapshot \u2014 ${result.totalSamples} samples (profiling continues)`, - "View Results", - ) - .then((choice) => { - if (choice === "View Results") { - openFlamegraphWebview(result); - } - }); - } - } catch (err: unknown) { - const msg = err instanceof Error ? err.message : String(err); - Logger.error(`Profile snapshot failed: ${msg}`); - vscode.window.showErrorMessage(`Basilisk: ${msg}`); - } -} - -/** - * Re-open the results panel for the most recent profile. The panel is a - * singleton the user can close; this palette command is the always-available - * way back in — results are never trapped behind a dismissed completion toast - * ([PROFILE-NATIVE-FALLBACK]). - */ -function handleProfileShowResults(): void { - if (lastResult === undefined) { - vscode.window.showInformationMessage( - "Basilisk: No profile results yet — run a profiling session first.", - ); - return; - } - openFlamegraphWebview(lastResult); -} - -async function handleProfileAttachToDebug(store: Store): Promise { - const session = vscode.debug.activeDebugSession; - if (session === undefined) { - vscode.window.showWarningMessage("Basilisk: No active debug session to profile."); - return; - } - - const client = store.client.value; - if (client?.isRunning() !== true) { - vscode.window.showWarningMessage("Basilisk: Language server not running."); - return; - } - - if (store.cpuBusy.value) { - vscode.window.showWarningMessage( - `Basilisk: Already profiling (session ${store.profiler.value.cpuSessionId ?? "?"}).`, - ); - return; - } - - // Resolve the debuggee's PID captured from the DAP `process` event so we - // profile the SAME process the debugger is attached to. The LSP profiler is - // PID-based; the privilege layer handles elevation (macOS helper) transparently. - const pid = store.getDebuggeeProcessId(session.id); - if (pid === undefined) { - vscode.window.showWarningMessage( - "Basilisk: The debuggee process isn't ready yet — let it start running, then run “Profile Debug Session” again.", - ); - return; - } - - const cfg = vscode.workspace.getConfiguration("basilisk"); - const sampleRate = cfg.get("profiler.sampleRate", DEFAULT_SAMPLE_RATE); - const includeNative = cfg.get("profiler.includeNative", false); - - try { - const result = await client.sendRequest<{ sessionId: string; pid: number; pythonVersion: string } | undefined>("workspace/executeCommand", { - command: LSP_CMD.start, - arguments: [{ pid, sampleRate, includeNative }], - }); - - if (result !== undefined && result !== null) { - adoptSession(store, result, `Basilisk: Profiling debug session (PID ${result.pid})`); - } - } catch (err: unknown) { - const msg = err instanceof Error ? err.message : String(err); - Logger.error(`Profile attach-to-debug failed: ${msg}`); - vscode.window.showErrorMessage(`Basilisk: ${msg}`); - } -} - -// ── Disposal ────────────────────────────────────────────────────────────── - -/** - * Dispose the profiler's UI artifacts. Session state lives in the store and is - * cleared by `store.reset()` on deactivation; the status bar and context keys - * follow reactively, so there is nothing imperative to tear down here. - */ -export function disposeProfiler(): void { - disposeProfileDecorations(); - disposeFlamegraphPanel(); - disposeProfileServer(); - lastResult = undefined; -} diff --git a/vscode-extension/src/progress-ops.ts b/vscode-extension/src/progress-ops.ts deleted file mode 100644 index 98d3fbb10..000000000 --- a/vscode-extension/src/progress-ops.ts +++ /dev/null @@ -1,77 +0,0 @@ -// Implements [PROFILE-UX-PROGRESS]. See docs/specs/LSP-PROFILING-SPEC.md#PROFILE-UX-PROGRESS -/** - * Centralized user-visible progress for long-running profiler operations. - * - * Every multi-second profiling flow (CPU start/stop, memory operations, - * panel refresh) runs through these wrappers so notification styling, - * structured logging, and testability stay uniform. VS Code's progress UI is - * not readable via the public API, so the wrappers also record a - * begin/step/end **operation log** — the e2e seam tests assert against. - */ - -import * as vscode from "vscode"; -import { Logger } from "./logger"; - -/** Cap so a long session cannot grow the log unbounded. */ -const OPERATION_LOG_CAP = 500; - -/** Chronological `begin:`/`step:`/`end:`-prefixed operation entries. */ -const operationLog: string[] = []; - -function record(entry: string): void { - operationLog.push(entry); - if (operationLog.length > OPERATION_LOG_CAP) { - operationLog.splice(0, operationLog.length - OPERATION_LOG_CAP); - } -} - -/** The operation log (e2e seam): `begin:`, `step:<title>:<msg>`, `end:<title>`. */ -export function recordedOperations(): readonly string[] { - return operationLog; -} - -/** - * Run `task` under a user-facing progress notification. The task receives a - * `report` callback for live stage messages ("Attaching…", "Collecting - * results…"). The notification closes when the task settles — including on - * throw, so a failed flow never leaves a zombie spinner. - */ -export async function withUserProgress<T>( - title: string, - task: (report: (message: string) => void) => Promise<T>, -): Promise<T> { - record(`begin:${title}`); - Logger.info(`[Progress] begin: ${title}`); - try { - return await vscode.window.withProgress( - { location: vscode.ProgressLocation.Notification, title }, - async (progress) => - task((message) => { - record(`step:${title}:${message}`); - Logger.debug(`[Progress] ${title} — ${message}`); - progress.report({ message }); - }), - ); - } finally { - record(`end:${title}`); - Logger.info(`[Progress] end: ${title}`); - } -} - -/** - * Run `task` under a tree view's built-in progress bar (the thin indeterminate - * bar atop the view) — the right surface for an in-panel refresh, where a - * notification would be heavyweight. - */ -export async function withViewProgress<T>( - viewId: string, - title: string, - task: () => Promise<T>, -): Promise<T> { - record(`begin:${title}`); - try { - return await vscode.window.withProgress({ location: { viewId } }, task); - } finally { - record(`end:${title}`); - } -} diff --git a/vscode-extension/src/reactive-refresh.ts b/vscode-extension/src/reactive-refresh.ts deleted file mode 100644 index 7a3705381..000000000 --- a/vscode-extension/src/reactive-refresh.ts +++ /dev/null @@ -1,41 +0,0 @@ -// Implements [EXTACT-REACTIVE-STATE]. See docs/specs/EXTENSION-ACTIVITY-PANEL-SPEC.md#EXTACT-REACTIVE-STATE -/** - * Shared reactive-refresh wiring for sidebar panels. - * - * Every panel is a pure projection of centralized store state: it subscribes to - * a monotonic revision signal and re-renders on each bump, never owning a timer - * or refreshing itself directly. The Modules panel keys off `analysisRevision`; - * the Python Processes panel keys off `processesRevision` (whose visibility-gated - * poll bumps the signal, since the OS has no push event for process changes, - * issue #148). Both share the one subscription primitive below. - */ - -import { effect, type ReadonlySignal } from "@preact/signals-core"; -import type * as vscode from "vscode"; - -/** A panel that can re-render on demand and tracks its own disposables. */ -export interface RefreshablePanel { - refresh(): void; - readonly disposables: vscode.Disposable[]; -} - -/** - * Subscribe `panel` to a centralized revision signal, refreshing on each real - * bump. The effect runs once on subscription; the captured baseline suppresses - * that initial no-op so only genuine bumps trigger a refresh. The effect's - * disposer is tracked on the panel so it is torn down with the panel. - */ -export function subscribeRevision( - revision: ReadonlySignal<number>, - panel: RefreshablePanel, -): void { - let lastRevision = revision.value; - const dispose = effect(() => { - const next = revision.value; - if (next !== lastRevision) { - lastRevision = next; - panel.refresh(); - } - }); - panel.disposables.push({ dispose }); -} diff --git a/vscode-extension/src/result.ts b/vscode-extension/src/result.ts deleted file mode 100644 index ed3b40a02..000000000 --- a/vscode-extension/src/result.ts +++ /dev/null @@ -1,5 +0,0 @@ -// Implements [VSIX]. See docs/specs/VSIX-SPEC.md#VSIX -/** Discriminated union modelling success/failure without throwing. */ -export type Result<T, E = Error> = - | { readonly ok: true; readonly value: T } - | { readonly ok: false; readonly error: E }; diff --git a/vscode-extension/src/shipwright-runtime.ts b/vscode-extension/src/shipwright-runtime.ts deleted file mode 100644 index 196014afa..000000000 --- a/vscode-extension/src/shipwright-runtime.ts +++ /dev/null @@ -1,164 +0,0 @@ -// Implements [VSIX]. See docs/specs/VSIX-SPEC.md#VSIX -import * as vscode from "vscode"; -import * as fs from "fs"; -import * as path from "path"; -import { Logger } from "./logger"; - -const SHIPWRIGHT_PACKAGE = "@nimblesite/shipwright-vscode"; -const BASILISK_COMPONENT_ID = "basilisk"; - -interface ShipwrightApi { - activateShipwright?: ActivateRuntime; - activateDeploymentToolkit?: ActivateRuntime; - detectPlatform?: (platform: NodeJS.Platform, arch: string) => string; - probeBinaryVersion?: (file: string) => Promise<{ name: string; version: string } | undefined>; -} - -type ActivateRuntime = ( - context: vscode.ExtensionContext, - options: { - readonly vscode: typeof vscode; - readonly manifestPath: string; - readonly showMessages?: boolean; - } -) => Promise<ActivationResult>; - -interface ActivationResult { - readonly diagnostics: readonly ActivationDiagnostic[]; - readonly ok: boolean; -} - -interface ActivationDiagnostic { - readonly blocking: boolean; - readonly componentId: string; - readonly message: string; - readonly resolution: RuntimeResolution; -} - -interface RuntimeResolution { - readonly path?: string | null; - readonly source: string | null; - readonly version?: string | null; -} - -export interface BasiliskRuntime { - readonly componentId: string; - readonly executablePath: string; - readonly source: string; - readonly version: string | undefined; -} - -// Implements [VSIX-BINARY-RESOLUTION] / [VSIX-BINARY-DISTRIBUTION] — binary -// resolution is delegated to Shipwright (the resolution cascade is the single -// source of truth in LSP-ARCHITECTURE-SPEC.md#LSPARCH-BINRES), which selects the -// per-platform bundled `basilisk` binary from the VSIX (or an override) and -// returns its executable path. -export async function resolveBasiliskRuntime(context: vscode.ExtensionContext): Promise<BasiliskRuntime> { - const api = await loadShipwrightApi(); - const activate = api.activateShipwright ?? api.activateDeploymentToolkit; - if (activate === undefined) { - throw new Error(`${SHIPWRIGHT_PACKAGE} does not export a VS Code activation function.`); - } - // showMessages: false — Shipwright AWAITS its error toast's action buttons, - // which blocks activation forever in headless hosts (e2e tests) and stalls - // real users behind a modal-ish prompt. Failures are surfaced by our own - // non-blocking reportRuntimeFailure path instead. - const result = await activate(context, { - vscode, - manifestPath: path.join(context.extensionPath, "shipwright.json"), - showMessages: false, - }); - const diagnostic = basiliskDiagnostic(result); - if (!result.ok) { - const fallback = await bundledFallback(api, context); - if (fallback !== undefined) { - return fallback; - } - throw new Error(formatActivationFailure(result)); - } - if (diagnostic === undefined) { - throw new Error("Shipwright did not return a basilisk runtime diagnostic."); - } - const executablePath = diagnostic.resolution.path; - if (executablePath === null || executablePath === undefined || executablePath === "") { - throw new Error(`Shipwright resolved ${BASILISK_COMPONENT_ID} without an executable path.`); - } - return { - componentId: diagnostic.componentId, - executablePath, - source: diagnostic.resolution.source ?? "unknown", - version: diagnostic.resolution.version ?? undefined, - }; -} - -/** - * Windows fallback for the bundled binary — works around an upstream - * Shipwright defect (Nimblesite/Shipwright): `shipwright-core`'s - * `joinBinary()` builds resolve candidates with `/` separators while - * `shipwright-vscode`'s `candidatePaths()` keys its probe map via - * `path.join()` (`\` on win32), so the probe lookup never matches and the - * bundled source resolves as `no-source-resolved` on every Windows install. - * Until that separator normalisation is fixed upstream, probe the bundled - * path ourselves and use it when it is a genuine basilisk binary. - * Remove once shipwright-vscode ships the fix. - */ -async function bundledFallback( - api: ShipwrightApi, - context: vscode.ExtensionContext -): Promise<BasiliskRuntime | undefined> { - if (api.detectPlatform === undefined || api.probeBinaryVersion === undefined) { - return undefined; - } - const platform = api.detectPlatform(process.platform, process.arch); - const exe = process.platform === "win32" ? ".exe" : ""; - const candidate = path.join(context.extensionPath, "bin", platform, `basilisk${exe}`); - if (!fs.existsSync(candidate)) { - return undefined; - } - const probe = await api.probeBinaryVersion(candidate); - if (probe?.name !== BASILISK_COMPONENT_ID) { - return undefined; - } - Logger.warn( - `Shipwright could not resolve the bundled basilisk binary; using direct bundled fallback at ${candidate} ` + - "(upstream win32 path-separator defect — see shipwright-runtime.ts bundledFallback)" - ); - return { - componentId: BASILISK_COMPONENT_ID, - executablePath: candidate, - source: "bundled-fallback", - version: probe.version, - }; -} - -async function loadShipwrightApi(): Promise<ShipwrightApi> { - // `new Function` is the only way to reach a native ESM `import()` from this - // CommonJS build, and it is typed `Function` — no runtime check can recover a - // call signature from that, so the shim's own type is the one thing here that - // must be asserted. Nothing is assumed about what it *returns*: every entry - // point read off the module is checked for `undefined` before it is called - // (see `resolveBasiliskRuntime` and `bundledFallbackRuntime`). - // eslint-disable-next-line @typescript-eslint/no-implied-eval, @typescript-eslint/no-unsafe-type-assertion -- see above. - const importModule = new Function("specifier", "return import(specifier)") as ( - specifier: string - ) => Promise<ShipwrightApi>; - return importModule(SHIPWRIGHT_PACKAGE); -} - -function basiliskDiagnostic(result: ActivationResult): ActivationDiagnostic | undefined { - return result.diagnostics.find((diagnostic) => diagnostic.componentId === BASILISK_COMPONENT_ID); -} - -function formatActivationFailure(result: ActivationResult): string { - const message = result.diagnostics - .filter((diagnostic) => diagnostic.blocking) - .map((diagnostic) => diagnostic.message) - .join("\n"); - return message === "" ? "Shipwright runtime activation failed." : message; -} - -export function reportRuntimeFailure(error: unknown): void { - const message = error instanceof Error ? error.message : String(error); - Logger.error(`Basilisk runtime resolution failed: ${message}`); - void vscode.window.showErrorMessage(message, { modal: false }); -} diff --git a/vscode-extension/src/store-ready.ts b/vscode-extension/src/store-ready.ts deleted file mode 100644 index e6e7a64f6..000000000 --- a/vscode-extension/src/store-ready.ts +++ /dev/null @@ -1,105 +0,0 @@ -// Implements [VSIX-ARCHITECTURE]. See docs/specs/VSIX-SPEC.md#VSIX-ARCHITECTURE -/** - * LSP ready-handle machinery for the store: a per-start-cycle promise that - * resolves when the client reaches Running, plus the awaited/polled path - * `ensureLspReadyPromise` rides. Pure functions over the store's own signals - * (the profiler-state.ts pattern) — the signals themselves stay owned by - * store.ts, the single global-state file. - */ - -import type { Signal } from "@preact/signals-core"; -import type { LanguageClient } from "vscode-languageclient/node"; -import type { Result } from "./result"; -import { POLL_INTERVAL_MS } from "./timeouts"; - -/** LSP lifecycle states exposed to consumers. */ -export type LspState = "idle" | "starting" | "running" | "stopped"; - -/** Lifecycle promise handle for LSP client ready signaling. */ -export interface ReadyHandle { - promise: Promise<void>; - resolve: () => void; -} - -/** The slice of the store's signals the ready machinery operates on. */ -export interface ReadySignals { - client: Signal<LanguageClient | undefined>; - lspState: Signal<LspState>; - readyHandle: Signal<ReadyHandle | undefined>; -} - -/** Resolve the ready handle and clear it. - * Resolution MUST be async (next tick) so callers' .then() handlers - * are attached before the promise settles. */ -export function resolveLspReady(signals: ReadySignals): void { - const handle = signals.readyHandle.value; - if (handle !== undefined) { - signals.readyHandle.value = undefined; - setTimeout(handle.resolve, 0); - } -} - -/** Placeholder resolver, replaced synchronously by the `Promise` executor. */ -function unresolved(): void { - // Intentionally empty — see `createReadyHandle`. -} - -/** Create a fresh ready handle for this start cycle. */ -export function createReadyHandle(signals: ReadySignals): ReadyHandle { - // Seeded with a no-op so the binding is a `() => void` without asserting one. - // The executor runs synchronously inside `new Promise`, so the real resolver - // is always in place by the time the handle is built on the next line. - let resolve: () => void = unresolved; - const promise = new Promise<void>((settle) => { resolve = settle; }); - const handle: ReadyHandle = { promise, resolve }; - signals.readyHandle.value = handle; - return handle; -} - -/** Wait for the LSP ready handle with a timeout, returning Result. */ -export async function awaitLspReady( - signals: ReadySignals, - timeoutMs: number, -): Promise<Result<LanguageClient>> { - // Fast path: client already running. - const client = signals.client.value; - if (client?.isRunning() === true) { - return { ok: true, value: client }; - } - // Also check our own state signal (catches post-restart where isRunning() - // lags behind the onDidChangeState callback that set lspState = "running"). - if (signals.lspState.value === "running" && client !== undefined) { - return { ok: true, value: client }; - } - - const existing = signals.readyHandle.value; - const ready = existing !== undefined ? existing.promise : createReadyHandle(signals).promise; - - // Poll for the client becoming ready via both isRunning() and our own - // lspState signal. The double check catches cases where the readyHandle - // was resolved before this function was called (e.g. after a deactivate/ - // activate cycle where the state listener already fired). - const poll = new Promise<"poll">((resolve) => { - const interval = setInterval(() => { - const c = signals.client.value; - if (c?.isRunning() === true || (signals.lspState.value === "running" && c !== undefined)) { - clearInterval(interval); - resolve("poll"); - } - }, POLL_INTERVAL_MS); - setTimeout(() => { clearInterval(interval); }, timeoutMs); - }); - - const timeout = new Promise<"timeout">((resolve) => { - setTimeout(() => { resolve("timeout"); }, timeoutMs); - }); - const outcome = await Promise.race([ready.then(() => "ready" as const), poll, timeout]); - if (outcome === "timeout") { - return { ok: false, error: new Error(`LSP client did not reach Running state within ${timeoutMs}ms`) }; - } - const resolved = signals.client.value; - if (resolved === undefined) { - return { ok: false, error: new Error("LSP client resolved but is undefined") }; - } - return { ok: true, value: resolved }; -} diff --git a/vscode-extension/src/store-types.ts b/vscode-extension/src/store-types.ts deleted file mode 100644 index 7cac486f7..000000000 --- a/vscode-extension/src/store-types.ts +++ /dev/null @@ -1,111 +0,0 @@ -// 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"; -import type { LanguageClient } from "vscode-languageclient/node"; -import type * as vscode from "vscode"; -import type { - ConfigurationEditorActions, - ConfigurationEditorState, -} from "./configuration-editor-state"; -import type { TypeshedStatusState } from "./configuration-editor-model"; -import type { LogSink } from "./logger"; -import type { ProcessPanelActions, ProcessPanelState } from "./processes-state"; -import type { ProfilerActions, ProfilerSession } from "./profiler-state"; -import type { Result } from "./result"; -import type { LspState, ReadyHandle } from "./store-ready"; - -/** - * The slice of the extension context a collaborator needs to own disposables. - * - * Depending on the whole `ExtensionContext` makes every caller — tests - * included — responsible for producing all seventeen of its members, none of - * which this code path reads. Naming the one member that is actually used - * keeps the dependency honest and lets a caller hand over exactly that. - */ -export interface DisposableSink { - readonly subscriptions: { dispose(): unknown }[]; -} - -/** - * The slice of the extension context that persists per-workspace UI state. - * - * `get` returns `unknown`, not a caller-chosen `T`. The value comes back from - * storage a previous version of the extension wrote, so a `get<ViewMode>(…)` - * would be an unchecked assertion dressed as a generic — the compiler would - * vouch for a shape nothing verified. Callers narrow what they read. - */ -export interface WorkspaceStateStore { - readonly workspaceState: { - get(key: string): unknown; - update(key: string, value: unknown): Thenable<void>; - }; -} - -/** Runtime binary selected by Shipwright during activation. */ -export interface RuntimeResolution { - readonly componentId: string; - readonly path: string; - readonly source: string; - readonly version: string | undefined; -} - -export interface Store extends ProfilerActions, ProcessPanelActions, ConfigurationEditorActions { - readonly client: ReadonlySignal<LanguageClient | undefined>; - readonly serverCommands: ReadonlySignal<ReadonlySet<string>>; - readonly clientCommands: ReadonlySignal<ReadonlySet<string>>; - readonly statusBarItem: ReadonlySignal<vscode.StatusBarItem | undefined>; - readonly outputChannel: ReadonlySignal<vscode.LogOutputChannel | undefined>; - readonly logSink: ReadonlySignal<LogSink | undefined>; - readonly lspState: ReadonlySignal<LspState>; - readonly isServerReady: ReadonlySignal<boolean>; - readonly analysisRevision: ReadonlySignal<number>; - readonly runtimeResolution: ReadonlySignal<RuntimeResolution | undefined>; - readonly sessionIdToPid: ReadonlySignal<ReadonlyMap<string, number>>; - readonly profiler: ReadonlySignal<ProfilerSession>; - readonly profilerBusy: ReadonlySignal<boolean>; - readonly cpuBusy: ReadonlySignal<boolean>; - readonly memoryBusy: ReadonlySignal<boolean>; - readonly processes: ReadonlySignal<ProcessPanelState>; - readonly processesRevision: ReadonlySignal<number>; - readonly configurationEditor: ReadonlySignal<ConfigurationEditorState>; - readonly typeshedStatuses: ReadonlySignal<ReadonlyMap<string, TypeshedStatusState>>; - readonly lspReadyPromise: ReadonlySignal<Promise<void> | undefined>; - - setClient(context: DisposableSink, client: LanguageClient): void; - setStatusBarItem(item: vscode.StatusBarItem): void; - setOutputChannel(channel: vscode.LogOutputChannel): void; - setLogSink(sink: LogSink): void; - setRuntimeResolution(resolution: RuntimeResolution): void; - setDebuggeeProcessId(sessionId: string, pid: number): void; - getDebuggeeProcessId(sessionId: string): number | undefined; - clearDebuggeeProcessId(sessionId: string): void; - bumpAnalysisRevision(): void; - isClientCommandRegistered(id: string): boolean; - isServerCommandAdvertised(id: string): boolean; - ensureLspReadyPromise(timeoutMs?: number): Promise<Result<LanguageClient>>; - reset(): void; -} - -/** Internal mutable signals backing one store instance. */ -export interface StoreSignals { - client: Signal<LanguageClient | undefined>; - serverCommands: Signal<ReadonlySet<string>>; - clientCommands: Signal<ReadonlySet<string>>; - statusBarItem: Signal<vscode.StatusBarItem | undefined>; - outputChannel: Signal<vscode.LogOutputChannel | undefined>; - logSink: Signal<LogSink | undefined>; - lspState: Signal<LspState>; - runtimeResolution: Signal<RuntimeResolution | undefined>; - sessionIdToPid: Signal<Map<string, number>>; - profiler: Signal<ProfilerSession>; - processes: Signal<ProcessPanelState>; - configurationEditor: Signal<ConfigurationEditorState>; - typeshedStatuses: Signal<ReadonlyMap<string, TypeshedStatusState>>; - readyHandle: Signal<ReadyHandle | undefined>; - analysisRevision: Signal<number>; - diagnosticsDebounce: ReturnType<typeof setTimeout> | undefined; - diagnosticsListenerBound: boolean; - commandDisposables: vscode.Disposable[]; - serverCommandDisposables: vscode.Disposable[]; -} diff --git a/vscode-extension/src/store.ts b/vscode-extension/src/store.ts deleted file mode 100644 index 1d0da2f48..000000000 --- a/vscode-extension/src/store.ts +++ /dev/null @@ -1,470 +0,0 @@ -// Implements [VSIX-ARCHITECTURE]. See docs/specs/VSIX-SPEC.md#VSIX-ARCHITECTURE -/** - * Centralized, immutable-by-default application state for the Basilisk - * VS Code extension. - * - * All mutable state lives here. Consumers receive ReadonlySignals and - * can only mutate through explicit actions on the Store. Call reset() - * to blow away all state (deactivation, test teardown, server restart). - * - * The LSP state listener lives inside the store — server commands can - * only be populated from the onDidChangeState callback. No external - * code can add or replace server commands. - * - * Factory function — not a singleton. Tests create their own store, - * production creates one in activate(). - */ - -import { arrayField, recordField } from "./unknown-shape"; -import { signal, computed } from "@preact/signals-core"; -import { type LanguageClient, State } from "vscode-languageclient/node"; -import * as vscode from "vscode"; -import { Logger, type LogSink } from "./logger"; -import { createServerCommandHandler } from "./lsp-client"; -import { stopClientSettled } from "./lsp-client-stop"; -import type { Result } from "./result"; -import { WAIT_MS } from "./timeouts"; -import { - createProfilerActions, - isProfilerBusy, - isCpuBusy, - isMemoryBusy, - IDLE_PROFILER_SESSION, - type ProfilerSession, -} from "./profiler-state"; -import { - createProcessPanelActions, - IDLE_PROCESS_PANEL, - type ProcessPanelState, -} from "./processes-state"; -import { - createConfigurationEditorActions, - decodeConfigurationChanged, - decodeTypeshedStatusChanged, - IDLE_CONFIGURATION_EDITOR, - requestConfigurationRefresh, - requestTypeshedStatusRefresh, - type ConfigurationEditorState, -} from "./configuration-editor-state"; -import type { TypeshedStatusState } from "./configuration-editor-model"; -import { - awaitLspReady, - createReadyHandle, - resolveLspReady, - type LspState, - type ReadyHandle, -} from "./store-ready"; -import type { DisposableSink, RuntimeResolution, Store, StoreSignals } from "./store-types"; - -// Re-exported so consumers keep importing the LSP lifecycle type from the store. -export { type LspState } from "./store-ready"; -export type { DisposableSink, RuntimeResolution, Store } from "./store-types"; - -// ── Private helpers operating on StoreSignals ───────────────────────────── - -/** Dispose all server-advertised command registrations. */ -function disposeServerCommands(signals: StoreSignals): void { - for (const d of signals.serverCommandDisposables) { - d.dispose(); - } - signals.serverCommandDisposables = []; -} - -/** - * Extract commands from the client's initializeResult and register them - * with VS Code so they appear in getCommands() and the command palette. - * - * Each command handler executes the command through the LSP client via - * workspace/executeCommand. This replaces the vscode-languageclient - * ExecuteCommandFeature which was removed to prevent double-registration. - */ -function syncServerCommands(signals: StoreSignals): void { - disposeServerCommands(signals); - - const client = signals.client.value; - const commands = client?.initializeResult?.capabilities?.executeCommandProvider?.commands; - if (!Array.isArray(commands) || client === undefined) { - signals.serverCommands.value = new Set(); - return; - } - - const next = new Set<string>(); - for (const cmd of commands) { - if (typeof cmd === "string") { - next.add(cmd); - const handler = createServerCommandHandler(client, cmd); - const disposable = vscode.commands.registerCommand(cmd, handler); - signals.serverCommandDisposables.push(disposable); - } - } - signals.serverCommands.value = next; -} - -interface CommandRegistration { - signals: StoreSignals; - context: DisposableSink; - commandId: string; -} - -/** - * Register a client command with VS Code and track it. - * - * The disposable is stored ONLY in commandDisposables — NOT in - * context.subscriptions. Rationale: disposeClientCommands() calls - * dispose() on every entry when the LSP restarts or stops. If the - * same disposable also lived in context.subscriptions, deactivate() - * would dispose it a second time (double-dispose). Per the VS Code - * API, registerCommand returns a Disposable whose dispose() method - * unregisters the command — calling it twice is undefined behaviour. - */ -function registerCommand( - reg: CommandRegistration, - handler: (...args: unknown[]) => unknown -): void { - const disposable = vscode.commands.registerCommand(reg.commandId, handler); - reg.signals.commandDisposables.push(disposable); - const next = new Set(reg.signals.clientCommands.value); - next.add(reg.commandId); - reg.signals.clientCommands.value = next; -} - -/** Dispose all registered commands (client AND server) so they can be re-registered fresh. */ -function disposeAllCommands(signals: StoreSignals): void { - for (const d of signals.commandDisposables) { - d.dispose(); - } - signals.commandDisposables = []; - signals.clientCommands.value = new Set(); - disposeServerCommands(signals); - signals.serverCommands.value = new Set(); -} - -// Implements [VSIX-COMMANDS] — registers the two client-only commands declared in -// vscode-extension/package.json (contributes.commands): basilisk.restartServer -// ([VSIX-ERROR-RECOVERY] manual recovery) and basilisk.showOutput -// ([VSIX-OUTPUT-CHANNELS]). All other contributed commands are server-advertised -// and auto-registered via syncServerCommands (per the Command Registration Rule). -/** Register all client-only commands. Called when LSP reaches Running. */ -function registerClientCommands(signals: StoreSignals, context: DisposableSink): void { - registerCommand({ signals, context, commandId: "basilisk.restartServer" }, async () => { - const lspClient = signals.client.value; - if (!lspClient) { - vscode.window.showWarningMessage("Basilisk: No language server to restart."); - return; - } - try { - Logger.info("Restarting Basilisk language server..."); - // Not `stop()`: it rejects for any state but Running, so a restart - // requested while the server is still coming up used to report a failure - // and leave the server un-restarted — nothing downstream of the restart - // ever fired. stopClientSettled waits the start out first, and leaves the - // client Stopped (not disposed) so it can start again below. - await stopClientSettled(lspClient); - await lspClient.start(); - Logger.info("Basilisk language server restarted."); - } catch (err: unknown) { - const msg = err instanceof Error ? err.message : String(err); - Logger.error(`Restart failed: ${msg}`); - vscode.window.showErrorMessage(`Basilisk: Failed to restart server: ${msg}`); - } - }); - - registerCommand({ signals, context, commandId: "basilisk.showOutput" }, () => { - signals.outputChannel.value?.show(); - }); -} - -/** Bump the analysis revision ([EXTACT-REACTIVE-STATE], issue #58). */ -function bumpAnalysisRevision(signals: StoreSignals): void { - signals.analysisRevision.value += 1; -} - -function replaceInitialTypeshedStatuses( - signals: StoreSignals, - client: LanguageClient, -): void { - const basilisk = recordField(client.initializeResult?.capabilities.experimental, "basilisk"); - const next = new Map<string, TypeshedStatusState>(); - for (const value of arrayField(basilisk, "typeshedStatuses")) { - const change = decodeTypeshedStatusChanged(value); - if (change !== undefined) { next.set(change.rootUri, change.status); } - } - signals.typeshedStatuses.value = next; -} - -function upsertTypeshedStatus(signals: StoreSignals, value: unknown): void { - const change = decodeTypeshedStatusChanged(value); - if (change === undefined) { return; } - const next = new Map(signals.typeshedStatuses.value); - next.set(change.rootUri, change.status); - signals.typeshedStatuses.value = next; - requestTypeshedStatusRefresh(signals.configurationEditor, change); -} - -/** Debounce window for diagnostics-driven refreshes (diagnostics fire per file). */ -const DIAGNOSTICS_BUMP_DEBOUNCE_MS = 300; - -/** - * Register the global diagnostics listener exactly once per store: any - * diagnostics change bumps the analysis revision (debounced) so panels that - * render health rollups stay live ([EXTACT-HEALTH-REFRESH]). - */ -function bindDiagnosticsListener(signals: StoreSignals, context: DisposableSink): void { - if (signals.diagnosticsListenerBound) { return; } - signals.diagnosticsListenerBound = true; - context.subscriptions.push( - vscode.languages.onDidChangeDiagnostics(() => { - if (signals.diagnosticsDebounce !== undefined) { - clearTimeout(signals.diagnosticsDebounce); - } - signals.diagnosticsDebounce = setTimeout(() => { - signals.diagnosticsDebounce = undefined; - bumpAnalysisRevision(signals); - }, DIAGNOSTICS_BUMP_DEBOUNCE_MS); - }), - ); -} - -/** - * Wire up the onDidChangeState listener on the given client. - * This is the ONLY place commands get registered or populated. - */ -function bindClientStateListener( - signals: StoreSignals, - context: DisposableSink, - lspClient: LanguageClient -): void { - lspClient.onDidChangeState((event) => { - const newState = event.newState; - - if (newState === State.Running) { - signals.lspState.value = "running"; - disposeAllCommands(signals); - syncServerCommands(signals); - replaceInitialTypeshedStatuses(signals, lspClient); - registerClientCommands(signals, context); - // Implements the client side of [EXTACT-LSP-COMMANDS-MODULE-CHANGED] — - // consumes the server's `basilisk/moduleChanged` notification (re-analysis - // complete) to bump the analysis revision. Registered after - // disposeAllCommands each Running cycle so restarts re-bind it - // ([EXTACT-REACTIVE-STATE]). - signals.commandDisposables.push( - lspClient.onNotification("basilisk/moduleChanged", () => { - bumpAnalysisRevision(signals); - }), - // Implements the client side of [EXTACT-LSP-COMMANDS-SCAN-COMPLETE] - // (#144): the workspace scan finishing must repaint panels even when - // it published nothing (a genuinely empty workspace produces no - // diagnostics events), so the loading state can settle into the - // honest empty-state. - lspClient.onNotification("basilisk/scanComplete", () => { - bumpAnalysisRevision(signals); - }), - // An applied configuration change rewrites the severity of every - // affected diagnostic, so panels rendering health rollups are stale the - // moment the server confirms it. The debounced diagnostics listener - // cannot be relied on here: a severity-only recheck can republish - // nothing the client observes, leaving the Modules panel showing the - // PREVIOUS configuration ([EXTACT-REACTIVE-STATE] / [LSPARCH-CONFIG]). - lspClient.onNotification("basilisk/configurationChanged", (value: unknown) => { - const change = decodeConfigurationChanged(value); - if (change !== undefined) { - requestConfigurationRefresh(signals.configurationEditor, change); - bumpAnalysisRevision(signals); - } - }), - lspClient.onNotification("basilisk/typeshedStatusChanged", (value: unknown) => { - upsertTypeshedStatus(signals, value); - }), - ); - resolveLspReady(signals); - // Initial analysis becomes available once the server runs. - bumpAnalysisRevision(signals); - return; - } - - if (newState === State.Stopped) { - disposeAllCommands(signals); - signals.lspState.value = "stopped"; - signals.typeshedStatuses.value = new Map(); - return; - } - - if (newState === State.Starting) { - signals.lspState.value = "starting"; - if (signals.readyHandle.value === undefined) { - createReadyHandle(signals); - } - } - }); -} - -/** Reset all signals to their initial values. */ -function resetSignals(signals: StoreSignals): void { - // Per-session client state — cleared on every reset. - signals.client.value = undefined; - signals.serverCommands.value = new Set(); - signals.clientCommands.value = new Set(); - signals.statusBarItem.value = undefined; - signals.lspState.value = "idle"; - signals.runtimeResolution.value = undefined; - signals.sessionIdToPid.value = new Map(); - signals.profiler.value = IDLE_PROFILER_SESSION; - signals.processes.value = IDLE_PROCESS_PANEL; - signals.configurationEditor.value = IDLE_CONFIGURATION_EDITOR; - signals.typeshedStatuses.value = new Map(); - signals.readyHandle.value = undefined; - // outputChannel and logSink are stable logging infrastructure created once by - // initLogging (and owned by context.subscriptions) — they are deliberately - // NOT cleared here. The onReset → startRuntime restart path reuses the store - // without re-running initLogging, so a restarted LanguageClient must still - // receive the existing channel. Clearing it would hand the client - // `outputChannel: undefined`, making vscode-languageclient 10 create and OWN - // an internal LogOutputChannel and dispose it on the next stop() — after - // which the server's stderr readline (which the client never tears down) - // drains a final line into the disposed channel and throws - // "Channel has been closed". -} - -/** Plain-value setters for logging/runtime infrastructure — extracted to keep createStore small. */ -function infrastructureSetters(signals: StoreSignals): Pick< - Store, - "setStatusBarItem" | "setOutputChannel" | "setLogSink" | "setRuntimeResolution" -> { - return { - setStatusBarItem(item: vscode.StatusBarItem): void { - signals.statusBarItem.value = item; - }, - setOutputChannel(ch: vscode.LogOutputChannel): void { - signals.outputChannel.value = ch; - }, - setLogSink(sink: LogSink): void { - signals.logSink.value = sink; - }, - setRuntimeResolution(resolution: RuntimeResolution): void { - signals.runtimeResolution.value = resolution; - }, - }; -} - -/** Debuggee PID actions (copy-on-write Map) — extracted to keep createStore small. */ -function debuggeePidActions(signals: StoreSignals): Pick< - Store, - "setDebuggeeProcessId" | "getDebuggeeProcessId" | "clearDebuggeeProcessId" -> { - return { - setDebuggeeProcessId(sessionId: string, pid: number): void { - const next = new Map(signals.sessionIdToPid.value); - next.set(sessionId, pid); - signals.sessionIdToPid.value = next; - }, - getDebuggeeProcessId(sessionId: string): number | undefined { - return signals.sessionIdToPid.value.get(sessionId); - }, - clearDebuggeeProcessId(sessionId: string): void { - if (!signals.sessionIdToPid.value.has(sessionId)) { return; } - const next = new Map(signals.sessionIdToPid.value); - next.delete(sessionId); - signals.sessionIdToPid.value = next; - }, - }; -} - -// ── Factory ─────────────────────────────────────────────────────────────── - -/** Build the fresh, mutable signal bag backing a store. */ -function createStoreSignals(): StoreSignals { - return { - client: signal<LanguageClient | undefined>(undefined), - serverCommands: signal<ReadonlySet<string>>(new Set()), - clientCommands: signal<ReadonlySet<string>>(new Set()), - statusBarItem: signal<vscode.StatusBarItem | undefined>(undefined), - outputChannel: signal<vscode.LogOutputChannel | undefined>(undefined), - logSink: signal<LogSink | undefined>(undefined), - lspState: signal<LspState>("idle"), - runtimeResolution: signal<RuntimeResolution | undefined>(undefined), - sessionIdToPid: signal<Map<string, number>>(new Map()), - profiler: signal<ProfilerSession>(IDLE_PROFILER_SESSION), - processes: signal<ProcessPanelState>(IDLE_PROCESS_PANEL), - configurationEditor: signal<ConfigurationEditorState>(IDLE_CONFIGURATION_EDITOR), - typeshedStatuses: signal(new Map()), - readyHandle: signal<ReadyHandle | undefined>(undefined), - analysisRevision: signal<number>(0), - diagnosticsDebounce: undefined, - diagnosticsListenerBound: false, - commandDisposables: [], - serverCommandDisposables: [], - }; -} - -export function createStore(onReset?: () => void): Store { - const signals: StoreSignals = createStoreSignals(); - - const isServerReady = computed(() => signals.client.value?.isRunning() === true); - const lspReadyPromise = computed(async () => signals.readyHandle.value?.promise); - const profilerBusy = computed(() => isProfilerBusy(signals.profiler.value)); - return { - client: signals.client, - serverCommands: signals.serverCommands, - clientCommands: signals.clientCommands, - statusBarItem: signals.statusBarItem, - outputChannel: signals.outputChannel, - logSink: signals.logSink, - lspState: signals.lspState, - runtimeResolution: signals.runtimeResolution, - sessionIdToPid: signals.sessionIdToPid, - profiler: signals.profiler, - profilerBusy, - cpuBusy: computed(() => isCpuBusy(signals.profiler.value)), - memoryBusy: computed(() => isMemoryBusy(signals.profiler.value)), - processes: signals.processes, - processesRevision: computed(() => signals.processes.value.revision), - configurationEditor: signals.configurationEditor, - typeshedStatuses: signals.typeshedStatuses, - lspReadyPromise, - isServerReady, - analysisRevision: signals.analysisRevision, - ...createProfilerActions(signals.profiler), - ...createProcessPanelActions(signals.processes), - ...createConfigurationEditorActions(signals.configurationEditor), - - setClient(context: DisposableSink, c: LanguageClient): void { - signals.client.value = c; - bindClientStateListener(signals, context, c); - bindDiagnosticsListener(signals, context); - }, - bumpAnalysisRevision: (): void => { bumpAnalysisRevision(signals); }, - ...infrastructureSetters(signals), - ...debuggeePidActions(signals), - isClientCommandRegistered(id: string): boolean { - return signals.clientCommands.value.has(id); - }, - isServerCommandAdvertised(id: string): boolean { - return signals.serverCommands.value.has(id); - }, - async ensureLspReadyPromise(timeoutMs = WAIT_MS): Promise<Result<LanguageClient>> { - return awaitLspReady(signals, timeoutMs); - }, - reset(): void { - // Stop the client being replaced BEFORE resetSignals drops the - // reference. A reset that only forgets the client leaves it fully - // alive — a zombie that keeps forwarding didOpen/didClose to its own - // server process and publishing into its own diagnostics collection, - // which VS Code merges into getDiagnostics(). Its late republishes - // then resurrect diagnostics the real server already cleared - // (GitHub #264). - // - // stopClientSettled covers the STARTING client too — an `isRunning()` - // guard reads false there and drops a client whose server process is - // already up, which is the same zombie by a quieter route. It also - // joins the shutdown deactivate() has in flight rather than racing it. - const dyingClient = signals.client.value; - if (dyingClient !== undefined) { - void stopClientSettled(dyingClient, "dispose"); - } - disposeAllCommands(signals); - resetSignals(signals); - onReset?.(); - }, - }; -} diff --git a/vscode-extension/src/subprocess-mode.ts b/vscode-extension/src/subprocess-mode.ts deleted file mode 100644 index 3e87e1021..000000000 --- a/vscode-extension/src/subprocess-mode.ts +++ /dev/null @@ -1,199 +0,0 @@ -// Implements [VSIX]. See docs/specs/VSIX-SPEC.md#VSIX -/** - * Subprocess mode: run `basilisk check --output json` on open/save and publish - * the parsed diagnostics. The fallback when the LSP is disabled - * (`basilisk.useLsp: false`). Extracted from `extension.ts` to keep activation - * focused on the LSP/debug/profiler wiring. - */ - -import * as vscode from "vscode"; -import { execFile } from "child_process"; -import * as path from "path"; -import { numberField, stringField } from "./unknown-shape"; - -/** Exit code returned by `basilisk check` on internal errors. */ -const BASILISK_INTERNAL_ERROR_EXIT_CODE = 3; - -/** - * Shape of a single diagnostic emitted by `basilisk check --output json`. - * - * Consumes [CHKARCH-CLI-OUTPUT-FAILURES]. See - * docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-CLI-OUTPUT-FAILURES - */ -interface BasiliskDiagnostic { - /** - * The rule code, absent for a file the CLI could not analyse at all. - * - * A parse failure is reported with a `null` code because no rule produced - * it. Requiring a code here dropped those entries on the floor, so a file - * with a syntax error showed a clean editor — the same blind spot the CLI's - * JSON output had. - */ - code?: string; - severity: "error" | "warning"; - message: string; - path: string; - line: number; - col: number; - end_line: number; - end_col: number; -} - -/** First workspace folder path, if any. */ -function workspaceRoot(): string | undefined { - return vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; -} - -/** Start subprocess mode: check Python documents on open/save via the CLI. */ -export function startSubprocessMode( - context: vscode.ExtensionContext, - executablePath: string -): void { - const collection = vscode.languages.createDiagnosticCollection("basilisk"); - context.subscriptions.push(collection); - - context.subscriptions.push( - vscode.workspace.onDidOpenTextDocument((doc) => { - if (doc.languageId === "python") {checkDocument(doc, collection, executablePath);} - }) - ); - context.subscriptions.push( - vscode.workspace.onDidSaveTextDocument((doc) => { - if (doc.languageId === "python") {checkDocument(doc, collection, executablePath);} - }) - ); - context.subscriptions.push( - vscode.workspace.onDidCloseTextDocument((doc) => { collection.delete(doc.uri); }) - ); - - for (const doc of vscode.workspace.textDocuments) { - if (doc.languageId === "python") {checkDocument(doc, collection, executablePath);} - } -} - -function checkDocument( - doc: vscode.TextDocument, - collection: vscode.DiagnosticCollection, - executablePath: string -): void { - const enabled = vscode.workspace.getConfiguration("basilisk").get<boolean>("enabled") ?? true; - if (!enabled) { - collection.delete(doc.uri); - return; - } - if (doc.isUntitled || doc.uri.scheme !== "file") {return;} - - const filePath = doc.uri.fsPath; - execFile( - executablePath, - ["check", "--output", "json", filePath], - { cwd: workspaceRoot() }, - (error, stdout, stderr) => { - if (error?.code === BASILISK_INTERNAL_ERROR_EXIT_CODE) { - // Exit 3 means at least one file could not be analysed at all, and the - // report on stdout now names them. Returning here published nothing, - // so a file with a syntax error kept whatever squiggles it had before - // the edit and explained itself only through a toast. - const reported = parseDiagnostics(stdout, doc); - collection.set(doc.uri, reported); - if (reported.length === 0) { - vscode.window.showWarningMessage( - `Basilisk: internal error checking ${path.basename(filePath)}: ${stderr}` - ); - } - return; - } - if (error && typeof error.code === "number" && error.code !== 1) { - vscode.window.showWarningMessage( - `Basilisk: failed to run '${executablePath}'. Is it on PATH? (${error.message})` - ); - collection.delete(doc.uri); - return; - } - collection.set(doc.uri, parseDiagnostics(stdout, doc)); - } - ); -} - -/** - * Map the CLI's JSON report onto the editor diagnostics for one document. - * - * Exported as the parse boundary between a separate process's output and the - * editor: it is where an unrecognised payload has to degrade to "nothing to - * show" rather than throw inside the `execFile` callback, so it is tested - * directly against the shapes the CLI actually emits. - */ -export function parseDiagnostics(json: string, doc: vscode.TextDocument): vscode.Diagnostic[] { - let parsed: unknown; - try { - parsed = JSON.parse(json); - } catch { - return []; - } - if (!Array.isArray(parsed)) {return [];} - - return parsed - .map(narrowDiagnostic) - .filter((item): item is BasiliskDiagnostic => item !== undefined) - .filter((item) => item.path === doc.uri.fsPath) - .map(toVscodeDiagnostic); -} - -/** - * Narrow one element of the CLI's JSON array to a diagnostic. - * - * Returns `undefined` — dropping the entry — when any required field is absent - * or the wrong type. The CLI is a separate process on a version the extension - * does not control, so a shape change must degrade to "no diagnostic here", - * never to a `TypeError` inside the `execFile` callback. - */ -function narrowDiagnostic(value: unknown): BasiliskDiagnostic | undefined { - const code = stringField(value, "code"); - const message = stringField(value, "message"); - const filePath = stringField(value, "path"); - const line = numberField(value, "line"); - const col = numberField(value, "col"); - const endLine = numberField(value, "end_line"); - const endCol = numberField(value, "end_col"); - if ( - message === undefined || filePath === undefined || - line === undefined || col === undefined || - endLine === undefined || endCol === undefined - ) { - return undefined; - } - return { - code, - // Anything that is not explicitly "error" is reported as a warning, matching - // the previous behaviour of the two-way severity mapping below. - severity: stringField(value, "severity") === "error" ? "error" : "warning", - message, - path: filePath, - line, - col, - end_line: endLine, - end_col: endCol, - }; -} - -/** Render a narrowed CLI diagnostic as an editor diagnostic. */ -function toVscodeDiagnostic(item: BasiliskDiagnostic): vscode.Diagnostic { - const range = new vscode.Range( - new vscode.Position(item.line - 1, item.col - 1), - new vscode.Position(item.end_line - 1, item.end_col - 1) - ); - const severity = item.severity === "error" - ? vscode.DiagnosticSeverity.Error - : vscode.DiagnosticSeverity.Warning; - const { code } = item; - const label = code === undefined ? item.message : `${item.message} [${code}]`; - const diag = new vscode.Diagnostic(range, label, severity); - diag.source = "basilisk"; - if (code !== undefined) { - diag.code = { - value: code, - target: vscode.Uri.parse(`https://www.basilisk-python.dev/errors/${code}`), - }; - } - return diag; -} diff --git a/vscode-extension/src/test-explorer.ts b/vscode-extension/src/test-explorer.ts deleted file mode 100644 index 96699039f..000000000 --- a/vscode-extension/src/test-explorer.ts +++ /dev/null @@ -1,521 +0,0 @@ -// Implements [VSIX-TEST-EXPLORER-INTEGRATION]. See docs/specs/VSIX-SPEC.md#VSIX-TEST-EXPLORER-INTEGRATION -/** - * Test Explorer integration for Basilisk. - * - * Creates a TestController that listens for `basilisk/testDiscoveryResult` - * notifications from the LSP server and populates VS Code's native Test - * Explorer. Test execution flows through `workspace/executeCommand` to the - * server's `basilisk.runTests` / `basilisk.debugTest` / `basilisk.runTestsCoverage` handlers. - * - * Architecture follows LSP-TEST-INTEGRATION-SPEC.md: - * - Discovery: LSP server parses AST, sends notification - * - Execution: LSP server spawns pytest subprocess - * - This module only handles the VS Code UI wiring - */ - -import * as vscode from "vscode"; -import { type LanguageClient } from "vscode-languageclient/node"; -import { applyCoverageDecorations, type LspCoverageResult } from "./coverage-decorations"; -import { Logger } from "./logger"; -import type { Store } from "./store"; -import { POLL_INTERVAL_MS } from "./timeouts"; -import { booleanField, numberField, recordArrayField, stringField } from "./unknown-shape"; - -/** Test item kind — mirrors the Rust `TestItemKind` enum. */ -type TestItemKind = "file" | "function" | "class" | "method"; - -/** Shape of a test item received from the LSP server. */ -interface LspTestItem { - name: string; - id: string; - file: string; - line: number; - kind: TestItemKind; - children: LspTestItem[]; -} - -/** Per-test result status. */ -type TestStatus = "passed" | "failed" | "skipped" | "error"; - -/** Per-test result from pytest output parsing. */ -interface LspPerTestResult { - testId: string; - status: TestStatus; - message: string; -} - -/** Shape of test run results from the LSP server. */ -interface LspTestRunResult { - stdout: string; - stderr: string; - exitCode: number; - passed: boolean; - perTest: LspPerTestResult[]; -} - -/** - * Register the Basilisk test explorer. - * - * Creates a `TestController`, wires up notification listeners, and registers - * run/debug/coverage profiles. Call this from `activate()` when LSP mode is active. - * - * Implements [LSPTEST-EDITOR-SPECIFIC-INTEGRATION-VSCODE] — TestController via the `vscode.tests` - * API, with results streamed back to the Test Explorer and debug routed through the DAP proxy. - */ -export function registerTestExplorer( - context: vscode.ExtensionContext, - store: Store -): vscode.TestController { - const controller = vscode.tests.createTestController( - "basilisk-tests", - "Basilisk Tests" - ); - - // Run profile: execute tests via pytest. - controller.createRunProfile( - "Run", - vscode.TestRunProfileKind.Run, - async (request, token) => runTests({ controller, store, request, token, debug: false }), - true - ); - - // Debug profile: start debug session targeting a test. - controller.createRunProfile( - "Debug", - vscode.TestRunProfileKind.Debug, - async (request, token) => runTests({ controller, store, request, token, debug: true }), - false - ); - - // Coverage profile: run tests with pytest-cov and show gutter decorations. - controller.createRunProfile( - "Coverage", - vscode.TestRunProfileKind.Coverage, - async (request, token) => runTests({ controller, store, request, token, debug: false, coverage: true }), - false - ); - - // Resolve handler: when the user expands a test item, discover its children. - controller.resolveHandler = async (item) => { - if (item === undefined) { - // Root resolve — request full workspace discovery. - await requestDiscovery(store); - return; - } - // Individual items are already populated from notifications. - }; - - // Listen for discovery notifications from the LSP server. - wireNotificationListener(controller, store); - - Logger.info("Test explorer registered"); - return controller; -} - -// Implements [LSPTEST-LSP-PROTOCOL-CUSTOM-NOTIFICATIONS] (client side) — subscribes to the -// `basilisk/testDiscoveryResult` and `basilisk/coverageResult` server→client notifications. -/** Wire up the `basilisk/testDiscoveryResult` notification listener. */ -function wireNotificationListener( - controller: vscode.TestController, - store: Store -): void { - // Re-wire whenever the LSP client changes (restart, reconnect). - let currentClient: LanguageClient | undefined; - - function checkClient(): void { - const client = store.client.value; - if (client === currentClient) { return; } - currentClient = client; - - if (client === undefined) { return; } - - client.onNotification( - "basilisk/testDiscoveryResult", - (params: { items: LspTestItem[] }) => { - Logger.info(`Test discovery: received ${params.items.length} item(s)`); - populateTestItems(controller, params.items); - } - ); - - client.onNotification( - "basilisk/coverageResult", - (params: LspCoverageResult) => { - Logger.info(`Coverage: received ${params.files.length} file(s), ${params.totalPct.toFixed(1)}%`); - applyCoverageDecorations(params); - } - ); - - // Request discovery now that the notification handler is wired up. - // The initial notification from `initialized` may have been sent - // before this handler was registered, so we request a fresh one. - if (client.isRunning()) { - requestDiscovery(store).catch((err: unknown) => { - Logger.error(`Initial test discovery request failed: ${err}`); - }); - } - } - - // Check immediately and on state changes. - checkClient(); - // Poll on a short interval since store.client is a signal but we can't - // subscribe to it directly from here. The effect runs in lsp-client.ts. - const interval = setInterval(checkClient, POLL_INTERVAL_MS); - const disposable = new vscode.Disposable(() => { clearInterval(interval); }); - const originalDispose = controller.dispose.bind(controller); - controller.dispose = () => { - disposable.dispose(); - originalDispose(); - }; -} - -/** Request test discovery from the LSP server. */ -async function requestDiscovery(store: Store): Promise<void> { - const client = store.client.value; - if (client?.isRunning() !== true) { return; } - - try { - await client.sendRequest("workspace/executeCommand", { - command: "basilisk.discoverTests", - arguments: [], - }); - } catch (err: unknown) { - const msg = err instanceof Error ? err.message : String(err); - Logger.error(`Test discovery request failed: ${msg}`); - } -} - -/** - * Populate the TestController with items from an LSP discovery notification. - * - * Replaces the entire tree — items not in the new list are removed. - * - * Implements [LSPTEST-TEST-ITEM-DATA-MODEL-HIERARCHY] (VS Code side) — renders the - * File > Class > Method tree received from the server into native TestItem nodes. - */ -function populateTestItems( - controller: vscode.TestController, - items: LspTestItem[] -): void { - // Track which top-level IDs we see so we can prune stale entries. - const seenIds = new Set<string>(); - - for (const item of items) { - seenIds.add(item.id); - upsertTestItem(controller, controller.items, item); - } - - // Remove stale top-level items. - controller.items.forEach((existing) => { - if (!seenIds.has(existing.id)) { - controller.items.delete(existing.id); - } - }); -} - -/** Create or update a test item and its children recursively. */ -function upsertTestItem( - controller: vscode.TestController, - collection: vscode.TestItemCollection, - lspItem: LspTestItem -): vscode.TestItem { - const uri = vscode.Uri.file(lspItem.file); - const range = new vscode.Range( - new vscode.Position(lspItem.line, 0), - new vscode.Position(lspItem.line, 0) - ); - - // Always recreate the item to update uri (read-only after creation). - let testItem = collection.get(lspItem.id); - if (testItem !== undefined) { - collection.delete(lspItem.id); - } - testItem = controller.createTestItem(lspItem.id, lspItem.name, uri); - testItem.range = range; - collection.add(testItem); - - // Recursively populate children. - const childIds = new Set<string>(); - for (const child of lspItem.children) { - childIds.add(child.id); - upsertTestItem(controller, testItem.children, child); - } - - // Remove stale children. - testItem.children.forEach((existing) => { - if (!childIds.has(existing.id)) { - testItem.children.delete(existing.id); - } - }); - - return testItem; -} - -/** Arguments for running tests. */ -interface RunTestsArgs { - controller: vscode.TestController; - store: Store; - request: vscode.TestRunRequest; - token: vscode.CancellationToken; - debug: boolean; - coverage?: boolean; -} - -/** - * Run or debug the requested tests. - * - * For run mode, sends `basilisk.runTests` to the LSP server. - * For debug mode, sends `basilisk.debugTest` and starts a VS Code debug session. - * - * Implements [LSPTEST-EDITOR-SPECIFIC-INTEGRATION-VSCODE] — invokes the server - * [LSPTEST-LSP-PROTOCOL-COMMANDS] handlers and streams results into the Test Explorer. - */ -async function runTests(args: RunTestsArgs): Promise<void> { - const { controller, store, request, token, debug, coverage = false } = args; - const run = controller.createTestRun(request); - const client = store.client.value; - - if (client?.isRunning() !== true) { - run.end(); - return; - } - - // Collect test IDs to run. - const testIds = collectTestIds(request, controller); - if (testIds.length === 0) { - run.end(); - return; - } - - // Mark all as started. - for (const id of testIds) { - const item = findTestItem(controller, id); - if (item !== undefined) { run.started(item); } - } - - if (token.isCancellationRequested) { - run.end(); - return; - } - - try { - if (debug) { - await runDebugTest({ client, store, run, controller, testId: testIds[0] }); - } else { - await runNormalTests({ client, run, controller, testIds, coverage }); - } - } catch (err: unknown) { - const msg = err instanceof Error ? err.message : String(err); - Logger.error(`Test run failed: ${msg}`); - for (const id of testIds) { - const item = findTestItem(controller, id); - if (item !== undefined) { - run.errored(item, new vscode.TestMessage(msg)); - } - } - } - - run.end(); -} - -/** Arguments for running normal (non-debug) tests. */ -interface RunNormalTestsArgs { - client: LanguageClient; - run: vscode.TestRun; - controller: vscode.TestController; - testIds: string[]; - coverage: boolean; -} - -/** Execute tests normally (not debug). */ -async function runNormalTests(args: RunNormalTestsArgs): Promise<void> { - const { client, run, controller, testIds, coverage } = args; - const command = coverage ? "basilisk.runTestsCoverage" : "basilisk.runTests"; - const result = await client.sendRequest("workspace/executeCommand", { - command, - arguments: [{ testIds }], - }); - - if (result === null) { return; } - - const typed = narrowRunResult(result); - // Use per-test results when available, fall back to bulk pass/fail. - if (typed.perTest.length > 0) { - applyPerTestResults(run, controller, typed.perTest); - } else { - // Fallback: mark all tests based on overall pass/fail. - for (const id of testIds) { - const item = findTestItem(controller, id); - if (item === undefined) { continue; } - - if (typed.passed) { - run.passed(item); - } else { - const message = new vscode.TestMessage( - typed.stderr !== "" ? typed.stderr : typed.stdout - ); - run.failed(item, message); - } - } - } -} - -/** - * Narrow the server's run report into the shape this module reads. - * - * The report crosses a process boundary, so every field is checked rather than - * asserted: a server on a different version yields empty text and a failed - * verdict instead of a value the compiler would keep vouching for. - */ -function narrowRunResult(value: unknown): LspTestRunResult { - return { - stdout: stringField(value, "stdout") ?? "", - stderr: stringField(value, "stderr") ?? "", - exitCode: numberField(value, "exitCode") ?? 0, - passed: booleanField(value, "passed") ?? false, - perTest: narrowPerTestResults(value), - }; -} - -/** Narrow the report's `perTest` array, dropping entries of an unknown shape. */ -function narrowPerTestResults(value: unknown): LspPerTestResult[] { - return recordArrayField(value, "perTest").flatMap((entry) => { - const testId = stringField(entry, "testId"); - const status = stringField(entry, "status"); - if (testId === undefined || !isTestStatus(status)) { return []; } - return [{ testId, status, message: stringField(entry, "message") ?? "" }]; - }); -} - -/** Whether `value` is one of the four statuses a per-test result can carry. */ -function isTestStatus(value: string | undefined): value is TestStatus { - return value === "passed" || value === "failed" || value === "skipped" || value === "error"; -} - -/** Arguments for running a debug test. */ -interface RunDebugTestArgs { - client: LanguageClient; - store: Store; - run: vscode.TestRun; - controller: vscode.TestController; - testId: string; -} - -/** Start a debug session targeting a specific test. */ -async function runDebugTest(args: RunDebugTestArgs): Promise<void> { - const { client, run, controller, testId } = args; - const rawResult = await client.sendRequest("workspace/executeCommand", { - command: "basilisk.debugTest", - arguments: [{ testId }], - }); - - if (rawResult === null) { - const item = findTestItem(controller, testId); - if (item !== undefined) { - run.errored(item, new vscode.TestMessage("Failed to start debug session")); - } - return; - } - - // The proxy address is checked, not asserted: a report without a usable - // host/port must surface as an errored test, never as an attach to - // `undefined:undefined`. - const host = stringField(rawResult, "host"); - const port = numberField(rawResult, "port"); - if (host === undefined || port === undefined) { - const item = findTestItem(controller, testId); - if (item !== undefined) { - run.errored(item, new vscode.TestMessage("Debug proxy did not report a host and port")); - } - return; - } - - // Start a VS Code debug session connecting to the debugpy proxy. - const debugStarted = await vscode.debug.startDebugging( - vscode.workspace.workspaceFolders?.[0], - { - name: `Debug Test: ${testId}`, - type: "basilisk-debug", - request: "attach", - connect: { host, port }, - } - ); - - const item = findTestItem(controller, testId); - if (item !== undefined) { - if (debugStarted) { - // Debug session started — result will come from debug adapter. - // Mark as passed for now; the user will see failures in the debugger. - run.passed(item); - } else { - run.errored(item, new vscode.TestMessage("Debug session failed to start")); - } - } -} - -// Implements [LSPTEST-EDITOR-SPECIFIC-INTEGRATION-VSCODE] — streams pass/fail/skip/error results -// (the [LSPTEST-TEST-ITEM-DATA-MODEL-HIERARCHY] inline failure message) into the Test Explorer. -/** Apply per-test results to the test run. */ -function applyPerTestResults( - run: vscode.TestRun, - controller: vscode.TestController, - perTest: LspPerTestResult[] -): void { - for (const result of perTest) { - const item = findTestItem(controller, result.testId); - if (item === undefined) { continue; } - - switch (result.status) { - case "passed": - run.passed(item); - break; - case "failed": - run.failed(item, new vscode.TestMessage(result.message === "" ? "Test failed" : result.message)); - break; - case "skipped": - run.skipped(item); - break; - case "error": - run.errored(item, new vscode.TestMessage(result.message === "" ? "Test errored" : result.message)); - break; - } - } -} - -/** Collect test IDs from the run request. */ -function collectTestIds( - request: vscode.TestRunRequest, - controller: vscode.TestController -): string[] { - if (request.include !== undefined && request.include.length > 0) { - return request.include.map((item) => item.id); - } - - // No specific items — run all. - const ids: string[] = []; - controller.items.forEach((item) => { ids.push(item.id); }); - return ids; -} - -/** Find a test item by ID anywhere in the tree. */ -function findTestItem( - controller: vscode.TestController, - id: string -): vscode.TestItem | undefined { - return findInCollection(controller.items, id); -} - -/** Recursively search a TestItemCollection for an item by ID. */ -function findInCollection( - collection: vscode.TestItemCollection, - id: string -): vscode.TestItem | undefined { - const direct = collection.get(id); - if (direct !== undefined) { return direct; } - - let found: vscode.TestItem | undefined; - collection.forEach((item) => { - if (found !== undefined) { return; } - found = findInCollection(item.children, id); - }); - return found; -} diff --git a/vscode-extension/src/test/fixtures/busy_wait.py b/vscode-extension/src/test/fixtures/busy_wait.py deleted file mode 100644 index 431858d26..000000000 --- a/vscode-extension/src/test/fixtures/busy_wait.py +++ /dev/null @@ -1,10 +0,0 @@ -"""Fixture that runs until the debugger disconnects. - -Used by the running-not-paused probes: the program must still be alive -(and NOT stopped at any breakpoint) whenever the test interrogates the -debug session, so it just sleeps in a loop until stopDebugging() kills it. -""" -import time - -while True: - time.sleep(0.05) diff --git a/vscode-extension/src/test/fixtures/debug_stepping.py b/vscode-extension/src/test/fixtures/debug_stepping.py deleted file mode 100644 index ceb651837..000000000 --- a/vscode-extension/src/test/fixtures/debug_stepping.py +++ /dev/null @@ -1,136 +0,0 @@ -"""Test fixture for debug stepping E2E tests. - -Each function has known variable values at specific lines. -The test harness sets breakpoints, steps through, and asserts -variable values match expectations. -""" - - -def arithmetic(): - """Simple arithmetic — step through and check values.""" - x = 10 # line 11 - y = 20 # line 12 - z = x + y # line 13 — z should be 30 - w = z * 2 # line 14 — w should be 60 - result = w - 5 # line 15 — result should be 55 - return result # line 16 - - -def string_ops(): - """String operations — check string values after each step.""" - greeting = "hello" # line 21 - name = "world" # line 22 - message = greeting + " " + name # line 23 — "hello world" - upper = message.upper() # line 24 — "HELLO WORLD" - length = len(upper) # line 25 — 11 - return upper # line 26 - - -def list_ops(): - """List mutations — verify list contents after each step.""" - items = [1, 2, 3] # line 31 - items.append(4) # line 32 — [1, 2, 3, 4] - items.insert(0, 0) # line 33 — [0, 1, 2, 3, 4] - total = sum(items) # line 34 — 10 - count = len(items) # line 35 — 5 - return total # line 36 - - -def dict_ops(): - """Dictionary operations — check key/value pairs.""" - data = {"a": 1, "b": 2} # line 41 - data["c"] = 3 # line 42 - keys = list(data.keys()) # line 43 — ["a", "b", "c"] - total = sum(data.values()) # line 44 — 6 - has_a = "a" in data # line 45 — True - return total # line 46 - - -def nested_call(): - """Function calls — step into helper and back.""" - a = 5 # line 51 - b = double(a) # line 52 — b should be 10 - c = double(b) # line 53 — c should be 20 - return c # line 54 - - -def double(n): - """Helper for nested_call.""" - result = n * 2 # line 59 - return result # line 60 - - -def loop_and_accumulate(): - """Loop stepping — verify accumulator at each iteration.""" - total = 0 # line 65 - for i in range(5): # line 66 - total += i # line 67 - # After loop: total = 0+1+2+3+4 = 10 - return total # line 69 - - -def conditional_branches(): - """Branching — verify which branch executes.""" - x = 42 # line 74 - if x > 100: # line 75 - label = "big" # line 76 - elif x > 10: # line 77 - label = "medium" # line 78 - else: - label = "small" # line 80 - return label # line 81 - - -def exception_handling(): - """Try/except — verify exception info.""" - caught = False # line 86 - error_msg = "" # line 87 - try: - value = 1 / 0 # line 89 - except ZeroDivisionError as exc: - caught = True # line 91 - error_msg = str(exc) # line 92 - return caught # line 93 - - -def type_variety(): - """Various types — verify type representations.""" - an_int = 42 # line 98 - a_float = 3.14 # line 99 - a_bool = True # line 100 - a_none = None # line 101 - a_tuple = (1, "two", 3.0) # line 102 - a_set = {10, 20, 30} # line 103 - a_bytes = b"hello" # line 104 - return an_int # line 105 - - -class Point: - def __init__(self, x: int, y: int) -> None: - self.x = x - self.y = y - - def magnitude(self) -> float: - return (self.x ** 2 + self.y ** 2) ** 0.5 - - -def class_instance() -> float: - """Class instantiation — check object attributes.""" - p = Point(3, 4) # line 119 - mag = p.magnitude() # line 120 — 5.0 - return mag # line 121 - - -# Entry point — run all functions so the script completes. -if __name__ == "__main__": - arithmetic() - string_ops() - list_ops() - dict_ops() - nested_call() - loop_and_accumulate() - conditional_branches() - exception_handling() - type_variety() - class_instance() - print("All debug fixtures executed.") diff --git a/vscode-extension/src/test/fixtures/memory_autopilot_loop.py b/vscode-extension/src/test/fixtures/memory_autopilot_loop.py deleted file mode 100644 index 3fcbea6f4..000000000 --- a/vscode-extension/src/test/fixtures/memory_autopilot_loop.py +++ /dev/null @@ -1,37 +0,0 @@ -"""Autopilot e2e fixture: leaks a fixed chunk on every loop iteration. - -Mirrors examples/memory_demo.py's leak_cache: each pass over `leak_round` -retains ~1.5 MiB in the module-global CACHE, so the autopilot's per-pause -snapshot+diff sees steady growth at the SAME site and escalates leak confidence -LOW -> MEDIUM -> HIGH across passes. `Widget` exists so the reference-graph type -picker has a real class symbol to offer. -""" - -CACHE = [] - - -class Widget: - """A retained object type for the reference-graph picker.""" - - def __init__(self, index: int) -> None: - self.index = index - self.blob = bytes(1024) - - -def leak_round(index: int) -> int: - for _i in range(300): - CACHE.append("x" * 5000) # ALLOC: ~1.5 MiB retained per round (the leak) - CACHE.append(Widget(index)) - return len(CACHE) - - -def main() -> None: - total = 0 - for index in range(8): - total = leak_round(index) # BREAKPOINT: autopilot captures on each pause - print(f"round {index}: {total} cached") - print("DONE", total) - - -if __name__ == "__main__": - main() diff --git a/vscode-extension/src/test/fixtures/memory_busy.py b/vscode-extension/src/test/fixtures/memory_busy.py deleted file mode 100644 index 995b97e6a..000000000 --- a/vscode-extension/src/test/fixtures/memory_busy.py +++ /dev/null @@ -1,13 +0,0 @@ -"""Run-forever allocator for the auto-pause memory e2e. - -Grows a module-level cache until the debugger disconnects, so a memory -snapshot taken at ANY moment attributes real allocations to the append -line below — without the test needing breakpoints or pauses. -""" -import time - -CACHE = [] - -while True: - CACHE.append("x" * 5000) - time.sleep(0.01) diff --git a/vscode-extension/src/test/fixtures/memory_growth.py b/vscode-extension/src/test/fixtures/memory_growth.py deleted file mode 100644 index 127bb3212..000000000 --- a/vscode-extension/src/test/fixtures/memory_growth.py +++ /dev/null @@ -1,22 +0,0 @@ -"""Memory profiling e2e fixture: grows a module-level cache in steps.""" -import time - -CACHE = [] - - -def allocate_chunk(): - for _i in range(300): - CACHE.append("x" * 5000) - return len(CACHE) - - -def main(): - first = allocate_chunk() - second = allocate_chunk() - third = allocate_chunk() - time.sleep(2) - print("DONE", first, second, third) - - -if __name__ == "__main__": - main() diff --git a/vscode-extension/src/test/fixtures/memory_introspect.py b/vscode-extension/src/test/fixtures/memory_introspect.py deleted file mode 100644 index 2bb358467..000000000 --- a/vscode-extension/src/test/fixtures/memory_introspect.py +++ /dev/null @@ -1,59 +0,0 @@ -"""Memory-introspection e2e fixture: a retained object graph + a dropped cycle. - -`build_registry` keeps a module-global list of custom-typed `Widget`s alive — a -real retention root the reference-graph walk can target. `make_cycle` builds two -linked `CycleNode`s and drops them, leaving an unreachable finalizer cycle on the -heap. `gc.disable()` keeps that cycle uncollected until the e2e drives a manual -`gc.collect()` through the gc-collect courier, so the collection is deterministic. -""" -import gc - -# The e2e owns collection timing: with the automatic collector off, the dropped -# cycle in make_cycle() survives on the heap until the gc-collect courier runs. -gc.disable() - - -class Widget: - """A retained, custom-typed object the reference-graph walk targets.""" - - def __init__(self, label): - self.label = label - - -class CycleNode: - """A finalizer node; two linked instances form an unreachable cycle.""" - - def __init__(self, name): - self.name = name - self.peer = None - - def __del__(self): - pass - - -REGISTRY = [] # module global: a real retention root for the Widgets - - -def build_registry(): - for index in range(8): - REGISTRY.append(Widget(f"w{index}")) - - -def make_cycle(): - left = CycleNode("left") - right = CycleNode("right") - left.peer = right - right.peer = left - # left/right drop on return -> an unreachable finalizer cycle on the heap. - - -def main(): - build_registry() - ready = True # BP_TRACK: start tracking + walk references here - make_cycle() - done = ready # BP_GC: force a collection here - return done - - -if __name__ == "__main__": - main() diff --git a/vscode-extension/src/test/real-world/corpus.ts b/vscode-extension/src/test/real-world/corpus.ts deleted file mode 100644 index 07b221611..000000000 --- a/vscode-extension/src/test/real-world/corpus.ts +++ /dev/null @@ -1,171 +0,0 @@ -// Implements [VSIX-REALWORLD-CORPUS]. See docs/specs/VSIX-REAL-WORLD-SPEC.md#VSIX-REALWORLD-CORPUS -/** - * Typed access to the real-world corpus manifest - * (`test-fixtures/real-world-corpus.json`) — the single source of truth - * shared with `scripts/fetch-real-world-repos.mjs` and `.vscode-test.mjs`. - * - * Probe positions are located by searching the PINNED file content for a - * verified token (the corpus is pinned to exact commit SHAs, so tokens are - * immutable). A missing token means the manifest and the fetched tree have - * drifted — that fails loudly, never silently probes the wrong position. - */ - -import * as assert from 'assert'; -import * as fs from 'fs'; -import * as path from 'path'; -import * as vscode from 'vscode'; -import { locate } from '../suite/test-helpers'; -import { recordArrayField } from '../../unknown-shape'; - -/** Env var each real-world test config sets to select its corpus entry. */ -export const REPO_ENV_VAR = 'BSK_REAL_WORLD_REPO'; - -/** Marker file the fetch script stamps into a fully-extracted repo. */ -export const FETCH_MARKER = '.bsk-real-world-ok'; - -export interface HoverProbe { - readonly token: string; - readonly at?: string; - readonly expect: readonly string[]; -} - -export interface DefinitionProbe { - readonly token: string; - readonly at?: string; - /** Repo-relative path (forward slashes) the definition must land in. */ - readonly expectFile: string; -} - -export interface CompletionProbe { - readonly token: string; - /** Prefix inside `token` ending with the dot to complete after (e.g. `self.`). */ - readonly afterDot: string; - readonly expect: readonly string[]; -} - -export interface ReferenceProbe { - readonly token: string; - readonly at?: string; - readonly minLocations: number; -} - -export interface FileJourney { - readonly path: string; - readonly minDocumentSymbols: number; - readonly expectSymbols: readonly string[]; - readonly hovers: readonly HoverProbe[]; - readonly definitions: readonly DefinitionProbe[]; - readonly completions: readonly CompletionProbe[]; - readonly references: readonly ReferenceProbe[]; -} - -export interface WorkspaceSymbolProbe { - readonly query: string; - readonly expectName: string; - readonly expectFile: string; -} - -export interface EditChurnSpec { - readonly path: string; - readonly cycles: number; -} - -export interface OpenBlitzSpec { - readonly dir: string; - readonly count: number; -} - -export interface ResourceBudgetsSpec { - readonly maxServerRssMb: number; - readonly maxServerLeakMb: number; - readonly maxExtHostRssMb: number; - readonly maxIdleCpuPercent: number; - readonly cpuSettleTimeoutMs: number; -} - -export interface RepoSpec { - readonly name: string; - readonly org: string; - readonly repo: string; - readonly tag: string; - readonly commit: string; - readonly sentinel: string; - /** Floor on `.py` files in the fetched tree — proves the full tree landed. */ - readonly minPythonFiles: number; - readonly budgets: ResourceBudgetsSpec; - readonly workspaceSymbols: readonly WorkspaceSymbolProbe[]; - readonly editChurn: EditChurnSpec; - readonly openBlitz: OpenBlitzSpec; - readonly files: readonly FileJourney[]; -} - - -/** Absolute path to the extension root (…/vscode-extension). */ -function extensionRoot(): string { - // out/test/real-world → out/test → out → extension root - return path.resolve(__dirname, '..', '..', '..'); -} - -/** Load the corpus manifest from test-fixtures. */ -export function loadCorpus(): readonly RepoSpec[] { - const manifest = path.join(extensionRoot(), 'test-fixtures', 'real-world-corpus.json'); - const parsed: unknown = JSON.parse(fs.readFileSync(manifest, 'utf8')); - // Check `repos` is really a non-empty array of objects BEFORE trusting its - // element type: reading `.length` off an unchecked cast throws a - // TypeError on a malformed manifest instead of failing this assertion. - const repos = recordArrayField(parsed, 'repos'); - assert.ok(repos.length > 0, `corpus manifest ${manifest} lists no repos`); - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- RepoSpec is a 9-field committed fixture schema; scripts/fetch-real-world-repos.mjs validates it field-by-field before any test reads it - return repos as unknown as readonly RepoSpec[]; -} - -/** The corpus entry selected by the active test config's env var. */ -export function activeRepoSpec(): RepoSpec { - const name = process.env[REPO_ENV_VAR]; - assert.ok( - name !== undefined && name !== '', - `${REPO_ENV_VAR} is not set — real-world suites must run via their ` + - '.vscode-test.mjs configs (npm run test:real-world)', - ); - const spec = loadCorpus().find((r) => r.name === name); - assert.ok(spec !== undefined, `${REPO_ENV_VAR}=${name} does not match any corpus repo`); - return spec; -} - -/** Convert a 0-based character offset in `content` to a Position. */ -function offsetToPosition(content: string, offset: number): vscode.Position { - const before = content.slice(0, offset); - const lines = before.split('\n'); - const line = lines.length - 1; - return new vscode.Position(line, lines[line].length); -} - -/** - * Locate `token` (first occurrence) in `content` and return a Position in - * the MIDDLE of `at` (a substring of `token`, defaulting to the whole - * token) — where a user's cursor would sit when hovering or clicking. - * - * The `token` disambiguates WHICH occurrence of `at` is meant; the actual - * cursor math is delegated to the shared {@link locate} helper. - */ -export function probePosition(content: string, token: string, at?: string): vscode.Position { - const tokenIdx = content.indexOf(token); - assert.notStrictEqual(tokenIdx, -1, `probe token ${JSON.stringify(token)} not found — corpus drifted from pinned tree`); - const sub = at ?? token; - const subIdx = token.indexOf(sub); - assert.notStrictEqual(subIdx, -1, `probe 'at' ${JSON.stringify(sub)} not inside token ${JSON.stringify(token)}`); - const occurrence = content.slice(0, tokenIdx + subIdx).split(sub).length - 1; - return locate(content, sub, occurrence); -} - -/** - * Position immediately AFTER the dot of `afterDot` (e.g. `self.`) within - * `token` — the position a user's caret has when member completion fires. - */ -export function completionPosition(content: string, probe: CompletionProbe): vscode.Position { - const tokenIdx = content.indexOf(probe.token); - assert.notStrictEqual(tokenIdx, -1, `completion token ${JSON.stringify(probe.token)} not found — corpus drifted from pinned tree`); - const dotIdx = probe.token.indexOf(probe.afterDot); - assert.notStrictEqual(dotIdx, -1, `afterDot ${JSON.stringify(probe.afterDot)} not inside token ${JSON.stringify(probe.token)}`); - return offsetToPosition(content, tokenIdx + dotIdx + probe.afterDot.length); -} diff --git a/vscode-extension/src/test/real-world/journey.ts b/vscode-extension/src/test/real-world/journey.ts deleted file mode 100644 index 09ab05b80..000000000 --- a/vscode-extension/src/test/real-world/journey.ts +++ /dev/null @@ -1,452 +0,0 @@ -// Implements [VSIX-REALWORLD-JOURNEY]. See docs/specs/VSIX-REAL-WORLD-SPEC.md#VSIX-REALWORLD-JOURNEY -/** - * The interaction engine for the real-world e2e suites: every phase a user - * would drive by hand (open, hover, jump, complete, find references, edit, - * search) executed against a pinned real-world repository, with a counted - * assertion on every observable outcome. `check()` both asserts and counts, - * so the suite can enforce a minimum assertion density at the end - * ([VSIX-REALWORLD-JOURNEY] density floor). - */ - -import { delay } from '../../timeouts'; -import * as assert from 'assert'; -import * as fs from 'fs'; -import * as path from 'path'; -import * as vscode from 'vscode'; -import { numberField, recordField } from '../../unknown-shape'; -import { getStore } from '../../extension'; -import { - DIAGNOSTIC_TIMEOUT_MS, - filterBasiliskDiagnostics, - flattenSymbolNames, - getDocumentSymbols, - getHoverText, - getNavLocations, - pollUntilResult, - replaceDocumentContent, -} from '../suite/test-helpers'; -import { - type CompletionProbe, - type FileJourney, - type RepoSpec, - FETCH_MARKER, - completionPosition, - probePosition, -} from './corpus'; -import { type ResourceMonitor } from './metrics'; - -/** How long the workspace-wide diagnostic set must hold still to be "settled". */ -const DIAGNOSTIC_SETTLE_MS = 6_000; -/** Poll cadence while waiting for the diagnostic set to settle. */ -const SETTLE_POLL_MS = 500; -/** Skip blitz files smaller than this — no symbols to assert on. */ -const BLITZ_MIN_FILE_BYTES = 300; -/** Sample resources every N files during the open blitz. */ -const BLITZ_SAMPLE_EVERY = 4; - -let assertionCount = 0; - -/** Counted assertion — the unit of the suite's assertion-density floor. */ -export function check(condition: boolean, message: string): void { - assertionCount += 1; - assert.ok(condition, message); -} - -/** Counted strict-equality assertion. */ -export function checkEq<T>(actual: T, expected: T, message: string): void { - assertionCount += 1; - assert.strictEqual(actual, expected, message); -} - -/** Total counted assertions executed so far in this test process. */ -export function assertionTotal(): number { - return assertionCount; -} - -/** PID of the running basilisk LSP server (via the language client's child process). */ -export function findServerPid(): number { - const store = getStore(); - assert.ok(store !== undefined, 'extension store unavailable — extension not activated'); - const client = store.client.value; - assert.ok(client !== undefined, 'LSP client not started'); - // `_serverProcess` is vscode-languageclient internals, absent from its - // public types — read it as an observation rather than asserting the - // client has a shape its own d.ts does not promise. - const pid = numberField(recordField(client, '_serverProcess'), 'pid'); - assert.ok(typeof pid === 'number' && pid > 0, 'basilisk server PID unavailable on the language client'); - return pid; -} - -/** Recursively count `.py` files under `dir` (skipping dot-directories). */ -function countPythonFiles(dir: string): number { - let count = 0; - for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { - if (entry.name.startsWith('.')) { continue; } - const full = path.join(dir, entry.name); - if (entry.isDirectory()) { - count += countPythonFiles(full); - } else if (entry.name.endsWith('.py')) { - count += 1; - } - } - return count; -} - -/** - * Assert the opened workspace IS the pinned corpus tree: right folder name, - * fetch marker stamped with the pinned commit, sentinel present, and the - * full tree on disk. Returns the workspace root path. - */ -export function verifyPinnedWorkspace(spec: RepoSpec): string { - const folders = vscode.workspace.workspaceFolders ?? []; - checkEq(folders.length, 1, 'real-world config must open exactly one workspace folder'); - const root = folders[0].uri.fsPath; - checkEq(path.basename(root), spec.name, `workspace folder must be the ${spec.name} corpus checkout`); - const marker = path.join(root, FETCH_MARKER); - check(fs.existsSync(marker), `fetch marker missing — run scripts/fetch-real-world-repos.mjs (${marker})`); - checkEq( - fs.readFileSync(marker, 'utf8').trim(), spec.commit, - `workspace tree is not pinned at ${spec.tag} (${spec.commit}) — re-run the fetch script`, - ); - check(fs.existsSync(path.join(root, spec.sentinel)), `sentinel ${spec.sentinel} missing from workspace`); - const pyFiles = countPythonFiles(root); - check( - pyFiles >= spec.minPythonFiles, - `workspace holds ${pyFiles} .py files — expected at least ${spec.minPythonFiles}; tree is truncated`, - ); - return root; -} - -/** Open a repo-relative file in the editor and return its document. */ -export async function openWorkspaceFile(root: string, relPath: string): Promise<vscode.TextDocument> { - const doc = await vscode.workspace.openTextDocument(vscode.Uri.file(path.join(root, relPath))); - await vscode.window.showTextDocument(doc, { preview: false }); - checkEq(doc.languageId, 'python', `${relPath} must open as a Python document`); - check(doc.lineCount > 1, `${relPath} must have content (${doc.lineCount} lines)`); - return doc; -} - -/** Workspace-wide basilisk diagnostics, keyed by file, inside `root` only. */ -export function workspaceDiagnostics(root: string): [vscode.Uri, vscode.Diagnostic[]][] { - return vscode.languages.getDiagnostics() - .map(([uri, diags]): [vscode.Uri, vscode.Diagnostic[]] => [uri, filterBasiliskDiagnostics(diags)]) - .filter(([uri, diags]) => diags.length > 0 && uri.fsPath.startsWith(root)); -} - -/** - * Wait for whole-workspace analysis to complete: the workspace symbol index - * answers the first pinned query AND the diagnostic set holds still for - * {@link DIAGNOSTIC_SETTLE_MS}. Returns the settled per-file snapshot. - */ -export async function waitForWorkspaceAnalysis( - spec: RepoSpec, - root: string, -): Promise<[vscode.Uri, vscode.Diagnostic[]][]> { - const first = spec.workspaceSymbols[0]; - const symbols = await pollUntilResult({ - fn: async () => vscode.commands.executeCommand<vscode.SymbolInformation[]>( - 'vscode.executeWorkspaceSymbolProvider', first.query, - ).then((r) => r ?? [], () => [] as vscode.SymbolInformation[]), - predicate: (r) => r.some((s) => s.name === first.expectName), - timeoutMs: spec.budgets.cpuSettleTimeoutMs, - intervalMs: SETTLE_POLL_MS, - }).catch(() => [] as vscode.SymbolInformation[]); - check( - symbols.some((s) => s.name === first.expectName), - `workspace symbol index never answered "${first.query}" — analysis did not complete`, - ); - - // The server computes the whole scan BEFORE publishing anything, while - // the symbol index answers incrementally mid-scan — so the diagnostic - // set is deceptively "stable" at zero until the end-of-scan burst. An - // empty snapshot therefore NEVER counts as settled: every corpus repo - // is fetched without its dependencies, guaranteeing unresolved-import - // diagnostics once the scan actually publishes. - const deadline = Date.now() + spec.budgets.cpuSettleTimeoutMs; - let lastShape = ''; - let stableSince = Date.now(); - while (Date.now() < deadline) { - const snapshot = workspaceDiagnostics(root); - const shape = `${snapshot.length}:${snapshot.reduce((n, [, d]) => n + d.length, 0)}`; - if (shape !== lastShape) { - lastShape = shape; - stableSince = Date.now(); - } else if (snapshot.length > 0 && Date.now() - stableSince >= DIAGNOSTIC_SETTLE_MS) { - return snapshot; - } - await delay(SETTLE_POLL_MS); - } - assert.fail(`workspace diagnostics never settled non-empty within ${spec.budgets.cpuSettleTimeoutMs}ms (last shape ${lastShape})`); -} - -/** Structural invariants every published basilisk diagnostic must satisfy. */ -export function assertDiagnosticInvariants(snapshot: readonly [vscode.Uri, vscode.Diagnostic[]][]): void { - for (const [uri, diags] of snapshot) { - const rel = vscode.workspace.asRelativePath(uri); - check( - uri.fsPath.endsWith('.py') || uri.fsPath.endsWith('.pyi'), - `${rel}: basilisk diagnostics must only target Python files`, - ); - for (const d of diags) { - assertSingleDiagnosticInvariants(rel, d); - } - } -} - -function assertSingleDiagnosticInvariants(rel: string, d: vscode.Diagnostic): void { - check(d.message.trim().length > 0, `${rel}: diagnostic has an empty message`); - check(d.range.start.line >= 0, `${rel}: diagnostic range starts before line 0`); - check( - d.range.end.isAfterOrEqual(d.range.start), - `${rel}: diagnostic range ends (${d.range.end.line}:${d.range.end.character}) before it starts`, - ); - check( - d.severity >= vscode.DiagnosticSeverity.Error && d.severity <= vscode.DiagnosticSeverity.Hint, - `${rel}: diagnostic severity ${d.severity} is not a valid DiagnosticSeverity`, - ); - // PEP-rule codes are snake_case rule names; opt-in house rules are - // BSK-XXXX. Both carry a docs link to their /errors/<code> page. - if (typeof d.code === 'object') { - check(String(d.code.value).trim().length > 0, `${rel}: diagnostic has an empty code value`); - check( - d.code.target.toString().includes('/errors/'), - `${rel}: diagnostic docs link ${d.code.target.toString()} does not point at an /errors/ page`, - ); - } -} - -async function runHoverProbes(file: FileJourney, doc: vscode.TextDocument): Promise<void> { - for (const probe of file.hovers) { - const position = probePosition(doc.getText(), probe.token, probe.at); - const hover = await getHoverText(doc.uri, position); - check(hover.trim().length > 0, `${file.path}: no hover content at ${JSON.stringify(probe.at ?? probe.token)}`); - for (const expected of probe.expect) { - check( - hover.includes(expected), - `${file.path}: hover for ${JSON.stringify(probe.at ?? probe.token)} lacks ${JSON.stringify(expected)} — got: ${hover.slice(0, 200)}`, - ); - } - } -} - -/** Platform-independent (forward-slash) form of a filesystem path. */ -function slashed(fsPath: string): string { - return fsPath.split('\\').join('/'); -} - -async function runDefinitionProbes(file: FileJourney, doc: vscode.TextDocument, root: string): Promise<void> { - for (const probe of file.definitions) { - const position = probePosition(doc.getText(), probe.token, probe.at); - const locations = await getNavLocations('vscode.executeDefinitionProvider', doc.uri, position); - check(locations.length > 0, `${file.path}: no definition for ${JSON.stringify(probe.at ?? probe.token)}`); - const expectedPath = slashed(path.join(root, probe.expectFile)); - const hit = locations.find((l) => slashed(l.uri.fsPath) === expectedPath); - check( - hit !== undefined, - `${file.path}: definition of ${JSON.stringify(probe.at ?? probe.token)} should land in ${probe.expectFile} — ` + - `got ${locations.map((l) => vscode.workspace.asRelativePath(l.uri)).join(', ')}`, - ); - if (hit !== undefined) { - check(hit.range.start.line >= 0, `${file.path}: definition target range is invalid`); - check(fs.existsSync(hit.uri.fsPath), `${file.path}: definition target ${hit.uri.fsPath} does not exist on disk`); - } - } -} - -/** Normalise a completion item's label to its plain text. */ -function completionLabel(item: vscode.CompletionItem): string { - return typeof item.label === 'string' ? item.label : item.label.label; -} - -async function runCompletionProbe(file: FileJourney, doc: vscode.TextDocument, probe: CompletionProbe): Promise<void> { - const position = completionPosition(doc.getText(), probe); - const list = await pollUntilResult({ - fn: async () => vscode.commands.executeCommand<vscode.CompletionList>( - 'vscode.executeCompletionItemProvider', doc.uri, position, - ).then((r) => r ?? new vscode.CompletionList([]), () => new vscode.CompletionList([])), - predicate: (r) => r.items.length > 0, - timeoutMs: DIAGNOSTIC_TIMEOUT_MS, - }).catch(() => new vscode.CompletionList([])); - const labels = list.items.map(completionLabel); - check( - labels.length >= probe.expect.length, - `${file.path}: completion after ${JSON.stringify(probe.afterDot)} returned ${labels.length} items — ` + - `expected at least ${probe.expect.length}`, - ); - for (const expected of probe.expect) { - check( - labels.includes(expected), - `${file.path}: completion after ${JSON.stringify(probe.afterDot)} lacks ${JSON.stringify(expected)} — ` + - `got: ${labels.slice(0, 15).join(', ')}…`, - ); - } - for (const item of list.items) { - check(completionLabel(item).length > 0, `${file.path}: completion list contains an empty label`); - } -} - -async function runReferenceProbes(file: FileJourney, doc: vscode.TextDocument): Promise<void> { - for (const probe of file.references) { - const position = probePosition(doc.getText(), probe.token, probe.at); - const locations = await pollUntilResult({ - fn: async () => vscode.commands.executeCommand<vscode.Location[]>( - 'vscode.executeReferenceProvider', doc.uri, position, - ).then((r) => r ?? [], () => [] as vscode.Location[]), - predicate: (r) => r.length >= probe.minLocations, - timeoutMs: DIAGNOSTIC_TIMEOUT_MS, - }).catch(() => [] as vscode.Location[]); - check( - locations.length >= probe.minLocations, - `${file.path}: ${JSON.stringify(probe.at ?? probe.token)} has ${locations.length} references — ` + - `expected at least ${probe.minLocations}`, - ); - for (const loc of locations) { - check(loc.range.start.line >= 0, `${file.path}: reference location has an invalid range`); - check(fs.existsSync(loc.uri.fsPath), `${file.path}: reference target ${loc.uri.fsPath} does not exist`); - } - } -} - -/** The full per-file interaction journey: symbols → hovers → defs → completions → refs. */ -export async function runFileJourney(file: FileJourney, root: string, monitor: ResourceMonitor): Promise<void> { - const doc = await openWorkspaceFile(root, file.path); - const symbols = await getDocumentSymbols(doc.uri, (s) => s.length >= file.minDocumentSymbols); - check( - symbols.length >= file.minDocumentSymbols, - `${file.path}: expected at least ${file.minDocumentSymbols} top-level symbols, got ${symbols.length}`, - ); - const names = flattenSymbolNames(symbols); - for (const expected of file.expectSymbols) { - check(names.includes(expected), `${file.path}: document symbols lack ${JSON.stringify(expected)}`); - } - for (const name of names) { - check(name.trim().length > 0, `${file.path}: document symbol with empty name`); - } - await runHoverProbes(file, doc); - await runDefinitionProbes(file, doc, root); - for (const probe of file.completions) { - await runCompletionProbe(file, doc, probe); - } - await runReferenceProbes(file, doc); - monitor.assertMemoryWithinBudget(`after journey ${file.path}`); -} - -/** Workspace-symbol search must resolve every pinned query to its file. */ -export async function runWorkspaceSymbolProbes(spec: RepoSpec, root: string): Promise<void> { - for (const probe of spec.workspaceSymbols) { - const results = await pollUntilResult({ - fn: async () => vscode.commands.executeCommand<vscode.SymbolInformation[]>( - 'vscode.executeWorkspaceSymbolProvider', probe.query, - ).then((r) => r ?? [], () => [] as vscode.SymbolInformation[]), - predicate: (r) => r.length > 0, - timeoutMs: DIAGNOSTIC_TIMEOUT_MS, - }).catch(() => [] as vscode.SymbolInformation[]); - check(results.length > 0, `workspace symbols: no results for "${probe.query}"`); - const expectedPath = slashed(path.join(root, probe.expectFile)); - check( - results.some((s) => s.name === probe.expectName && slashed(s.location.uri.fsPath) === expectedPath), - `workspace symbols: "${probe.query}" should surface ${probe.expectName} in ${probe.expectFile} — ` + - `got ${results.slice(0, 10).map((s) => `${s.name}@${vscode.workspace.asRelativePath(s.location.uri)}`).join(', ')}`, - ); - for (const s of results) { - check(s.name.length > 0, `workspace symbols: empty symbol name for query "${probe.query}"`); - } - } -} - -/** - * Open-many soak: open `count` real files back to back the way a user - * riffles through a codebase, asserting symbols on each and sampling - * resources as it goes. Feeds the leak assertion that follows it. - */ -export async function runOpenBlitz(spec: RepoSpec, root: string, monitor: ResourceMonitor): Promise<void> { - const dir = path.join(root, spec.openBlitz.dir); - const files = fs.readdirSync(dir) - .filter((f) => f.endsWith('.py') && fs.statSync(path.join(dir, f)).size > BLITZ_MIN_FILE_BYTES) - .sort() - .slice(0, spec.openBlitz.count); - check( - files.length >= Math.min(spec.openBlitz.count, 8), - `open blitz found only ${files.length} candidate files in ${spec.openBlitz.dir}`, - ); - for (const [index, name] of files.entries()) { - const doc = await openWorkspaceFile(root, `${spec.openBlitz.dir}/${name}`); - const symbols = await getDocumentSymbols(doc.uri); - check(symbols.length > 0, `open blitz: ${name} produced no document symbols`); - if ((index + 1) % BLITZ_SAMPLE_EVERY === 0) { - monitor.assertMemoryWithinBudget(`open blitz after ${index + 1} files`); - } - } - await vscode.commands.executeCommand('workbench.action.closeAllEditors'); -} - -/** - * The erroneous probe function appended during edit churn (cycle-unique). - * Returns an int literal against a declared `-> str` — flagged by - * `returns_compatibility` (def line) and `returns_compatibility_2` - * (return line). A literal is used deliberately: returning a mistyped - * *parameter* is not currently flagged by the checker. - */ -function churnProbeText(cycle: number): string { - return `\n\ndef _bsk_realworld_probe_${cycle}() -> str:\n return ${cycle}\n`; -} - -/** - * An unsaved edit re-triggers analysis of the whole workspace in - * wholeModule mode, so a churn diagnostic can take a full re-analysis pass - * (~15s on the flask corpus) to arrive — give it analysis-scale time. - * Exported so the churn test's mocha timeout covers every sanctioned poll. - */ -export const CHURN_DIAGNOSTIC_TIMEOUT_MS = 45_000; - -async function appendAndExpectError(doc: vscode.TextDocument, cycle: number, relPath: string): Promise<void> { - const probeText = churnProbeText(cycle); - const newText = doc.getText() + probeText; - check(await replaceDocumentContent(doc, newText), `${relPath}: churn edit ${cycle} failed to apply`); - // The bad-return error may anchor on the `return` line or the signature - // line depending on the rule — any line inside the appended probe counts. - const probeStartLine = newText.slice(0, newText.indexOf(probeText)).split('\n').length; - function inProbe(x: vscode.Diagnostic): boolean { - return x.range.start.line >= probeStartLine; - } - const diags = await pollUntilResult({ - fn: async () => filterBasiliskDiagnostics(vscode.languages.getDiagnostics(doc.uri)), - predicate: (d) => d.some(inProbe), - timeoutMs: CHURN_DIAGNOSTIC_TIMEOUT_MS, - }).catch(() => [] as vscode.Diagnostic[]); - const hit = diags.find(inProbe); - check(hit !== undefined, `${relPath}: churn cycle ${cycle} — no diagnostic on the appended bad return (lines >= ${probeStartLine})`); - if (hit !== undefined) { - checkEq(hit.severity, vscode.DiagnosticSeverity.Error, `${relPath}: bad return must be an Error`); - check(hit.message.length > 10, `${relPath}: churn diagnostic message is too thin: "${hit.message}"`); - assertSingleDiagnosticInvariants(relPath, hit); - } -} - -/** - * Edit churn: repeatedly introduce a guaranteed type error, assert the - * diagnostic arrives on the exact line, revert, and assert the diagnostic - * set returns to its baseline — live analysis, no stale leftovers. - */ -export async function runEditChurn(spec: RepoSpec, root: string): Promise<void> { - const relPath = spec.editChurn.path; - const doc = await openWorkspaceFile(root, relPath); - const original = doc.getText(); - const baseline = filterBasiliskDiagnostics(vscode.languages.getDiagnostics(doc.uri)).length; - for (let cycle = 0; cycle < spec.editChurn.cycles; cycle++) { - await appendAndExpectError(doc, cycle, relPath); - check(await replaceDocumentContent(doc, original), `${relPath}: churn revert ${cycle} failed to apply`); - const after = await pollUntilResult({ - fn: async () => filterBasiliskDiagnostics(vscode.languages.getDiagnostics(doc.uri)), - predicate: (d) => d.length === baseline, - timeoutMs: CHURN_DIAGNOSTIC_TIMEOUT_MS, - }).catch(() => filterBasiliskDiagnostics(vscode.languages.getDiagnostics(doc.uri))); - checkEq( - after.length, baseline, - `${relPath}: cycle ${cycle} — diagnostics did not return to baseline after revert`, - ); - checkEq(doc.getText(), original, `${relPath}: cycle ${cycle} — document text not restored`); - } - await vscode.commands.executeCommand('workbench.action.files.revert'); - checkEq(doc.isDirty, false, `${relPath}: document left dirty after churn`); -} diff --git a/vscode-extension/src/test/real-world/metrics.ts b/vscode-extension/src/test/real-world/metrics.ts deleted file mode 100644 index 25b59f555..000000000 --- a/vscode-extension/src/test/real-world/metrics.ts +++ /dev/null @@ -1,305 +0,0 @@ -// Implements [VSIX-REALWORLD-RESOURCES]. See docs/specs/VSIX-REAL-WORLD-SPEC.md#VSIX-REALWORLD-RESOURCES -/** - * OS-level resource measurement for the real-world e2e suites. - * - * Samples the basilisk LSP server process (RSS + cumulative CPU time) from - * OUTSIDE the process — the honest external view, not self-reported stats — - * plus the extension host's own RSS. The {@link ResourceMonitor} turns those - * samples into hard assertions: peak memory budgets, leak ceilings, CPU - * settle-after-analysis, and server-PID stability (a PID change mid-journey - * means the server crashed and restarted, which is a failure, not a detail). - */ - -import { delay } from '../../timeouts'; -import * as assert from 'assert'; -import { execFileSync } from 'child_process'; -import * as fs from 'fs'; - -/** One point-in-time reading of a process. */ -export interface ProcessSample { - /** Resident set size, bytes. */ - readonly rssBytes: number; - /** Cumulative CPU time (user+system), milliseconds. */ - readonly cpuMs: number; - /** Wall-clock timestamp of the sample (ms since epoch). */ - readonly atMs: number; -} - -/** Budgets a repo journey must stay inside (from the corpus manifest). */ -export interface ResourceBudgets { - readonly maxServerRssMb: number; - readonly maxServerLeakMb: number; - readonly maxExtHostRssMb: number; - readonly maxIdleCpuPercent: number; - readonly cpuSettleTimeoutMs: number; -} - -const BYTES_PER_KB = 1024; -const BYTES_PER_MB = 1024 * 1024; -const MS_PER_SECOND = 1000; -const DEFAULT_LINUX_CLK_TCK = 100; -const CPU_WINDOW_MS = 2_000; -const SETTLED_WINDOWS_REQUIRED = 2; -/** Max time to lock onto a stable server PID at suite start. */ -const PID_LOCK_TIMEOUT_MS = 30_000; -/** Gap between the two PID reads that establish startup stability. */ -const PID_STABILITY_GAP_MS = 750; - -function sampleWindows(pid: number): ProcessSample { - // [long] casts keep the output culture-invariant (no decimal commas). - const script = - `$p = Get-Process -Id ${pid} -ErrorAction Stop; ` + - `Write-Output ("{0}|{1}" -f $p.WorkingSet64, [long][math]::Round($p.TotalProcessorTime.TotalMilliseconds))`; - const out = execFileSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', script], { - encoding: 'utf8', - }); - const [rss, cpu] = out.trim().split('|'); - return { rssBytes: Number(rss), cpuMs: Number(cpu), atMs: Date.now() }; -} - -let cachedClkTck: number | undefined; - -function linuxClkTck(): number { - if (cachedClkTck === undefined) { - try { - cachedClkTck = Number(execFileSync('getconf', ['CLK_TCK'], { encoding: 'utf8' }).trim()); - } catch { - cachedClkTck = DEFAULT_LINUX_CLK_TCK; - } - if (!Number.isFinite(cachedClkTck) || cachedClkTck <= 0) { - cachedClkTck = DEFAULT_LINUX_CLK_TCK; - } - } - return cachedClkTck; -} - -function sampleLinux(pid: number): ProcessSample { - const status = fs.readFileSync(`/proc/${pid}/status`, 'utf8'); - const rssLine = status.split('\n').find((l) => l.startsWith('VmRSS:')) ?? ''; - const rssKb = Number(rssLine.replace('VmRSS:', '').replace('kB', '').trim()); - // Fields after the ")" of the command name: utime is index 11, stime 12. - const stat = fs.readFileSync(`/proc/${pid}/stat`, 'utf8'); - const rest = stat.slice(stat.lastIndexOf(')') + 2).split(' '); - const ticks = Number(rest[11]) + Number(rest[12]); - return { - rssBytes: rssKb * BYTES_PER_KB, - cpuMs: (ticks * MS_PER_SECOND) / linuxClkTck(), - atMs: Date.now(), - }; -} - -/** Parse a ps(1) cputime value: `[[dd-]hh:]mm:ss[.cc]`. */ -export function parsePsCpuTime(raw: string): number { - let days = 0; - let rest = raw.trim(); - const dash = rest.indexOf('-'); - if (dash !== -1) { - days = Number(rest.slice(0, dash)); - rest = rest.slice(dash + 1); - } - const parts = rest.split(':').map(Number); - const seconds = parts.reduce((total, part) => total * 60 + part, 0); - const hoursFromDays = 24; - return Math.round(((days * hoursFromDays * 3600) + seconds) * MS_PER_SECOND); -} - -function sampleDarwin(pid: number): ProcessSample { - const out = execFileSync('ps', ['-o', 'rss=,cputime=', '-p', String(pid)], { encoding: 'utf8' }); - // Split on runs of spaces without a regex (see CLAUDE.md). - const fields = out.trim().split(' ').filter((f) => f.length > 0); - return { - rssBytes: Number(fields[0]) * BYTES_PER_KB, - cpuMs: parsePsCpuTime(fields[1] ?? '0:00'), - atMs: Date.now(), - }; -} - -/** Sample RSS + cumulative CPU of an arbitrary live process. Throws if dead. */ -export function sampleProcess(pid: number): ProcessSample { - if (process.platform === 'win32') { return sampleWindows(pid); } - if (process.platform === 'linux') { return sampleLinux(pid); } - return sampleDarwin(pid); -} - -function toMb(bytes: number): number { - return Math.round((bytes / BYTES_PER_MB) * 10) / 10; -} - -/** CPU utilisation (%) between two samples. May exceed 100 on multi-core. */ -export function cpuPercentBetween(prev: ProcessSample, next: ProcessSample): number { - const wallMs = next.atMs - prev.atMs; - if (wallMs <= 0) { return 0; } - return ((next.cpuMs - prev.cpuMs) / wallMs) * 100; -} - -/** - * Tracks the basilisk server + extension host across a journey and asserts - * every budget in the corpus manifest. Every assert*() call is a real gate: - * a budget breach fails the suite. - */ -export class ResourceMonitor { - private readonly resolvePid: () => number; - private readonly budgets: ResourceBudgets; - private readonly repo: string; - private readonly initialPid: number; - private peakRssBytes = 0; - private peakExtHostRssBytes = 0; - private lastSample: ProcessSample; - - private constructor(init: { - resolvePid: () => number; - budgets: ResourceBudgets; - repo: string; - pid: number; - first: ProcessSample; - }) { - this.resolvePid = init.resolvePid; - this.budgets = init.budgets; - this.repo = init.repo; - this.initialPid = init.pid; - this.lastSample = init.first; - this.peakRssBytes = init.first.rssBytes; - this.peakExtHostRssBytes = process.memoryUsage().rss; - } - - /** - * Lock onto the server process once it is STABLE: the PID must resolve - * and sample successfully twice, {@link PID_STABILITY_GAP_MS} apart, - * without changing. Client startup can briefly race the spawn; a server - * that dies or restarts AFTER this lock is a hard suite failure. - */ - public static async create( - resolvePid: () => number, - budgets: ResourceBudgets, - repo: string, - ): Promise<ResourceMonitor> { - const deadline = Date.now() + PID_LOCK_TIMEOUT_MS; - let lastError = 'no attempt made'; - while (Date.now() < deadline) { - try { - const pid = resolvePid(); - const first = sampleProcess(pid); - await delay(PID_STABILITY_GAP_MS); - if (resolvePid() === pid) { - return new ResourceMonitor({ resolvePid, budgets, repo, pid, first }); - } - lastError = `server PID changed during startup (was ${pid})`; - } catch (error: unknown) { - lastError = error instanceof Error ? error.message : String(error); - } - await delay(PID_STABILITY_GAP_MS); - } - assert.fail( - `[${repo}] could not lock onto a stable basilisk server process ` + - `within ${PID_LOCK_TIMEOUT_MS}ms — last error: ${lastError}`, - ); - } - - /** The PID being measured (asserts the server never restarted). */ - public pid(phase: string): number { - const current = this.resolvePid(); - assert.strictEqual( - current, this.initialPid, - `[${this.repo}] ${phase}: basilisk server PID changed ` + - `${this.initialPid} → ${current} — the server crashed or restarted mid-journey`, - ); - return current; - } - - /** Take a sample, update peaks, and return it. */ - public sample(phase: string): ProcessSample { - const s = sampleProcess(this.pid(phase)); - assert.ok( - Number.isFinite(s.rssBytes) && s.rssBytes > 0, - `[${this.repo}] ${phase}: RSS sample is not a positive number (${s.rssBytes})`, - ); - assert.ok( - Number.isFinite(s.cpuMs) && s.cpuMs >= 0, - `[${this.repo}] ${phase}: CPU-time sample is not a non-negative number (${s.cpuMs})`, - ); - this.peakRssBytes = Math.max(this.peakRssBytes, s.rssBytes); - this.peakExtHostRssBytes = Math.max(this.peakExtHostRssBytes, process.memoryUsage().rss); - this.lastSample = s; - return s; - } - - /** Assert current AND peak server RSS + extension-host RSS are in budget. */ - public assertMemoryWithinBudget(phase: string): void { - const s = this.sample(phase); - const maxBytes = this.budgets.maxServerRssMb * BYTES_PER_MB; - assert.ok( - s.rssBytes <= maxBytes, - `[${this.repo}] ${phase}: basilisk server RSS ${toMb(s.rssBytes)} MB ` + - `exceeds the ${this.budgets.maxServerRssMb} MB budget`, - ); - assert.ok( - this.peakRssBytes <= maxBytes, - `[${this.repo}] ${phase}: basilisk server PEAK RSS ${toMb(this.peakRssBytes)} MB ` + - `exceeds the ${this.budgets.maxServerRssMb} MB budget`, - ); - const extHostMax = this.budgets.maxExtHostRssMb * BYTES_PER_MB; - assert.ok( - this.peakExtHostRssBytes <= extHostMax, - `[${this.repo}] ${phase}: extension host PEAK RSS ${toMb(this.peakExtHostRssBytes)} MB ` + - `exceeds the ${this.budgets.maxExtHostRssMb} MB budget`, - ); - } - - /** Assert server RSS grew at most maxServerLeakMb since `baseline`. */ - public assertNoLeakSince(baseline: ProcessSample, phase: string): void { - const s = this.sample(phase); - const growthMb = toMb(s.rssBytes - baseline.rssBytes); - assert.ok( - growthMb <= this.budgets.maxServerLeakMb, - `[${this.repo}] ${phase}: basilisk server RSS grew ${growthMb} MB since baseline ` + - `(${toMb(baseline.rssBytes)} → ${toMb(s.rssBytes)} MB) — ` + - `leak budget is ${this.budgets.maxServerLeakMb} MB`, - ); - } - - /** - * Assert the server's CPU settles to idle: two consecutive ~2s windows - * below maxIdleCpuPercent, within cpuSettleTimeoutMs. Catches busy-loop - * and re-analysis-storm regressions that a one-shot sample would miss. - */ - public async assertCpuSettles(phase: string): Promise<number> { - const deadline = Date.now() + this.budgets.cpuSettleTimeoutMs; - let prev = this.sample(phase); - let calmWindows = 0; - let lastPct = Number.POSITIVE_INFINITY; - while (Date.now() < deadline) { - await delay(CPU_WINDOW_MS); - const next = this.sample(phase); - lastPct = cpuPercentBetween(prev, next); - prev = next; - calmWindows = lastPct <= this.budgets.maxIdleCpuPercent ? calmWindows + 1 : 0; - if (calmWindows >= SETTLED_WINDOWS_REQUIRED) { - return lastPct; - } - } - assert.fail( - `[${this.repo}] ${phase}: basilisk server CPU never settled below ` + - `${this.budgets.maxIdleCpuPercent}% for ${SETTLED_WINDOWS_REQUIRED} consecutive ` + - `windows within ${this.budgets.cpuSettleTimeoutMs}ms (last window: ${lastPct.toFixed(1)}%)`, - ); - } - - /** Peak server RSS seen so far (bytes) — for reporting in assertions. */ - public peakRss(): number { - return this.peakRssBytes; - } - - /** Most recent sample without re-sampling. */ - public last(): ProcessSample { - return this.lastSample; - } - - /** Measured numbers for the run — written to a calibration report file. */ - public report(): Record<string, number> { - return { - peakServerRssMb: toMb(this.peakRssBytes), - lastServerRssMb: toMb(this.lastSample.rssBytes), - peakExtHostRssMb: toMb(this.peakExtHostRssBytes), - }; - } -} diff --git a/vscode-extension/src/test/real-world/real-world.test.ts b/vscode-extension/src/test/real-world/real-world.test.ts deleted file mode 100644 index 9a365ba5c..000000000 --- a/vscode-extension/src/test/real-world/real-world.test.ts +++ /dev/null @@ -1,163 +0,0 @@ -// Tests for [VSIX-REALWORLD]. See docs/specs/VSIX-REAL-WORLD-SPEC.md#VSIX-REALWORLD -/** - * Real-world workspace e2e suite: opens a PINNED popular Python repository - * (flask / rich / fastapi — see test-fixtures/real-world-corpus.json) as the - * VS Code workspace and drives the extension the way a user does — waiting - * for whole-workspace analysis, hammering hovers, definitions, completions, - * references, workspace search, and edit churn — while the basilisk server - * process is measured from the OS and HELD to hard memory + CPU budgets - * ([VSIX-REALWORLD-RESOURCES]). - * - * One corpus repo per test process: `.vscode-test.mjs` builds a config per - * repo, sets BSK_REAL_WORLD_REPO, and opens the fetched tree as the - * workspace folder ([VSIX-REALWORLD-WIRING]). - */ - -import * as fs from 'fs'; -import * as path from 'path'; -import { - DIAGNOSTIC_TIMEOUT_MS, - SUITE_SETUP_TIMEOUT_MS, - closeAllEditors, - waitForLspReady, -} from '../suite/test-helpers'; -import { type FileJourney, activeRepoSpec } from './corpus'; -import { - CHURN_DIAGNOSTIC_TIMEOUT_MS, - assertDiagnosticInvariants, - assertionTotal, - check, - findServerPid, - runEditChurn, - runFileJourney, - runOpenBlitz, - runWorkspaceSymbolProbes, - verifyPinnedWorkspace, - waitForWorkspaceAnalysis, - workspaceDiagnostics, -} from './journey'; -import { type ProcessSample, ResourceMonitor } from './metrics'; - -/** Margin added to analysis-scale timeouts for editor/session overhead. */ -const TIMEOUT_MARGIN_MS = 60_000; - -/** - * Per-repo floor on counted assertions — the density ratchet. Measured runs - * count 7.5k (rich) to 28k (fastapi); the floor holds a wide margin under - * the weakest repo and only ratchets UP. - */ -const MIN_ASSERTIONS_PER_REPO = 2_000; - -/** - * Mocha budget for one file journey: every probe is allowed one full poll - * deadline, so the test timeout must cover the worst case the journey's - * own deadlines sanction — a slow-but-in-budget run must fail on the - * journey's descriptive assertion, never an opaque mocha timeout. - */ -function journeyTimeoutMs(file: FileJourney): number { - const probes = 1 + file.hovers.length + file.definitions.length + - file.completions.length + file.references.length; - return probes * DIAGNOSTIC_TIMEOUT_MS + TIMEOUT_MARGIN_MS; -} - -const spec = activeRepoSpec(); - -suite(`Real-world workspace: ${spec.name} @ ${spec.tag} [VSIX-REALWORLD]`, () => { - let root = ''; - let monitor: ResourceMonitor; - let postAnalysisBaseline: ProcessSample; - - suiteSetup(async function (this: Mocha.Context) { - this.timeout(SUITE_SETUP_TIMEOUT_MS); - root = verifyPinnedWorkspace(spec); - await waitForLspReady(); - monitor = await ResourceMonitor.create(findServerPid, spec.budgets, spec.name); - await closeAllEditors(); - }); - - suiteTeardown(async () => { - await closeAllEditors(); - // Calibration artifact: the measured peaks for this run, written next - // to the corpus checkouts (git-ignored). Budgets ratchet DOWN toward - // these numbers — see [VSIX-REALWORLD-RESOURCES]. - if (root !== '' && monitor !== undefined) { - const report = { - repo: spec.name, - tag: spec.tag, - ...monitor.report(), - assertions: assertionTotal(), - }; - fs.writeFileSync( - path.join(root, '..', `${spec.name}.metrics.json`), - JSON.stringify(report, null, 2), - ); - } - }); - - test('whole-workspace analysis completes, CPU settles, memory in budget', async function (this: Mocha.Context) { - // The body sequentially spends up to THREE full budgets: the - // workspace-symbol poll, the diagnostics-settle loop, and the CPU - // settle windows — the mocha timeout must cover all of them. - this.timeout(3 * spec.budgets.cpuSettleTimeoutMs + TIMEOUT_MARGIN_MS); - const settled = await waitForWorkspaceAnalysis(spec, root); - check( - settled.length > 0, - 'analysis must publish diagnostics — the corpus is fetched without its dependencies', - ); - const settledPct = await monitor.assertCpuSettles('post-analysis'); - check(settledPct >= 0, 'settled CPU percentage must be non-negative'); - monitor.assertMemoryWithinBudget('post-analysis'); - postAnalysisBaseline = monitor.last(); - }); - - test('every published diagnostic obeys structural invariants', () => { - // Fresh snapshot at execution time: validating a snapshot captured - // before the CPU settled could silently check a stale subset. - const snapshot = workspaceDiagnostics(root); - check( - snapshot.length > 0, - 'workspace must hold at least one basilisk diagnostic when invariants run', - ); - assertDiagnosticInvariants(snapshot); - monitor.assertMemoryWithinBudget('post-invariants'); - }); - - for (const file of spec.files) { - test(`interaction journey — ${file.path}`, async function (this: Mocha.Context) { - this.timeout(journeyTimeoutMs(file)); - await runFileJourney(file, root, monitor); - }); - } - - test('workspace symbol search resolves every pinned symbol', async () => { - await runWorkspaceSymbolProbes(spec, root); - monitor.assertMemoryWithinBudget('post-workspace-symbols'); - }); - - test(`edit churn keeps diagnostics live and honest — ${spec.editChurn.path}`, async function (this: Mocha.Context) { - // Each cycle sanctions two full churn polls (error arrives + revert - // settles) — the mocha budget must cover all of them. - this.timeout(spec.editChurn.cycles * 2 * CHURN_DIAGNOSTIC_TIMEOUT_MS + TIMEOUT_MARGIN_MS); - await runEditChurn(spec, root); - monitor.assertMemoryWithinBudget('post-edit-churn'); - }); - - test('open blitz: no leak, CPU settles back to idle', async function (this: Mocha.Context) { - // One symbol poll per blitzed file, then a full CPU-settle window. - this.timeout( - spec.openBlitz.count * DIAGNOSTIC_TIMEOUT_MS + - spec.budgets.cpuSettleTimeoutMs + TIMEOUT_MARGIN_MS, - ); - await runOpenBlitz(spec, root, monitor); - await monitor.assertCpuSettles('post-blitz'); - monitor.assertNoLeakSince(postAnalysisBaseline, 'post-blitz leak check'); - monitor.assertMemoryWithinBudget('final'); - }); - - test('assertion density meets the floor', () => { - check( - assertionTotal() >= MIN_ASSERTIONS_PER_REPO, - `suite executed ${assertionTotal()} counted assertions — floor is ${MIN_ASSERTIONS_PER_REPO}`, - ); - }); -}); diff --git a/vscode-extension/src/test/runTest.ts b/vscode-extension/src/test/runTest.ts deleted file mode 100644 index ad0932b0a..000000000 --- a/vscode-extension/src/test/runTest.ts +++ /dev/null @@ -1,177 +0,0 @@ -// Tests for [VSIX]. See docs/specs/VSIX-SPEC.md#VSIX -import * as path from 'path'; -import * as fs from 'fs'; -import * as os from 'os'; -import * as crypto from 'crypto'; -import { runTests } from '@vscode/test-electron'; -import { execFileSync } from 'child_process'; - -/** - * VS Code listens on a Unix socket inside the user-data dir; macOS caps - * AF_UNIX socket paths at 104 bytes ("IPC handle longer than 103 chars"). - * Deep checkouts (e.g. git worktrees) overflow that and the electron main - * process dies with `listen EINVAL`, so fall back to a short per-checkout - * dir under tmp — the same policy as .vscode-test.mjs. - */ -function resolveUserDataDir(extensionDevelopmentPath: string): string { - const defaultDir = path.join(extensionDevelopmentPath, '.vscode-test', 'user-data'); - if (defaultDir.length <= 80) { - return defaultDir; - } - const checkoutHash = crypto - .createHash('sha256') - .update(extensionDevelopmentPath) - .digest('hex') - .slice(0, 8); - return path.join(os.tmpdir(), `bsk-vsct-${checkoutHash}`); -} - -/** - * Find the system VS Code Electron binary on macOS. - * Returns the path to the Electron binary inside the .app bundle, - * or undefined if not found. - */ -function findSystemVSCodeElectron(): string | undefined { - // Check common macOS install locations. - const appPaths = [ - '/Applications/Visual Studio Code.app', - path.join(process.env.HOME ?? '', 'Applications/Visual Studio Code.app'), - ]; - for (const appPath of appPaths) { - const electron = path.join(appPath, 'Contents/MacOS/Electron'); - if (fs.existsSync(electron)) { - return electron; - } - } - - // Try resolving from the `code` CLI shim. execFileSync (no shell) keeps - // PATH-derived values out of shell parsing — CodeQL - // js/shell-command-injection-from-environment. - try { - const codePath = execFileSync('which', ['code'], { encoding: 'utf8' }).trim(); - const realPath = fs.realpathSync(codePath); - // realPath is like /Applications/Visual Studio Code.app/Contents/Resources/app/bin/code - const appRoot = realPath.replace(/\/Contents\/Resources\/app\/bin\/code$/, ''); - const electron = path.join(appRoot, 'Contents/MacOS/Electron'); - if (fs.existsSync(electron)) { - return electron; - } - } catch { - // Ignore - } - - return undefined; -} - -/** - * Find the built basilisk binary, preferring `release` over `debug`. - * - * Cargo names the executable `basilisk.exe` on Windows, so the suffix is not - * optional: without it this probe misses a perfectly good binary and `main()` - * aborts with "Basilisk binary not found" on every Windows checkout. Mirrors - * `findBasiliskBinary()` in suite/test-helpers.ts. Implements - * [VSIX-CI-PLATFORM-COVERAGE]. - */ -function findBinary(): string | undefined { - const workspaceRoot = path.resolve(__dirname, '../../..'); - const exe = process.platform === 'win32' ? '.exe' : ''; - for (const profile of ['release', 'debug']) { - const binary = path.join(workspaceRoot, 'target', profile, `basilisk${exe}`); - if (fs.existsSync(binary)) { - return binary; - } - } - return undefined; -} - -function syncShipwrightManifest(extensionDevelopmentPath: string): void { - const repoRoot = path.resolve(extensionDevelopmentPath, '..'); - const source = path.join(repoRoot, 'shipwright.json'); - const target = path.join(extensionDevelopmentPath, 'shipwright.json'); - if (!fs.existsSync(source)) { - throw new Error(`Missing Shipwright manifest: ${source}`); - } - fs.copyFileSync(source, target); -} - -/** - * Stage the runtime binaries through the ONE canonical staging script - * (`scripts/stage-runtime.mjs`) — the same path `_test_vsix` and the release - * packager use, so this debug runner can never validate a different bundle - * shape than what ships ([VSIX-PACKAGING-PARITY], #71). Also vendors the - * debugpy asset when it is not already present, so bundle-dependent journeys - * (debugging, memory profiling) run against the real layout. - */ -function stageBundledRuntime(extensionDevelopmentPath: string, binaryDir: string): void { - // execFileSync passes binaryDir (derived from BASILISK_EXECUTABLE_PATH) - // as an argv entry — no shell, so no expansion of `$(...)`/backticks from - // the environment (CodeQL js/indirect-command-line-injection). - execFileSync('node', ['scripts/stage-runtime.mjs', binaryDir], { - cwd: extensionDevelopmentPath, - stdio: 'inherit', - }); - const debugpyDir = path.join(extensionDevelopmentPath, 'bundled', 'debugpy'); - const vendored = fs.existsSync(debugpyDir) && fs.readdirSync(debugpyDir).length > 0; - if (!vendored) { - execFileSync('node', ['scripts/vendor-debugpy.mjs'], { - cwd: extensionDevelopmentPath, - stdio: 'inherit', - }); - } -} - -async function main(): Promise<void> { - try { - const extensionDevelopmentPath = path.resolve(__dirname, '../../'); - const extensionTestsPath = path.resolve(__dirname, './suite/index'); - - const systemElectron = findSystemVSCodeElectron(); - - const debugBinary = process.env.BASILISK_EXECUTABLE_PATH ?? findBinary(); - if (debugBinary === undefined || debugBinary === '') { - throw new Error( - 'Basilisk binary not found. Build with: cargo build -p basilisk-cli -p basilisk-profiler-helper' - ); - } - syncShipwrightManifest(extensionDevelopmentPath); - stageBundledRuntime(extensionDevelopmentPath, path.dirname(debugBinary)); - delete process.env.BASILISK_EXECUTABLE_PATH; - delete process.env.BASILISK_BINARY_DIR; - - // Open the SAME workspace the CI runner (.vscode-test.mjs) opens. The - // diagnostics suites depend on its config — the `[tool.basilisk]` - // table in `pyproject.toml` turns on the opt-in strict-annotation - // rules their fixtures trip, and - // `.vscode/settings.json` selects wholeModule analysis — while its - // settings carry no binary override, so activation still proves the - // 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({ - extensionDevelopmentPath, - extensionTestsPath, - ...(systemElectron !== undefined ? { vscodeExecutablePath: systemElectron } : {}), - launchArgs: [ - '--disable-extensions', - '--user-data-dir', resolveUserDataDir(extensionDevelopmentPath), - workspace, - ], - }); - } catch (err) { - // eslint-disable-next-line no-console - console.error('Failed to run tests', err); - process.exit(1); - } -} - -void main(); diff --git a/vscode-extension/src/test/suite/activity-panel-accessibility.test.ts b/vscode-extension/src/test/suite/activity-panel-accessibility.test.ts deleted file mode 100644 index 9d4e377f5..000000000 --- a/vscode-extension/src/test/suite/activity-panel-accessibility.test.ts +++ /dev/null @@ -1,226 +0,0 @@ -// Tests for [EXTACT]. See docs/specs/EXTENSION-ACTIVITY-PANEL-SPEC.md#EXTACT -/** - * Activity Panel Accessibility Audit Tests for the Basilisk VS Code Extension. - * - * Validates that the activity panel meets WCAG accessibility guidelines: - * - All tree items have descriptive labels (not empty/generic) - * - Status indicators use icon + text (never color alone) - * - All interactive elements have associated commands (keyboard navigable) - * - Tooltips provide sufficient context for screen readers - * - Context values enable context menu filtering for keyboard users - */ - -import * as assert from "assert"; -import { getStore } from "../../extension"; -import { - manifestCommands, - manifestMenu, - manifestViews, - manifestViewsWelcome, - type CommandContribution, -} from "./extension-manifest"; -import { - WAIT_MS, - setupLspTestSuite, - teardownLspTestSuite, - closeAllEditors, -} from "./test-helpers"; - -// ── Assertion helpers (extracted to keep the suite body under 120 lines) ── - -function assertViewsHaveDescriptiveNames(): void { - const views = manifestViews()["basilisk-explorer"] ?? []; - - for (const view of views) { - assert.ok(view.name, `View "${view.id}" should have a name`); - assert.ok( - view.name.length >= 3, - `View "${view.id}" name "${view.name}" should be descriptive (>=3 chars)`, - ); - } -} - -function assertCommandsHaveDescriptiveTitles(): void { - const commands = manifestCommands(); - - const panelCommands = commands.filter( - (cmd) => - cmd.command.includes("ModuleExplorer") || - cmd.command.includes("toggleFeature") || - cmd.command.includes("openWalkthrough") || - cmd.command.includes("copyImportPath") || - cmd.command.includes("copyQualifiedName") || - cmd.command.includes("filterModuleExplorer"), - ); - - assert.ok(panelCommands.length > 0, "Should find panel-related commands"); - - for (const cmd of panelCommands) { - assert.ok(cmd.title, `Command "${cmd.command}" should have a title`); - assert.ok( - cmd.title.length >= 3, - `Command "${cmd.command}" title "${cmd.title}" should be descriptive`, - ); - } -} - -function assertToolbarCommandsHaveIcons(): void { - const commands = manifestCommands(); - const titleMenus = manifestMenu("view/title"); - - const toolbarCommandIds = new Set(titleMenus.map((entry) => entry.command)); - - for (const cmdId of toolbarCommandIds) { - const cmd = commands.find((c) => c.command === cmdId); - assert.ok(cmd, `Toolbar command "${cmdId}" should exist in commands`); - assert.ok( - cmd.icon, - `Toolbar command "${cmdId}" should have an icon`, - ); - } -} - -function assertCommandsHaveCategory(): void { - const commands = manifestCommands(); - - const panelCommands = commands.filter( - (cmd) => - cmd.command.includes("ModuleExplorer") || - cmd.command.includes("toggleFeature") || - cmd.command.includes("openWalkthrough"), - ); - - for (const cmd of panelCommands) { - assert.ok( - cmd.category, - `Command "${cmd.command}" should have a category for command palette grouping`, - ); - } -} - -function assertToolbarMenusHaveWhenClauses(): void { - const titleMenus = manifestMenu("view/title"); - - const panelMenus = titleMenus.filter( - (entry) => - entry.when.includes("basilisk.moduleExplorer") || - entry.when.includes("basilisk.info"), - ); - - assert.ok(panelMenus.length > 0, "Should find panel toolbar menus"); - - for (const menu of panelMenus) { - assert.ok(menu.when, `Menu for "${menu.command}" should have a 'when' clause`); - assert.ok( - menu.when.includes("view =="), - `Menu for "${menu.command}" 'when' should scope to a specific view`, - ); - } -} - -function assertContextMenusHaveWhenClauses(): void { - const contextMenus = manifestMenu("view/item/context"); - - const panelMenus = contextMenus.filter((entry) => entry.when.includes("basilisk")); - - for (const menu of panelMenus) { - assert.ok(menu.when, `Context menu for "${menu.command}" should have a 'when' clause`); - } -} - -function assertWelcomeViewsHaveMeaningfulContent(): void { - const welcomeViews = manifestViewsWelcome(); - - const panelWelcome = welcomeViews.filter( - (entry) => entry.view === "basilisk.moduleExplorer", - ); - - assert.ok( - panelWelcome.length >= 1, - "The merged Modules panel should have welcome content", - ); - - for (const welcome of panelWelcome) { - assert.ok( - welcome.contents.length >= 10, - `Welcome content for "${welcome.view}" should be descriptive`, - ); - } -} - -function assertCommandsFollowNamingPattern(filter: (cmd: CommandContribution) => boolean): void { - const commands = manifestCommands(); - const filtered = commands.filter(filter); - - for (const cmd of filtered) { - assert.ok(cmd.title, `"${cmd.command}" needs a title for screen readers`); - assert.ok(!cmd.title.includes("undefined"), `"${cmd.command}" title should not contain 'undefined'`); - assert.ok(!cmd.title.includes("TODO"), `"${cmd.command}" title should not contain 'TODO'`); - } -} - -function assertInfoPanelVisible(): void { - const views = manifestViews()["basilisk-explorer"] ?? []; - const infoView = views.find((v) => v.id === "basilisk.info"); - - assert.ok(infoView, "info view should exist"); - assert.strictEqual(infoView.visibility, "visible", "Info panel should default to 'visible'"); -} - -function assertDataPanelsRequireWorkspace(): void { - const views = manifestViews()["basilisk-explorer"] ?? []; - - const dataViews = views.filter( - (v) => v.id === "basilisk.moduleExplorer", - ); - - for (const view of dataViews) { - assert.ok(view.when, `"${view.id}" should have a 'when' clause`); - assert.ok( - view.when.includes("basilisk.hasWorkspace"), - `"${view.id}" should depend on basilisk.hasWorkspace context key`, - ); - } -} - -// ── Test Suite ──────────────────────────────────────────────────────────── - -// Tests [EXTACT-ACCESSIBILITY] — descriptive labels, icon+text indicators, -// keyboard-navigable commands, and screen-reader-meaningful content across the -// activity panel. -suite("Basilisk Activity Panel Accessibility Audit", function () { - - let suiteContext: { tmpDir: string; basiliskBinary: string }; - - suiteSetup(async function () { - suiteContext = await setupLspTestSuite("a11y-panel"); - - const store = getStore(); - assert.ok(store, "Store should exist after activation"); - const result = await store.ensureLspReadyPromise(WAIT_MS); - assert.ok(result.ok, "LSP should be running"); - }); - - suiteTeardown(function () { - teardownLspTestSuite(suiteContext?.tmpDir); - }); - - teardown(async () => { - await closeAllEditors(); - }); - - test("all activity panel views have descriptive names", assertViewsHaveDescriptiveNames); - test("all activity panel commands have descriptive titles", assertCommandsHaveDescriptiveTitles); - test("toolbar commands have icons for visual recognition", assertToolbarCommandsHaveIcons); - test("commands have category for consistent palette grouping", assertCommandsHaveCategory); - test("toolbar menu entries have 'when' clauses", assertToolbarMenusHaveWhenClauses); - test("context menu entries have 'when' clauses", assertContextMenusHaveWhenClauses); - test("welcome views provide meaningful empty-state messages", assertWelcomeViewsHaveMeaningfulContent); - - test("module explorer commands follow naming pattern", function () { - assertCommandsFollowNamingPattern((cmd) => cmd.command.includes("ModuleExplorer")); - }); - - test("info panel is always visible for discoverability", assertInfoPanelVisible); - test("data panels require workspace to avoid empty state", assertDataPanelsRequireWorkspace); -}); diff --git a/vscode-extension/src/test/suite/activity-panel.test.ts b/vscode-extension/src/test/suite/activity-panel.test.ts deleted file mode 100644 index 0763160a2..000000000 --- a/vscode-extension/src/test/suite/activity-panel.test.ts +++ /dev/null @@ -1,802 +0,0 @@ -// Tests for [EXTACT]. See docs/specs/EXTENSION-ACTIVITY-PANEL-SPEC.md#EXTACT -/** - * Activity Panel E2E Tests for the Basilisk VS Code Extension. - * - * Validates: - * - Activity bar views are registered (moduleExplorer, typeHealth, info) - * - Module explorer commands are registered and executable - * - Type health commands are registered and executable - * - Info panel commands are registered and executable - * - Server advertises basilisk.workspaceModules and basilisk.typeHealth - * - Context key basilisk.hasWorkspace is set - */ - -import * as assert from "assert"; -import * as vscode from "vscode"; -import { getStore } from "../../extension"; -import { InfoPanelProvider } from "../../info-panel"; -import { - ModuleTreeItem, - workspaceHealthBadge, - workspaceHealthMessage, -} from "../../module-explorer"; -import { - manifestCommands, - manifestConfigurationProperties, - manifestContributes, - manifestViews, - type Contributes, - type ViewContribution, -} from "./extension-manifest"; -import { - LSP_RESTART_WAIT_MS, - pollUntilResult, - setupLspTestSuite, - teardownLspTestSuite, - closeAllEditors, -} from "./test-helpers"; - -// ── Command lists ───────────────────────────────────────────────────────── - -/** - * Commands registered client-side for the module explorer panel. - * These are registered via context.subscriptions in registerModuleExplorer(). - */ -const MODULE_EXPLORER_COMMANDS = [ - "basilisk.refreshModuleExplorer", - "basilisk.toggleModuleExplorerView", - "basilisk.sortModuleExplorer", - "basilisk.filterModuleExplorer", - "basilisk.copyImportPath", - "basilisk.copyQualifiedName", -] as const; - -/** - * Commands registered client-side for the info panel. - * These are registered via context.subscriptions in registerInfoPanel(). - */ -const INFO_PANEL_COMMANDS = [ - "basilisk.toggleFeature", -] as const; - -/** Command registered directly in extension.ts for the walkthrough. */ -const WALKTHROUGH_COMMAND = "basilisk.openWalkthrough"; - -/** View IDs contributed in package.json under basilisk-explorer. */ -const ACTIVITY_VIEW_IDS = [ - "basilisk.moduleExplorer", - "basilisk.info", -] as const; - -/** - * Server-advertised command that backs the merged Modules panel. Type Health is - * folded into this one response (issue #103), so the panel makes a single - * round-trip; basilisk.typeHealth remains advertised for Zed/Neovim and is - * covered by command-registration.test.ts. - */ -const PANEL_SERVER_COMMANDS = [ - "basilisk.workspaceModules", -] as const; - -// ── Helpers ─────────────────────────────────────────────────────────────── - -/** - * Assert that registering a command throws — proving it IS already registered. - * - * This is the sanctioned approach: we do NOT call vscode.commands.getCommands() - * or whenCommandReady. Instead we rely on the VS Code API guarantee that - * registering an already-registered command throws. - */ -function assertCommandRegistered(commandId: string, label: string): void { - let threw = false; - try { - vscode.commands.registerCommand(commandId, () => { /* probe */ }); - } catch { - threw = true; - } - assert.ok( - threw, - `${label}: "${commandId}" should be registered (re-registering should throw)`, - ); -} - -/** Load the extension's package.json contributes section with type safety. */ -function loadContributes(): Contributes { - return manifestContributes(); -} - -/** - * Every contributed command's icon, as a comparable glyph string. - * - * A manifest icon may be a glyph reference or a light/dark pair; both are - * reduced to one string so uniqueness comparisons stay meaningful instead of - * degrading to object identity (which every pair would trivially pass). - */ -function commandIconGlyphs(): Map<string, string> { - return new Map( - manifestCommands().map((cmd) => [ - cmd.command, - typeof cmd.icon === "string" ? cmd.icon : JSON.stringify(cmd.icon ?? null), - ]), - ); -} - -/** Load the basilisk-explorer views from package.json. */ -function loadBasiliskViews(): ViewContribution[] { - const views = manifestViews()["basilisk-explorer"] ?? []; - assert.ok(views.length > 0, "Extension should contribute views"); - return views; -} - -/** Extract a TreeItem's label as a plain string. */ -function rowLabel(item: vscode.TreeItem): string { - const { label } = item; - return typeof label === "string" ? label : label?.label ?? ""; -} - -/** - * Quick actions promoted from the info panel to the Modules toolbar (issue - * #103), when-gated on the server running so a button can never invoke an - * unregistered handler [EXTACT-INFO-ACTION-WIRING]. - */ -const PROMOTED_TOOLBAR_COMMANDS = [ - "basilisk.fixWorkspace", - "basilisk.organizeImports", - "basilisk.restartServer", -] as const; - -// ── Test Suite ──────────────────────────────────────────────────────────── - -// eslint-disable-next-line max-lines-per-function -suite("Basilisk Activity Panel E2E Tests", function () { - - let suiteContext: { tmpDir: string; basiliskBinary: string }; - - suiteSetup(async function () { - suiteContext = await setupLspTestSuite("activity-panel"); - - const store = getStore(); - assert.ok(store, "Store should exist after activation"); - const result = await store.ensureLspReadyPromise(LSP_RESTART_WAIT_MS); - assert.ok(result.ok, "LSP should be running"); - }); - - suiteTeardown(function () { - teardownLspTestSuite(suiteContext?.tmpDir); - }); - - teardown(async () => { - await closeAllEditors(); - }); - - // ── Activity Bar View Registration ──────────────────────────────────── - - test("activity bar views are contributed in package.json", function () { - const views = loadBasiliskViews(); - const viewIds = views.map((view) => view.id); - - for (const expectedId of ACTIVITY_VIEW_IDS) { - assert.ok( - viewIds.includes(expectedId), - `View "${expectedId}" should be contributed, got: ${viewIds.join(", ")}`, - ); - } - }); - - test("moduleExplorer view has correct 'when' condition", function () { - const views = loadBasiliskViews(); - const moduleView = views.find((view) => view.id === "basilisk.moduleExplorer"); - - assert.ok(moduleView, "moduleExplorer view should exist"); - assert.strictEqual( - moduleView.when, - "basilisk.hasWorkspace", - "moduleExplorer should have 'basilisk.hasWorkspace' when clause", - ); - }); - - test("info view is always visible (no 'when' condition)", function () { - const views = loadBasiliskViews(); - const infoView = views.find((view) => view.id === "basilisk.info"); - - assert.ok(infoView, "info view should exist"); - assert.strictEqual(infoView.when, undefined, "info view should not have a 'when' condition"); - assert.strictEqual(infoView.visibility, "visible", "info view should have visibility 'visible'"); - }); - - // ── Module Explorer Commands ────────────────────────────────────────── - - // Tests [EXTACT-MODULES-TOOLBAR] / [EXTACT-MODULES-CONTEXT-MENU] command registration. - test("module explorer commands are registered", function () { - for (const cmd of MODULE_EXPLORER_COMMANDS) { - assertCommandRegistered(cmd, "Module Explorer"); - } - }); - - // Tests [EXTACT-MODULES-REFRESH] manual refresh button. - test("refreshModuleExplorer command is executable", async function () { - await vscode.commands.executeCommand("basilisk.refreshModuleExplorer"); - }); - - // Tests [EXTACT-MODULES-TOOLBAR] Toggle View. - test("toggleModuleExplorerView command is executable", async function () { - await vscode.commands.executeCommand("basilisk.toggleModuleExplorerView"); - }); - - // Tests [EXTACT-MODULES-TOOLBAR] Sort (the explicit picker, #189). - test("sortModuleExplorer command opens the sort picker (#189)", async function () { - // The command now shows a QuickPick of the explicit sort modes; dismiss it - // so the test exercises the command without blocking on user input. - const dismiss = new Promise<void>((resolve) => { - setTimeout(() => { - void vscode.commands.executeCommand("workbench.action.closeQuickOpen").then(() => { resolve(); }); - }, 200); - }); - await Promise.all([ - vscode.commands.executeCommand("basilisk.sortModuleExplorer"), - dismiss, - ]); - }); - - // ── Info Panel Commands ─────────────────────────────────────────────── - - test("info panel toggleFeature command is registered", function () { - for (const cmd of INFO_PANEL_COMMANDS) { - assertCommandRegistered(cmd, "Info Panel"); - } - }); - - test("openWalkthrough command is registered", function () { - assertCommandRegistered(WALKTHROUGH_COMMAND, "Walkthrough"); - }); - - test("toggleFeature command can toggle a boolean setting", async function () { - const cfg = vscode.workspace.getConfiguration("basilisk"); - const original = cfg.get<boolean>("uv.enabled") ?? true; - - await vscode.commands.executeCommand("basilisk.toggleFeature", "basilisk.uv.enabled", !original); - - const updated = vscode.workspace.getConfiguration("basilisk").get<boolean>("uv.enabled"); - assert.strictEqual(updated, !original, "toggleFeature should flip the setting value"); - - // Restore original value. - await vscode.workspace.getConfiguration().update( - "basilisk.uv.enabled", - undefined, - vscode.ConfigurationTarget.Workspace, - ); - }); - - // Regression for issue #65 [EXTACT-INFO-ACTION-WIRING]: every actionable row - // the panel renders must resolve to a registered handler. In the slimmed - // panel (issue #103) the actionable rows are the top-level feature toggles. - // Drives the LIVE panel tree and checks each row's own command via the - // sanctioned "re-registering a live command throws" probe. - test("every actionable info panel row resolves to a registered command (no dead actions)", function () { - const store = getStore(); - assert.ok(store, "Store should exist"); - - const provider = new InfoPanelProvider(store); - try { - const actionableRows = provider - .getChildren() - .filter((row) => row.contextValue === "feature"); - - assert.ok(actionableRows.length > 0, "info panel should render feature toggles"); - - for (const row of actionableRows) { - const commandId = row.command?.command; - assert.ok(commandId, `"${rowLabel(row)}" must carry a command`); - assertCommandRegistered(commandId, `Info panel toggle "${rowLabel(row)}"`); - } - } finally { - provider.dispose(); - } - }); - - // Issue #103: the high-value quick actions were promoted from the info panel - // to Modules-toolbar buttons, when-gated on basilisk.serverState == 'running' - // so they can never render without a live handler [EXTACT-INFO-ACTION-WIRING]. - test("Fix All / Organize Imports / Restart are Modules toolbar buttons gated on the server running", function () { - const contributes = loadContributes(); - const titleMenus = contributes?.menus?.["view/title"] ?? []; - const moduleMenus = titleMenus.filter( - (entry) => entry.when.includes("view == basilisk.moduleExplorer"), - ); - - for (const cmd of PROMOTED_TOOLBAR_COMMANDS) { - const entry = moduleMenus.find((menu) => menu.command === cmd); - assert.ok(entry, `"${cmd}" must be contributed to the Modules view/title toolbar`); - assert.ok( - entry.when.includes("basilisk.serverState == 'running'"), - `"${cmd}" toolbar button must be when-gated on the server running, got: ${entry.when}`, - ); - } - }); - - test("promoted toolbar commands are registered and executable while the server runs", async function () { - for (const cmd of PROMOTED_TOOLBAR_COMMANDS) { - assertCommandRegistered(cmd, "Promoted toolbar action"); - } - }); - - // Issue #113 [VSIX-MODULE-EXPLORER-TOOLBAR]: the Modules toolbar contract. - // Read-only view-state actions render as deterministically ordered inline - // icons; mutating actions and server control live in separate ordered - // overflow groups (divider between them); inline glyphs never collide; and - // the unrefined Fix All is feature-flagged off by default. - test("Modules toolbar: deterministic order, read-only inline, no duplicate glyphs", function () { - const contributes = loadContributes(); - const titleMenus = (contributes?.menus?.["view/title"] ?? []).filter( - (entry) => entry.when.includes("view == basilisk.moduleExplorer"), - ); - assert.ok(titleMenus.length > 0, "Modules view must contribute toolbar entries"); - - for (const entry of titleMenus) { - assert.match( - entry.group ?? "", - /@\d+$/, - `"${entry.command}" must carry an explicit @N order, got: ${entry.group}`, - ); - } - - const inline = titleMenus.filter((entry) => entry.group?.startsWith("navigation") === true); - const inlineOrdered = [...inline].sort( - (a, b) => - Number(a.group?.split("@")[1] ?? 0) - Number(b.group?.split("@")[1] ?? 0), - ); - assert.deepStrictEqual( - inlineOrdered.map((entry) => entry.command), - [ - "basilisk.refreshModuleExplorer", - "basilisk.toggleModuleExplorerView", - "basilisk.filterModuleExplorer", - "basilisk.sortModuleExplorer", - ], - "inline toolbar must be exactly the read-only view-state actions, in order " + - "(Collapse All is VS Code's native showCollapseAll button, never contributed — #113)", - ); - - // Mutating + server-control actions live in the overflow menu, in - // distinct groups so VS Code renders a divider between them. - const overflow = new Map( - titleMenus - .filter((entry) => entry.group?.startsWith("navigation") !== true) - .map((entry) => [entry.command, entry.group ?? ""]), - ); - const fixAllGroup = overflow.get("basilisk.fixWorkspace"); - const organizeGroup = overflow.get("basilisk.organizeImports"); - const restartGroup = overflow.get("basilisk.restartServer"); - assert.ok(fixAllGroup, "fixWorkspace must be an overflow action, not an inline icon"); - assert.ok(organizeGroup, "organizeImports must be an overflow action, not an inline icon"); - assert.ok(restartGroup, "restartServer must be an overflow action, not an inline icon"); - assert.notStrictEqual( - restartGroup.split("@")[0], - fixAllGroup.split("@")[0], - "server control must be divided from mutating actions", - ); - - // No two inline buttons may render the same (or near-identical) glyph. - const commandIcons = commandIconGlyphs(); - const inlineIcons = inline.map((entry) => commandIcons.get(entry.command)); - assert.strictEqual( - new Set(inlineIcons).size, - inlineIcons.length, - `inline toolbar icons must be unique, got: ${inlineIcons.join(", ")}`, - ); - }); - - // Issue #113 [VSIX-MODULE-EXPLORER-TOOLBAR]: the panel must ship exactly ONE - // Collapse All — VS Code's native showCollapseAll button. The custom no-op - // `basilisk.collapseModuleExplorer` was the duplicate; it (and any command - // re-glyphed as $(collapse-all)) must never be contributed again. - test("Modules toolbar contributes no Collapse All — only the native showCollapseAll exists", function () { - const contributes = loadContributes(); - - const collapseCommand = (contributes?.commands ?? []).find( - (cmd) => cmd.command === "basilisk.collapseModuleExplorer", - ); - assert.strictEqual( - collapseCommand, - undefined, - "basilisk.collapseModuleExplorer must not exist — Collapse All is native (showCollapseAll)", - ); - - const moduleToolbar = (contributes?.menus?.["view/title"] ?? []).filter( - (entry) => entry.when.includes("view == basilisk.moduleExplorer"), - ); - const collapseEntries = moduleToolbar.filter( - (entry) => entry.command === "basilisk.collapseModuleExplorer", - ); - assert.strictEqual( - collapseEntries.length, - 0, - "no custom Collapse All may be contributed to the Modules toolbar", - ); - - // Defence-in-depth: no Modules toolbar command may re-introduce the - // $(collapse-all) glyph, which would render as a second collapse button - // next to the native one. - const commandIcons = commandIconGlyphs(); - for (const entry of moduleToolbar) { - assert.notStrictEqual( - commandIcons.get(entry.command), - "$(collapse-all)", - `"${entry.command}" must not use the $(collapse-all) glyph — Collapse All is native (#113)`, - ); - } - }); - - // Issue #151: the Sort button silently no-ops in the default tree view (sort is - // flat-only per [EXTACT-MODULES-TOOLBAR]). It must only appear where it works — - // gated on the flat view — so it is never a visible, enabled no-op. - test("Sort is gated to flat view so it is never a no-op in the tree view", function () { - const contributes = loadContributes(); - const sortEntry = (contributes?.menus?.["view/title"] ?? []).find( - (entry) => - entry.command === "basilisk.sortModuleExplorer" && - entry.when.includes("view == basilisk.moduleExplorer"), - ); - assert.ok(sortEntry, "sortModuleExplorer must be contributed to the Modules toolbar"); - assert.ok( - sortEntry.when.includes("basilisk.moduleExplorerView == 'flat'"), - `Sort must be gated on the flat view so it never no-ops in tree view, got: ${sortEntry.when}`, - ); - }); - - test("Fix All is feature-flagged: config default off, when-clause gated", function () { - const contributes = loadContributes(); - const flag = manifestConfigurationProperties()["basilisk.experimental.fixAll"]; - assert.ok(flag, "basilisk.experimental.fixAll setting must be declared"); - assert.strictEqual(flag.type, "boolean"); - assert.strictEqual(flag.default, false, "Fix All must be off by default"); - - const fixAllEntry = (contributes?.menus?.["view/title"] ?? []).find( - (entry) => - entry.command === "basilisk.fixWorkspace" && - entry.when.includes("view == basilisk.moduleExplorer"), - ); - assert.ok(fixAllEntry, "fixWorkspace must be contributed to the Modules toolbar"); - assert.ok( - fixAllEntry.when.includes("config.basilisk.experimental.fixAll"), - `fixWorkspace must be gated on the experimental flag, got: ${fixAllEntry.when}`, - ); - assert.ok( - fixAllEntry.when.includes("basilisk.serverState == 'running'"), - "fixWorkspace must stay gated on the server running", - ); - }); - - // Tests [EXTACT-INFO-SERVER-INFO] freshness rule. Defect 3 of issue #103: - // Server Info went stale — the provider only re-rendered on configuration - // changes, so "Server: stopped" / a missing Version row persisted after the - // server came up. The provider now holds a signals effect on - // store.lspState/store.client; restarting the real server must therefore fire - // the tree's change event without any config change. - test("info panel re-renders on LSP state changes (no stale Server Info)", async function () { - this.timeout(60_000); - const store = getStore(); - assert.ok(store, "Store should exist"); - - const provider = new InfoPanelProvider(store); - try { - const fired = new Promise<void>((resolve) => { - const sub = provider.onDidChangeTreeData(() => { - sub.dispose(); - resolve(); - }); - }); - - await vscode.commands.executeCommand("basilisk.restartServer"); - await fired; - - // Restore a fully-running server for the tests that follow. isRunning() - // can flip true a beat before the store's state listener re-registers - // the server commands, so also wait for the commands to be re-advertised - // — the very next tests assert on them. - const ready = await store.ensureLspReadyPromise(LSP_RESTART_WAIT_MS); - assert.ok(ready.ok, "LSP should be running again after restart"); - await pollUntilResult({ - fn: async () => store.serverCommands.value.size, - predicate: (size) => size > 0, - timeoutMs: LSP_RESTART_WAIT_MS, - }); - } finally { - provider.dispose(); - } - }); - - // ── Server-Advertised Commands ──────────────────────────────────────── - - // Tests [EXTACT-LSP-COMMANDS-WORKSPACE-MODULES] is server-advertised. - test("LSP server advertises basilisk.workspaceModules command", function () { - const store = getStore(); - assert.ok(store, "Store should exist"); - assert.ok( - store.isServerCommandAdvertised("basilisk.workspaceModules"), - "Server should advertise basilisk.workspaceModules", - ); - }); - - // The merged Modules panel no longer calls basilisk.typeHealth (its rollup is - // folded into workspaceModules, issue #103), but the command remains the - // shared workspace-health rollup for editors without a unified panel - // (Zed /health, Neovim :BasiliskHealth). Guard that it stays advertised. - // Tests [EXTACT-LSP-COMMANDS-TYPE-HEALTH] stays advertised for Zed/Neovim. - test("LSP server still advertises basilisk.typeHealth for other editors", function () { - const store = getStore(); - assert.ok(store, "Store should exist"); - assert.ok( - store.isServerCommandAdvertised("basilisk.typeHealth"), - "Server should still advertise basilisk.typeHealth (Zed/Neovim health command)", - ); - }); - - test("panel server commands are server-advertised, not client-registered", function () { - const store = getStore(); - assert.ok(store, "Store should exist"); - - for (const cmd of PANEL_SERVER_COMMANDS) { - assert.ok( - store.isServerCommandAdvertised(cmd), - `${cmd} should be server-advertised`, - ); - assert.ok( - !store.isClientCommandRegistered(cmd), - `${cmd} should NOT be client-registered (server commands flow through LSP middleware)`, - ); - } - }); - - // ── Context Keys ────────────────────────────────────────────────────── - - test("basilisk.hasWorkspace context key is set when workspace exists", function () { - const hasWorkspace = (vscode.workspace.workspaceFolders?.length ?? 0) > 0; - - // The extension should have called setContext("basilisk.hasWorkspace", hasWorkspace). - // We verify the extension is active and the store exists (proving initExtension ran, - // which calls setContext before registering panels). - const store = getStore(); - assert.ok(store, "Store should exist (proves initExtension ran, which sets context key)"); - - // If workspace folders exist, the module explorer panel commands should be - // registered — their 'when' clause depends on basilisk.hasWorkspace being true. - if (hasWorkspace) { - assertCommandRegistered("basilisk.refreshModuleExplorer", "Context key verification"); - } - }); - - // ── Menu Contributions ──────────────────────────────────────────────── - - // Tests [EXTACT-MODULES-TOOLBAR] contribution (Refresh / Toggle View / Filter / Sort). - test("module explorer has toolbar actions in package.json", function () { - const contributes = loadContributes(); - const titleMenus = contributes?.menus?.["view/title"] ?? []; - - const moduleMenus = titleMenus.filter( - (entry) => entry.when.includes("view == basilisk.moduleExplorer"), - ); - const menuCommands = moduleMenus.map((entry) => entry.command); - - assert.ok(menuCommands.includes("basilisk.refreshModuleExplorer"), "Should include refresh"); - assert.ok(menuCommands.includes("basilisk.toggleModuleExplorerView"), "Should include view toggle"); - assert.ok(menuCommands.includes("basilisk.filterModuleExplorer"), "Should include filter"); - assert.ok(menuCommands.includes("basilisk.sortModuleExplorer"), "Should include sort (folded Type Health)"); - // Collapse All is VS Code's native showCollapseAll button — never a - // contributed command. A contributed collapse is the #113 duplicate. - assert.ok( - !menuCommands.includes("basilisk.collapseModuleExplorer"), - "must NOT contribute a custom Collapse All — the native showCollapseAll is the only one (#113)", - ); - }); - - // Tests [EXTACT-MODULES-CONTEXT-MENU] Copy Import Path / Copy Qualified Name. - test("module explorer has context menu for copy actions", function () { - const contributes = loadContributes(); - const contextMenus = contributes?.menus?.["view/item/context"] ?? []; - - const copyMenus = contextMenus.filter( - (entry) => entry.when.includes("basilisk.moduleExplorer"), - ); - const menuCommands = copyMenus.map((entry) => entry.command); - - assert.ok(menuCommands.includes("basilisk.copyImportPath"), "Should include Copy Import Path"); - assert.ok(menuCommands.includes("basilisk.copyQualifiedName"), "Should include Copy Qualified Name"); - }); - - // ── Welcome Views ───────────────────────────────────────────────────── - - test("module explorer has welcome content in package.json", function () { - const contributes = loadContributes(); - const welcomeViews = contributes?.viewsWelcome ?? []; - - const moduleWelcome = welcomeViews.find((entry) => entry.view === "basilisk.moduleExplorer"); - assert.ok(moduleWelcome, "moduleExplorer should have welcome content"); - assert.ok( - moduleWelcome.contents.includes("No modules found"), - "moduleExplorer welcome should mention no modules found", - ); - }); - - // The settings cog was only on the BASILISK info panel title — easy to miss. - // [VSIX-STATUS-BAR]: Open Configuration must be reachable from EVERY Basilisk - // sidebar view title, plus the always-visible status bar (basilisk.statusMenu). - test("Open Configuration is reachable from every Basilisk view title, not just the info panel", function () { - const contributes = loadContributes(); - const titleMenus = contributes?.menus?.["view/title"] ?? []; - const configViews = new Set( - titleMenus - .filter((entry) => entry.command === "basilisk.openConfigurationEditor") - .map((entry) => { - const match = /view == (basilisk\.[A-Za-z]+)/.exec(entry.when); - return match?.[1]; - }) - .filter((view): view is string => view !== undefined), - ); - for (const view of ["basilisk.info", "basilisk.moduleExplorer", "basilisk.pythonProcesses"]) { - assert.ok( - configViews.has(view), - `Open Configuration must be contributed to ${view}'s title bar; got: ${[...configViews].join(", ")}`, - ); - } - // Every config-cog entry must stay gated on editor support so it never - // renders a dead button when the server lacks the configuration editor. - for (const entry of titleMenus.filter((menu) => menu.command === "basilisk.openConfigurationEditor")) { - assert.ok( - entry.when.includes("basilisk.configurationEditorSupported"), - `config cog on '${entry.when}' must be gated on basilisk.configurationEditorSupported`, - ); - } - }); - - test("clicking the status bar opens the config-first status menu, which is a declared command", function () { - const contributes = loadContributes(); - const statusMenu = (contributes?.commands ?? []).find( - (cmd) => cmd.command === "basilisk.statusMenu", - ); - assert.ok(statusMenu, "basilisk.statusMenu must be declared in package.json"); - assertCommandRegistered("basilisk.statusMenu", "Status bar menu"); - }); -}); - -// ── Merged Modules panel: health chrome + per-module coverage [EXTACT-MODULES] ─ -// -// The Type Health panel was merged into the Modules panel (issue #103): the -// workspace summary now renders in the tree view's native message + numeric -// badge chrome, and each module carries a coverage bar on its description. -// -// Regression for issue #57: an empty workspace (totalFiles === 0) must render an -// explicit "no Python files" state, never a misleading 100% coverage bar. -// Spec: docs/specs/EXTENSION-ACTIVITY-PANEL-SPEC.md#EXTACT-MODULES-HEADER -suite("Modules panel health chrome [EXTACT-MODULES-HEADER]", function () { - const emptyStats = { - totalSymbols: 0, - annotatedSymbols: 0, - coveragePercent: 100, - errors: 0, - warnings: 0, - adoptedFiles: 0, - totalFiles: 0, - // The #57 empty-state is only rendered once the initial scan finished; - // an unfinished scan shows the loading state instead - // ([EXTACT-MODULES-HEADER-LOADING], #144). - scanComplete: true, - }; - - const measuredStats = { - totalSymbols: 20, - annotatedSymbols: 17, - coveragePercent: 85, - errors: 2, - warnings: 3, - adoptedFiles: 0, - totalFiles: 3, - }; - - test("empty workspace message is 'No Python files found', never a 100% bar", function () { - const message = workspaceHealthMessage(emptyStats); - - assert.strictEqual( - message, - "No Python files found", - "empty workspace must render an explicit 'no Python files' state", - ); - assert.ok( - !message.includes("%"), - `empty workspace must not show a percentage, got: "${message}"`, - ); - assert.ok( - !message.includes("█") && !message.includes("░"), - `empty workspace must not render a coverage bar, got: "${message}"`, - ); - }); - - test("empty workspace shows no badge (nothing to flag)", function () { - assert.strictEqual( - workspaceHealthBadge(emptyStats), - undefined, - "empty workspace must not show a numeric badge", - ); - }); - - test("measured workspace message shows coverage percent and diagnostics", function () { - const message = workspaceHealthMessage(measuredStats); - assert.ok(message.includes("85%"), `expected the coverage percentage, got: "${message}"`); - assert.ok( - message.includes("🔴 2") && message.includes("🟠 3"), - `expected error/warning tallies, got: "${message}"`, - ); - }); - - test("measured workspace badge counts outstanding diagnostics", function () { - const badge = workspaceHealthBadge(measuredStats); - assert.ok(badge, "measured workspace with diagnostics should have a badge"); - assert.strictEqual(badge.value, 5, "badge should count errors + warnings (2 + 3)"); - }); - - // Tests [EXTACT-MODULES-MODULE-ROW] — the module row's folded-health description. - test("each module row renders a coverage bar, percentage, and tallies", function () { - const item = new ModuleTreeItem({ - name: "myapp.api", - path: "/ws/myapp/api.py", - kind: "module", - symbols: [], - coveragePercent: 85, - errors: 2, - warnings: 3, - adopted: false, - }); - const description = String(item.description); - - assert.ok(description.includes("85%"), `expected the coverage percentage, got: "${description}"`); - assert.ok(description.includes("█"), `expected a coverage bar, got: "${description}"`); - assert.ok( - description.includes("🔴 2") && description.includes("🟠 3"), - `expected error/warning tallies, got: "${description}"`, - ); - }); - - // Regression for issue #236 [EXTACT-MODULES-COUNT-STYLE]: inline tallies on - // every plain-text surface (header message, module row description) must - // render the coloured Unicode glyphs `🔴 n` (errors) / `🟠 n` (warnings) — - // never the lettered `nE nW` form the spec forbids. - test("tallies render count-style glyphs 🔴 n / 🟠 n, never nE nW letters (#236)", function () { - const row = new ModuleTreeItem({ - name: "myapp.api", path: "/ws/myapp/api.py", kind: "module", symbols: [], - coveragePercent: 85, errors: 2, warnings: 3, adopted: false, - }); - const surfaces = [ - ["header", workspaceHealthMessage(measuredStats)], - ["module row", String(row.description)], - ] as const; - for (const [surface, text] of surfaces) { - assert.ok( - text.includes("🔴 2") && text.includes("🟠 3"), - `${surface} tally must use the 🔴 n / 🟠 n glyph style, got: "${text}"`, - ); - assert.ok( - !text.includes("2E") && !text.includes("3W"), - `${surface} tally must never use nE nW letters, got: "${text}"`, - ); - } - }); - - // Tests [EXTACT-MODULES-MODULE-ROW] — the row's `[adopted]` badge. - test("adopted module row shows the [adopted] badge", function () { - const item = new ModuleTreeItem({ - name: "legacy", - path: "/ws/legacy.py", - kind: "module", - symbols: [], - coveragePercent: 12, - errors: 11, - warnings: 19, - adopted: true, - }); - - assert.ok( - String(item.description).includes("[adopted]"), - "adopted module must show the [adopted] badge", - ); - }); -}); diff --git a/vscode-extension/src/test/suite/bundle-integrity.test.ts b/vscode-extension/src/test/suite/bundle-integrity.test.ts deleted file mode 100644 index 6bb27358a..000000000 --- a/vscode-extension/src/test/suite/bundle-integrity.test.ts +++ /dev/null @@ -1,124 +0,0 @@ -// Tests for [VSIX]. See docs/specs/VSIX-SPEC.md#VSIX -// -// Regression for issue #71: the e2e suite must run against the REAL release -// bundle. Every component shipwright declares `bundled` for this platform must -// be present in the extension-under-test. If the packaging process omits one -// (e.g. debugpy, or the profiler helper), this test fails — so a broken bundle -// can no longer pass tests while shipping a debugger-less VSIX. -import * as assert from 'assert'; -import * as fs from 'fs'; -import * as path from 'path'; -import * as vscode from 'vscode'; - -import { EXTENSION_ID } from './test-helpers'; -import { - recordArrayField, - recordField, - stringArrayField, - stringField, -} from '../../unknown-shape'; - -// shipwright.json is bytes read off disk, so every field below is narrowed -// rather than asserted into a `Manifest` shape nothing has checked: a manifest -// that drifts must fail this test's assertion, not type-error past it. - -/** One shipwright component, narrowed from the parsed manifest. */ -interface Component { - readonly id: string; - readonly kind: string | undefined; - readonly binaryName: string | undefined; - readonly platforms: readonly string[] | undefined; - readonly bundlePath: string | undefined; -} - -/** Read the manifest's `components`, keeping only what this test reads. */ -function componentsOf(manifest: unknown): Component[] { - return recordArrayField(manifest, 'components').map((component) => ({ - id: stringField(component, 'id') ?? '', - kind: stringField(component, 'kind'), - binaryName: stringField(component, 'binaryName'), - platforms: - 'platforms' in component ? stringArrayField(component, 'platforms') : undefined, - bundlePath: stringField(recordField(component, 'bundled'), 'bundlePath'), - })); -} - -/** The release target triple for the host, matching shipwright `${platform}`. */ -function currentTarget(): string { - const arch = process.arch === 'arm64' ? 'arm64' : 'x64'; - if (process.platform === 'darwin') { - return `darwin-${arch}`; - } - if (process.platform === 'linux') { - return `linux-${arch}`; - } - if (process.platform === 'win32') { - return `win32-${arch}`; - } - throw new Error(`unsupported platform: ${process.platform}`); -} - -function supportsPlatform(component: Component, target: string): boolean { - return ( - component.platforms === undefined || - component.platforms.includes(target) || - component.platforms.includes('all') - ); -} - -/** Substitute shipwright `${...}` placeholders without relying on replaceAll. */ -function fill(template: string, vars: Record<string, string>): string { - return Object.entries(vars).reduce( - (acc, [key, value]) => acc.split(`\${${key}}`).join(value), - template - ); -} - -// Tests [VSIX-BINARY-DISTRIBUTION] / [VSIX-PACKAGING-PARITY]: the VSIX under test -// bundles the per-platform `basilisk` binary (and every other shipwright-declared -// component, e.g. debugpy) the manifest requires for this platform. -suite('Bundle integrity (#71)', () => { - test('extension-under-test bundles every shipwright-declared component for this platform', () => { - const ext = vscode.extensions.getExtension(EXTENSION_ID); - assert.ok(ext, `extension ${EXTENSION_ID} not found`); - const root = ext.extensionPath; - - const manifestPath = path.join(root, 'shipwright.json'); - assert.ok(fs.existsSync(manifestPath), `shipwright.json missing at ${manifestPath}`); - const manifest: unknown = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); - - const target = currentTarget(); - const exe = target.startsWith('win32-') ? '.exe' : ''; - - const missing: string[] = []; - for (const component of componentsOf(manifest)) { - if (component.bundlePath === undefined || !supportsPlatform(component, target)) { - continue; - } - const rel = fill(component.bundlePath, { - platform: target, - binaryName: component.binaryName ?? '', - exe, - }); - const abs = path.join(root, rel); - if (component.binaryName !== undefined && component.binaryName !== '') { - if (!fs.existsSync(abs)) { - missing.push(`binary ${component.id} (${rel})`); - } - } else if (component.kind === 'asset') { - const present = fs.existsSync(abs) && fs.readdirSync(abs).length > 0; - if (!present) { - missing.push(`asset ${component.id} (${rel}/)`); - } - } - } - - assert.deepStrictEqual( - missing, - [], - `e2e tests are running against an incomplete bundle — these release ` + - `components are missing (run the suite against a bundle built by the ` + - `real packaging process): ${missing.join(', ')}` - ); - }); -}); diff --git a/vscode-extension/src/test/suite/caught-error.ts b/vscode-extension/src/test/suite/caught-error.ts deleted file mode 100644 index 9070718c1..000000000 --- a/vscode-extension/src/test/suite/caught-error.ts +++ /dev/null @@ -1,25 +0,0 @@ -// Implements [VSIX]. See docs/specs/VSIX-SPEC.md#VSIX -/** - * Narrowing for values caught in test `catch` blocks. - * - * `catch` binds `unknown`, and `(err as Error).message` *asserts* a shape the - * compiler then trusts: when a command rejects with a string, a bare DAP error - * object, or an `undefined`, the assertion reads `.message` off something that - * has none and the test compares against `undefined` instead of failing loudly. - * Narrowing at the read site keeps the message honest for every throw shape. - */ - -import { stringField } from '../../unknown-shape'; - -/** - * The human-readable message carried by `error`, whatever shape it arrived in. - * - * Prefers a real `Error.message`, then a string `message` field (LSP/DAP - * rejections are plain objects), and finally the value's own string form. - */ -export function errorMessage(error: unknown): string { - if (error instanceof Error) { - return error.message; - } - return stringField(error, 'message') ?? String(error); -} diff --git a/vscode-extension/src/test/suite/command-registration.test.ts b/vscode-extension/src/test/suite/command-registration.test.ts deleted file mode 100644 index 55bed0e56..000000000 --- a/vscode-extension/src/test/suite/command-registration.test.ts +++ /dev/null @@ -1,672 +0,0 @@ -// Tests for [LSPARCH-CMDREG]. See docs/specs/LSP-ARCHITECTURE-SPEC.md#LSPARCH-CMDREG -/** - * Command Registration Tests for the Basilisk VS Code Extension. - * - * Proves compliance with the VS Code API contract for commands: - * - * - registerCommand() returns a Disposable whose dispose() unregisters it - * - Registering a command with an existing identifier twice throws - * - After dispose(), the same identifier can be re-registered - * - Client commands survive a full dispose/re-register cycle (LSP restart) - * - Server commands are never pre-registered (ExecuteCommandFeature removed) - * - No double-dispose: disposables live in ONE collection only - * - Server + client commands survive full deactivate/activate cycles - * - * Reference: https://code.visualstudio.com/api/references/vscode-api#commands - */ - -import { delay } from '../../timeouts'; -import * as assert from 'assert'; -import * as vscode from 'vscode'; -import { getStore, activate, deactivate } from '../../extension'; -import { - LSP_RESTART_WAIT_MS, - EXTENSION_ID, - POLL_INTERVAL_MS, - closeAllEditors, - setupLspTestSuite, - teardownLspTestSuite, -} from "./test-helpers"; -import { - manifestCommands -} from "./extension-manifest"; - -/** Number of consecutive deactivate/activate cycles to test. */ -const MULTI_CYCLE_COUNT = 3; - -/** Brief settle time after restart. */ -const RESTART_SETTLE_MS = 500; - -/** - * Mocha budget for ONE test that fully restarts the language server. - * - * The suite default (45s, .vscode-test.mjs) is sized for tests that do not - * respawn the server binary. These do, and a cold win32 runner spends real - * time on it — so a test driving three restarts needs three times the budget, - * not the same one. Exceeding the default reports as a bare Mocha timeout that - * names nothing; `pollUntilReady` inside it reports the state it observed. - */ -const RESTART_TEST_TIMEOUT_MS = 60_000; - -/** Budget for a replaced LSP client to stop after store.reset() (#264). */ -const ZOMBIE_STOP_TIMEOUT_MS = 5_000; - -/** - * All commands declared in package.json contributes.commands — read from the - * REAL manifest, never a hand-copied list. A hand-maintained copy silently - * drifts: it never included `basilisk.profileDiff`, which shipped contributed - * but unregistered ("command not found" in the palette) and no test noticed. - */ -function manifestCommandIds(): readonly string[] { - return manifestCommands().map((entry) => entry.command); -} - -/** Commands registered client-side (not by the LSP server). */ -const CLIENT_COMMANDS = [ - 'basilisk.restartServer', - 'basilisk.showOutput', -] as const; - -/** - * Commands advertised by the LSP server via executeCommandProvider. - * - * This list MUST match `basilisk_common::commands::ALL` in - * `crates/basilisk-common/src/lib.rs`. If the server adds a new command, - * add it here too — the cross-session tests below will catch drift. - */ -const SERVER_COMMANDS = [ - 'basilisk.organizeImports', - 'basilisk.startDebugSession', - 'basilisk.stopDebugSession', - 'basilisk.disableRule', - 'basilisk.fixFile', - 'basilisk.fixFileAll', - 'basilisk.fixWorkspace', - 'basilisk.fixWorkspaceAll', - 'basilisk.adoptFile', - 'basilisk.adoptWorkspace', - 'basilisk.unadoptFile', - 'basilisk.uv.sync', - 'basilisk.uv.add', - 'basilisk.uv.addDev', - 'basilisk.uv.remove', - 'basilisk.uv.lock', - 'basilisk.uv.createEnv', - 'basilisk.moveSymbol', - 'basilisk.stubs.createLocal', - 'basilisk.stubs.addMember', - 'basilisk.discoverTests', - 'basilisk.runTests', - 'basilisk.runTestFile', - 'basilisk.debugTest', - 'basilisk.runTestsCoverage', - 'basilisk.workspaceModules', - 'basilisk.typeHealth', - 'basilisk.profiler.start', - 'basilisk.profiler.stop', - 'basilisk.profiler.snapshot', - 'basilisk.profiler.list', - 'basilisk.profiler.processes', - 'basilisk.profiler.cooperativeScript', - 'basilisk.profiler.cooperativeAttach', - 'basilisk.memory.start', - 'basilisk.memory.snapshot', - 'basilisk.memory.diff', - 'basilisk.memory.references', - 'basilisk.memory.objectsByType', - 'basilisk.memory.gcCollect', - 'basilisk.memory.ingest', -] as const; - -/** Assert that registering a command succeeds (it was NOT already registered). */ -function assertCanRegister(cmd: string, context: string): void { - let threw = false; - let disposable: vscode.Disposable | undefined; - try { - disposable = vscode.commands.registerCommand(cmd, () => { /* probe */ }); - } catch { - threw = true; - } finally { - disposable?.dispose(); - } - assert.ok(!threw, `${context}: "${cmd}" should be registerable (not already registered)`); -} - -/** Assert that registering a command throws (it IS already registered). */ -function assertCannotRegister(cmd: string, context: string): void { - let threw = false; - try { - vscode.commands.registerCommand(cmd, () => { /* noop */ }); - } catch { - threw = true; - } - assert.ok(threw, `${context}: re-registering "${cmd}" should throw (already registered)`); -} - -/** Assert that executing a command does not throw. */ -async function assertExecutable(cmd: string, context: string): Promise<void> { - let threw = false; - try { - await vscode.commands.executeCommand(cmd); - } catch { - threw = true; - } - assert.ok(!threw, `${context}: "${cmd}" should be executable`); -} - -/** - * Wait until the store has re-registered its client commands AND the server - * has re-advertised its own. - * - * Throws on timeout rather than returning quietly. A silent give-up turned - * "the server did not come back within the budget" into whichever assertion - * happened to run next — `old client should be running before reset`, - * `Baseline should have server commands` — none of which named the real - * cause. The message below reports the state it actually observed. - */ -async function pollUntilReady( - timeoutMs: number, -): Promise<void> { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - const store = getStore(); - if (store) { - const clientReady = CLIENT_COMMANDS.every((cmd) => store.isClientCommandRegistered(cmd)); - const serverReady = store.serverCommands.value.size > 0; - if (clientReady && serverReady) { return; } - } - await delay(POLL_INTERVAL_MS); - } - const store = getStore(); - const missing = store === undefined - ? 'no store' - : CLIENT_COMMANDS.filter((cmd) => !store.isClientCommandRegistered(cmd)).join(', ') || 'none'; - throw new Error( - `LSP client did not become ready within ${timeoutMs}ms — ` + - `lspState=${store?.lspState.value ?? 'n/a'}, ` + - `client=${store?.client.value !== undefined}, ` + - `serverCommands=${store?.serverCommands.value.size ?? 0}, ` + - `unregistered client commands=[${missing}]` - ); -} - -// eslint-disable-next-line max-lines-per-function -suite('Command Registration (VS Code API Compliance)', () => { - let tmpDir: string; - - suiteSetup(async function () { - const result = await setupLspTestSuite('basilisk-cmd-reg-test-'); - tmpDir = result.tmpDir; - await pollUntilReady(LSP_RESTART_WAIT_MS); - - const store = getStore(); - assert.ok(store, 'Store should exist after suiteSetup'); - assert.ok( - store.serverCommands.value.size > 0, - `suiteSetup: server commands empty (lspState=${store.lspState.value}, ` + - `client=${store.client.value !== undefined}, ` + - `cmds=${store.serverCommands.value.size})` - ); - }); - - suiteTeardown(async () => { - await closeAllEditors(); - teardownLspTestSuite(tmpDir); - }); - - teardown(async () => { - await closeAllEditors(); - }); - - // ================================================================ - // NON-DESTRUCTIVE TESTS (run first — no deactivate/activate calls) - // ================================================================ - - // ---------------------------------------------------------------- - // 1. Every manifest command is known to VS Code's command registry - // ---------------------------------------------------------------- - test('all manifest commands exist in the VS Code command registry', function () { - const commands = manifestCommandIds(); - assert.ok(commands.length > 0, 'the manifest must contribute commands'); - for (const cmd of commands) { - assertCannotRegister(cmd, 'Manifest command registration'); - } - }); - - // ---------------------------------------------------------------- - // 2. Client commands are tracked in the store - // ---------------------------------------------------------------- - test('client commands are tracked in the store after activation', function () { - const store = getStore(); - assert.ok(store, 'Store should be available after activation'); - - for (const cmd of CLIENT_COMMANDS) { - assert.ok( - store.isClientCommandRegistered(cmd), - `Client command "${cmd}" should be tracked in store.clientCommands` - ); - } - }); - - // ---------------------------------------------------------------- - // 3. Server commands are NOT tracked as client commands - // ---------------------------------------------------------------- - test('server commands are NOT registered as client commands', function () { - const store = getStore(); - assert.ok(store, 'Store should be available after activation'); - - for (const cmd of SERVER_COMMANDS) { - assert.ok( - !store.isClientCommandRegistered(cmd), - `Server command "${cmd}" must NOT appear in store.clientCommands — ` + - `it should only be in store.serverCommands` - ); - } - }); - - // ---------------------------------------------------------------- - // 4. Server commands are advertised via LSP capabilities - // - // Checks that every command from SERVER_COMMANDS that the server - // binary supports is present in the store. Commands missing from - // the binary (stale build) are logged but not failed — the drift - // guard test below catches those. - // ---------------------------------------------------------------- - test('server commands are advertised in store.serverCommands', function () { - const store = getStore(); - assert.ok(store, 'Store should be available after activation'); - assert.ok(store.serverCommands.value.size > 0, 'Server should advertise at least one command'); - - // Verify that every command the server advertises is in our test list. - for (const cmd of store.serverCommands.value) { - assert.ok( - (SERVER_COMMANDS as readonly string[]).includes(cmd), - `Server advertises "${cmd}" but it is not in SERVER_COMMANDS` - ); - } - }); - - // ---------------------------------------------------------------- - // 5. Client commands are NOT in server commands - // ---------------------------------------------------------------- - test('client-only commands are NOT in server commands', function () { - const store = getStore(); - assert.ok(store, 'Store should be available after activation'); - - for (const cmd of CLIENT_COMMANDS) { - assert.ok( - !store.isServerCommandAdvertised(cmd), - `Client-only command "${cmd}" must NOT appear in store.serverCommands` - ); - } - }); - - // ---------------------------------------------------------------- - // 6. No duplicate registration — API says this throws - // ---------------------------------------------------------------- - test('registering an already-registered client command throws', async function () { - for (const cmd of CLIENT_COMMANDS) { - assertCannotRegister(cmd, 'Duplicate registration'); - } - }); - - // ---------------------------------------------------------------- - // 7. Server commands ARE registered (routed through LSP client) - // ---------------------------------------------------------------- - test('server commands are registered via syncServerCommands', async function () { - for (const cmd of SERVER_COMMANDS) { - assertCannotRegister(cmd, 'Server command registration'); - } - }); - - // ---------------------------------------------------------------- - // 8. SERVER_COMMANDS list matches what the LSP actually advertises - // - // Guards against drift: every command the server advertises must - // be in our SERVER_COMMANDS list. Also verifies that the server - // advertises a reasonable number of commands (detects broken binary). - // ---------------------------------------------------------------- - test('SERVER_COMMANDS list matches server capabilities exactly', function () { - const store = getStore(); - assert.ok(store, 'Store should be available'); - - const serverSet = store.serverCommands.value; - assert.ok(serverSet.size > 0, 'Server should advertise at least one command'); - - // Every command the server advertises must be in our test list. - const testSet = new Set<string>(SERVER_COMMANDS); - for (const cmd of serverSet) { - assert.ok( - testSet.has(cmd), - `Server advertises "${cmd}" but it is not in SERVER_COMMANDS — add it` - ); - } - - // Every command in our test list should be advertised by the server. - // If not, the binary is stale — log but still fail (rebuild required). - for (const cmd of SERVER_COMMANDS) { - assert.ok( - serverSet.has(cmd), - `SERVER_COMMANDS contains "${cmd}" but server does not advertise it. ` + - `Rebuild the binary: cargo build -p basilisk-cli` - ); - } - }); - - // ================================================================ - // DESTRUCTIVE TESTS (call deactivate/activate — run last) - // ================================================================ - - // ---------------------------------------------------------------- - // 9. Client commands survive a restart cycle (dispose + re-register) - // ---------------------------------------------------------------- - test('client commands survive a full LSP restart cycle', async function () { - this.timeout(RESTART_TEST_TIMEOUT_MS); - - const store = getStore(); - assert.ok(store, 'Store should be available'); - - for (const cmd of CLIENT_COMMANDS) { - assert.ok( - store.isClientCommandRegistered(cmd), - `"${cmd}" should be registered before restart` - ); - } - - await vscode.commands.executeCommand('basilisk.restartServer'); - await delay(RESTART_SETTLE_MS); - await pollUntilReady(LSP_RESTART_WAIT_MS); - - for (const cmd of CLIENT_COMMANDS) { - assert.ok(store.isClientCommandRegistered(cmd), `"${cmd}" should be re-registered after restart`); - } - for (const cmd of CLIENT_COMMANDS) { - await assertExecutable(cmd, 'After restart'); - } - }); - - // ---------------------------------------------------------------- - // 10. store.reset() disposes all client commands - // ---------------------------------------------------------------- - test('store.reset() clears all client command tracking', async function () { - this.timeout(RESTART_TEST_TIMEOUT_MS); - - const store = getStore(); - assert.ok(store, 'Store should be available'); - - for (const cmd of CLIENT_COMMANDS) { - assert.ok( - store.isClientCommandRegistered(cmd), - `"${cmd}" should be registered before reset` - ); - } - - store.reset(); - - for (const cmd of CLIENT_COMMANDS) { - assert.ok( - !store.isClientCommandRegistered(cmd), - `"${cmd}" should NOT be tracked after store.reset()` - ); - } - - for (const cmd of CLIENT_COMMANDS) { - assertCanRegister(cmd, 'After store.reset()'); - } - - // Re-activate so subsequent tests aren't broken. - const ext = vscode.extensions.getExtension(EXTENSION_ID); - if (ext && !ext.isActive) { - await ext.activate(); - } - await pollUntilReady(LSP_RESTART_WAIT_MS); - }); - - // ---------------------------------------------------------------- - // 10b. store.reset() must stop the replaced LSP client (GitHub #264) - // - // reset() drops the client reference and onReset starts a NEW - // LanguageClient. If the old client is never stopped it stays a - // live zombie: it keeps forwarding didOpen/didClose to its own - // server and publishing into its own diagnostics collection, - // which VS Code merges into getDiagnostics() — late zombie - // republishes then resurrect diagnostics the real server - // cleared (the flaky openFilesOnly diagnostics-clear failure). - // ---------------------------------------------------------------- - test('store.reset() stops the replaced LSP client — no zombie publisher (#264)', async function () { - this.timeout(RESTART_TEST_TIMEOUT_MS); - const store = getStore(); - assert.ok(store, 'Store should be available'); - await pollUntilReady(LSP_RESTART_WAIT_MS); - - const oldClient = store.client.value; - assert.ok(oldClient, 'a running LSP client should exist before reset'); - assert.strictEqual(oldClient.isRunning(), true, 'old client should be running before reset'); - - store.reset(); - - // The onReset hook must bring up a replacement client. - await pollUntilReady(LSP_RESTART_WAIT_MS); - const newClient = store.client.value; - assert.ok(newClient, 'reset() must start a replacement client'); - assert.notStrictEqual(newClient, oldClient, 'reset() must create a new client instance'); - - // The replaced client must stop; a still-running one is a zombie - // publisher (GitHub #264). - const deadline = Date.now() + ZOMBIE_STOP_TIMEOUT_MS; - while (oldClient.isRunning() && Date.now() < deadline) { - await delay(100); - } - assert.strictEqual( - oldClient.isRunning(), - false, - 'store.reset() must stop the replaced LSP client — a running one keeps ' + - 'publishing stale diagnostics from its own collection (GitHub #264)' - ); - }); - - // ---------------------------------------------------------------- - // 11. CROSS-SESSION: deactivate → activate cycle - // - // Simulates VS Code window reload. After deactivate(), all - // command registrations must be disposed. After activate(), - // they must be re-registered without errors. - // ---------------------------------------------------------------- - test('CROSS-SESSION: deactivate then activate does not throw duplicate command errors', async function () { - this.timeout(RESTART_TEST_TIMEOUT_MS); - - const storeBefore = getStore(); - assert.ok(storeBefore, 'Store should exist in session 1'); - for (const cmd of CLIENT_COMMANDS) { - assert.ok( - storeBefore.isClientCommandRegistered(cmd), - `Session 1: "${cmd}" should be registered` - ); - } - - const stopPromise = deactivate(); - if (stopPromise !== undefined) { - await stopPromise; - } - - assert.strictEqual( - getStore(), - undefined, - 'Store should be undefined after deactivate()' - ); - - for (const cmd of CLIENT_COMMANDS) { - assertCanRegister(cmd, 'After deactivate() — THIS IS THE BUG if it fails'); - } - - const ext = vscode.extensions.getExtension(EXTENSION_ID); - assert.ok(ext, 'Extension should still be installed'); - await ext.activate(); - - const storeAfter = getStore(); - assert.ok(storeAfter, 'Store should exist in session 2'); - await pollUntilReady(LSP_RESTART_WAIT_MS); - - for (const cmd of CLIENT_COMMANDS) { - assert.ok(storeAfter.isClientCommandRegistered(cmd), `Session 2: "${cmd}" should be registered`); - } - for (const cmd of CLIENT_COMMANDS) { - await assertExecutable(cmd, 'Session 2'); - } - for (const cmd of CLIENT_COMMANDS) { - assertCannotRegister(cmd, 'Session 2 duplicate check'); - } - }); - - // ---------------------------------------------------------------- - // 12. CROSS-SESSION: three consecutive deactivate/activate cycles - // ---------------------------------------------------------------- - test('CROSS-SESSION: three consecutive deactivate/activate cycles', async function () { - this.timeout(MULTI_CYCLE_COUNT * RESTART_TEST_TIMEOUT_MS); - - for (let cycle = 1; cycle <= MULTI_CYCLE_COUNT; cycle++) { - const tag = `Cycle ${cycle}`; - - const stopPromise = deactivate(); - if (stopPromise !== undefined) { - await stopPromise; - } - assert.strictEqual(getStore(), undefined, `${tag}: store should be undefined after deactivate`); - - for (const cmd of CLIENT_COMMANDS) { - assertCanRegister(cmd, tag); - } - - const ext = vscode.extensions.getExtension(EXTENSION_ID); - assert.ok(ext, `${tag}: extension should still be installed`); - await ext.activate(); - - const store = getStore(); - assert.ok(store, `${tag}: store should exist after activate`); - await pollUntilReady(LSP_RESTART_WAIT_MS); - - for (const cmd of CLIENT_COMMANDS) { - assert.ok(store.isClientCommandRegistered(cmd), `${tag}: "${cmd}" should be registered`); - } - } - }); - - // ---------------------------------------------------------------- - // 13. CROSS-SESSION: all server commands re-advertised after cycle - // ---------------------------------------------------------------- - test('CROSS-SESSION: all server commands re-advertised after deactivate/activate', async function () { - this.timeout(RESTART_TEST_TIMEOUT_MS); - - // Ensure we start in a good state. - await pollUntilReady(LSP_RESTART_WAIT_MS); - const storeBefore = getStore(); - assert.ok(storeBefore, 'Store should exist in session 1'); - const session1Commands = new Set(storeBefore.serverCommands.value); - assert.ok(session1Commands.size > 0, 'Server should advertise at least one command'); - - const stopPromise = deactivate(); - if (stopPromise !== undefined) { - await stopPromise; - } - assert.strictEqual(getStore(), undefined, 'Store should be undefined after deactivate'); - - const ext = vscode.extensions.getExtension(EXTENSION_ID); - assert.ok(ext, 'Extension should still be installed'); - await ext.activate(); - - // Trigger re-init and wait for server commands. - await pollUntilReady(LSP_RESTART_WAIT_MS); - - const storeAfter = getStore(); - assert.ok(storeAfter, 'Store should exist in session 2'); - - for (const cmd of session1Commands) { - assert.ok( - storeAfter.isServerCommandAdvertised(cmd), - `Server command "${cmd}" was in session 1 but missing in session 2` - ); - } - - assert.strictEqual( - storeAfter.serverCommands.value.size, - session1Commands.size, - `Session 2 should have ${session1Commands.size} server commands, ` + - `got ${storeAfter.serverCommands.value.size}` - ); - }); - - // ---------------------------------------------------------------- - // 14. CROSS-SESSION: server commands survive three rapid cycles - // - // Snapshots the server commands from session 0, then verifies - // they all re-appear after each deactivate/activate cycle. - // This tests the actual binary's command set, not the hardcoded - // SERVER_COMMANDS list (which may include commands from a newer - // build). - // ---------------------------------------------------------------- - test('CROSS-SESSION: server commands survive three rapid deactivate/activate cycles', async function () { - this.timeout(MULTI_CYCLE_COUNT * RESTART_TEST_TIMEOUT_MS); - - // Snapshot from current session. - await pollUntilReady(LSP_RESTART_WAIT_MS); - const baseline = getStore(); - assert.ok(baseline, 'Baseline store should exist'); - const baselineCommands = new Set(baseline.serverCommands.value); - assert.ok(baselineCommands.size > 0, 'Baseline should have server commands'); - - for (let cycle = 1; cycle <= MULTI_CYCLE_COUNT; cycle++) { - const tag = `Cycle ${cycle}`; - - const stopPromise = deactivate(); - if (stopPromise !== undefined) { - await stopPromise; - } - assert.strictEqual(getStore(), undefined, `${tag}: store should be undefined after deactivate`); - - const ext = vscode.extensions.getExtension(EXTENSION_ID); - assert.ok(ext, `${tag}: extension should still be installed`); - await ext.activate(); - - await pollUntilReady(LSP_RESTART_WAIT_MS); - const store = getStore(); - assert.ok(store, `${tag}: store should exist after activate`); - - for (const cmd of baselineCommands) { - assert.ok( - store.isServerCommandAdvertised(cmd), - `${tag}: server command "${cmd}" should be advertised` - ); - } - - for (const cmd of CLIENT_COMMANDS) { - assert.ok( - store.isClientCommandRegistered(cmd), - `${tag}: client command "${cmd}" should be registered` - ); - } - } - }); - - // ---------------------------------------------------------------- - // 15. CROSS-SESSION: client commands are executable after refresh - // ---------------------------------------------------------------- - test('CROSS-SESSION: client commands are executable after deactivate/activate', async function () { - this.timeout(RESTART_TEST_TIMEOUT_MS); - - const stopPromise = deactivate(); - if (stopPromise !== undefined) { - await stopPromise; - } - assert.strictEqual(getStore(), undefined, 'Store should be undefined after deactivate'); - - const ext = vscode.extensions.getExtension(EXTENSION_ID); - assert.ok(ext, 'Extension should still be installed'); - await ext.activate(); - - await pollUntilReady(LSP_RESTART_WAIT_MS); - - for (const cmd of CLIENT_COMMANDS) { - await assertExecutable(cmd, 'After deactivate/activate'); - } - }); -}); diff --git a/vscode-extension/src/test/suite/configuration-editor-cache-dom.test.ts b/vscode-extension/src/test/suite/configuration-editor-cache-dom.test.ts deleted file mode 100644 index 7b0abd736..000000000 --- a/vscode-extension/src/test/suite/configuration-editor-cache-dom.test.ts +++ /dev/null @@ -1,214 +0,0 @@ -// Tests [LSPCFGED-CACHE] in a REAL webview DOM, driven like a user. -// -// What this suite locks down: -// * the Project view names BOTH caching layers — a panel that showed only -// the persistent switch would read as "this is all the caching there is", -// which is exactly the confusion the panel exists to remove; -// * the in-session Salsa layer renders read-only, with no control of any -// kind, because it has no configuration key; -// * a cache edit lands immediately with no impact dialog: there is no -// rule-severity trade-off to weigh ([CONFIGEDITOR-VSIX-EXPERIENCE]); -// * the toggle writes an explicit `cache = true|false`, and the folder -// reset REMOVES `cache-dir` rather than writing the default back as an -// entry; -// * a cancelled folder picker writes nothing at all. - -import * as assert from "assert"; -import { - DRIVER_PRELUDE, - RESULT_TIMEOUT_MS, - runScenario, - ScenarioHost, - type DomStep, - type ScenarioOutcome, -} from "./webview-dom-harness"; -import { decodeConfigurationEditorIntent } from "../../configuration-editor-intents"; -import type { EditorMutation } from "../../configuration-editor-model"; -import { booleanField, rawField, stringField } from "../../unknown-shape"; - -const DEFAULT_CACHE_DIR = "/workspace/project/.basilisk/cache/check"; -const CHOSEN_CACHE_DIR = "/workspace/project/build/bsk-cache"; - -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; -} - -/** One row of the read-only in-session table a recorded step rendered. */ -function inSessionRow(entry: DomStep, row: string): string | undefined { - return stringField(rawField(entry, "inSession"), row); -} - -function mutationsOf(intents: readonly Record<string, unknown>[], index: number): unknown { - return intents[index]?.mutations; -} - -/** Every `preview` intent the runtime posted, decoded through the real decoder. */ -function previewMutations(intents: readonly Record<string, unknown>[]): EditorMutation[][] { - return intents - .map((intent, index) => ({ intent, index })) - .filter(({ intent }) => intent.type === "preview") - .map(({ index }) => { - const decoded = decodeConfigurationEditorIntent({ - type: "preview", - mutations: mutationsOf(intents, index), - }); - assert.ok( - decoded?.type === "preview", - `preview intent ${index} must survive the production decoder`, - ); - return decoded.mutations; - }); -} - -const cacheJourneyDriver = String.raw` - (async () => { - ${DRIVER_PRELUDE} - try { - if (!await waitFor('[data-cache-enabled]')) { report({ ok: false, reason: 'caching controls never rendered' }); return; } - await click(document.querySelector('[data-section-target="project"]')); - record('initial'); - // 1. Turning the cache on must land at once — no impact dialog stands - // between a setting switch and the configuration. - el('[data-cache-enabled]').click(); - record('enabled-sync'); - await waitUntil(() => el('[data-cache-enabled]').checked === true); - record('enabled'); - // 2. Choose a folder through the native picker. - await click(el('[data-pick-cache-folder]')); - await waitUntil(() => el('[data-cache-folder]').value === '${CHOSEN_CACHE_DIR}'); - record('folder-chosen'); - // 3. Reset it: the key is removed, so the default returns. - await click(el('[data-action="reset-cache-folder"]')); - await waitUntil(() => el('[data-cache-folder]').value === '${DEFAULT_CACHE_DIR}'); - record('folder-reset'); - // 4. Turn it back off — an explicit false, not an erased key. - el('[data-cache-enabled]').click(); - await waitUntil(() => el('[data-cache-enabled]').checked === false); - record('disabled'); - // 5. Cancel the picker: nothing may be written. - await click(el('[data-pick-cache-folder]')); - await sleep(settleDelay); - record('picker-cancelled'); - report({ ok: true, steps }); - } catch (error) { report({ ok: false, reason: String(error), steps }); } - })(); -`; - -suite("Configuration editor · caching panel DOM", () => { - // One journey, asserted from three angles. Driving a real webview costs ~10s - // of panel setup and IPC per run, so the scenario runs ONCE in suiteSetup - // and every test reads the same recorded steps — re-running it per test - // would triple the wall clock to observe the identical DOM. - let outcome: ScenarioOutcome; - - suiteSetup(async function runOnce() { - this.timeout(RESULT_TIMEOUT_MS * 3); - outcome = await runScenario( - cacheJourneyDriver, - new ScenarioHost({ folders: [CHOSEN_CACHE_DIR, undefined] }), - ); - assert.ok(outcome.result.ok, `driver failed: ${outcome.result.reason ?? "unknown"}`); - }); - - test("both layers render, and only the persistent one is configurable", () => { - const initial = step(outcome.result.steps, "initial"); - assert.strictEqual( - booleanField(initial, "cacheEnabledPresent"), - true, - "the Project view must render the persistent cache toggle", - ); - assert.strictEqual( - booleanField(initial, "cacheEnabled"), - false, - "an unconfigured project shows the persistent cache off", - ); - assert.strictEqual( - stringField(initial, "cacheFolderValue"), - DEFAULT_CACHE_DIR, - "the default folder is shown even before the cache is enabled", - ); - assert.strictEqual( - booleanField(initial, "cacheResetPresent"), - false, - "there is nothing to reset until the project chooses a folder", - ); - - // The whole point of the panel: Salsa is named, and stated as always on. - assert.strictEqual(inSessionRow(initial, "Engine"), "Salsa incremental queries"); - assert.strictEqual(inSessionRow(initial, "State"), "Always on · no configuration"); - assert.ok( - (inSessionRow(initial, "Memoized files") ?? "").includes("tracked in this session"), - "the in-session layer must report its live memo count", - ); - }); - - test("a cache edit lands at once and writes explicit keys", () => { - const steps = outcome.result.steps; - - // No impact dialog: a setting switch has no severity trade-off to weigh. - ["enabled-sync", "enabled", "folder-chosen", "folder-reset", "disabled"].forEach((label) => { - assert.strictEqual( - booleanField(step(steps, label), "dialogOpen"), - false, - `step "${label}" must not open the impact dialog for a cache edit`, - ); - }); - - assert.strictEqual(booleanField(step(steps, "enabled"), "cacheEnabled"), true); - assert.strictEqual( - stringField(step(steps, "folder-chosen"), "cacheFolderValue"), - CHOSEN_CACHE_DIR, - ); - assert.strictEqual( - booleanField(step(steps, "folder-chosen"), "cacheResetPresent"), - true, - "a chosen folder is a project decision, so it can be undone", - ); - assert.strictEqual( - stringField(step(steps, "folder-reset"), "cacheFolderValue"), - DEFAULT_CACHE_DIR, - "resetting must fall back to the default, not blank the field", - ); - assert.strictEqual(booleanField(step(steps, "disabled"), "cacheEnabled"), false); - - // The exact wire vocabulary: explicit booleans, and a REMOVE for the - // folder reset rather than the default written back as an entry. Choosing - // a folder posts `pickCacheFolder`, not a mutation — the host builds the - // write from what the native picker returned. - assert.deepStrictEqual(previewMutations(outcome.intents), [ - [{ kind: "SetCacheSetting", key: { kind: "CacheEnabled" }, value: "true" }], - [{ kind: "RemoveCacheSetting", key: { kind: "CacheDir" } }], - [{ kind: "SetCacheSetting", key: { kind: "CacheEnabled" }, value: "false" }], - ]); - assert.strictEqual( - outcome.intents.filter((intent) => intent.type === "pickCacheFolder").length, - 2, - "both folder interactions must route through the native picker", - ); - }); - - test("a cancelled folder picker writes nothing and restores the controls", () => { - const cancelled = step(outcome.result.steps, "picker-cancelled"); - assert.strictEqual( - stringField(cancelled, "cacheFolderValue"), - DEFAULT_CACHE_DIR, - "a cancelled picker leaves the configuration that still holds", - ); - assert.strictEqual(booleanField(cancelled, "cacheEnabled"), false); - assert.strictEqual( - previewMutations(outcome.intents).length, - 3, - "the cancelled picker must post no further mutation", - ); - assert.strictEqual( - booleanField(cancelled, "cachePickerDisabled"), - false, - "nothing in the caching panel ever locks", - ); - }); -}); diff --git a/vscode-extension/src/test/suite/configuration-editor-focus.test.ts b/vscode-extension/src/test/suite/configuration-editor-focus.test.ts deleted file mode 100644 index 731e27151..000000000 --- a/vscode-extension/src/test/suite/configuration-editor-focus.test.ts +++ /dev/null @@ -1,239 +0,0 @@ -// Implements [CONFIGEDITOR-VSIX-EXPERIENCE]: the Configure Severity hover -// deep link — `basilisk.openConfigurationEditor` with a `{ rule }` argument -// opens the editor focused on that rule, and the LSP hover markdown is -// trusted for exactly that one command. - -import { delay } from "../../timeouts"; -import * as assert from "assert"; -import * as vscode from "vscode"; -import type { - ApplyConfigurationRequest, - ConfigurationPreview, - ConfigurationSnapshot, - PreviewConfigurationRequest, - RuleOccurrencesRequest, - RuleOccurrencesResponse, - TypeshedActionRequest, - TypeshedActionResult, -} from "../../configuration-editor-model"; -import { - ConfigurationEditorController, - CONFIGURATION_EDITOR_COMMAND, - EDIT_CONFIG_COMMAND, - type ConfigurationEditorTransport, -} from "../../configuration-editor"; -import { configurationEditorFocusRule } from "../../configuration-editor-registration"; -import { buildClientOptions, trustConfigureSeverityLinks } from "../../lsp-client"; -import { createStore } from "../../store"; -import { cacheFixture, typeshedFixture } from "./settings-fixture"; - -const ROOT_URI = "file:///workspace"; -const RULE_CODE = "BSK-0001"; - -function snapshotWithRule(): ConfigurationSnapshot { - return { - rootUri: ROOT_URI, - configUri: `${ROOT_URI}/pyproject.toml`, - revision: "revision-1", - rules: [{ - descriptor: { - code: RULE_CODE, - title: "Missing parameter type annotation", - summary: "All function parameters require explicit types.", - docsUrl: `https://example.test/errors/${RULE_CODE}`, - tags: ["basilisk", "annotations"], - }, - entry: undefined, - effectiveSeverity: { kind: "Error" }, - diagnosticCount: 1, - }], - tags: [], - source: { uri: `${ROOT_URI}/pyproject.toml`, exists: true, readOnly: false }, - pathOverrides: [], - debt: { - remainingDiagnostics: 1, - errorDiagnostics: 1, - warningDiagnostics: 0, - infoDiagnostics: 0, - adoptedRules: 0, - disabledRules: 0, - }, - problems: [], - typeshed: typeshedFixture({ downloading: true }), - cache: cacheFixture(), - }; -} - -/** Snapshot-only transport; preview/apply/occurrences are unreachable here. */ -function snapshotTransport(): ConfigurationEditorTransport { - return { - async snapshot(_rootUri: string): Promise<ConfigurationSnapshot> { - return snapshotWithRule(); - }, - async preview(_request: PreviewConfigurationRequest): Promise<ConfigurationPreview> { - throw new Error("preview is not under test"); - }, - async apply(_request: ApplyConfigurationRequest): Promise<ConfigurationSnapshot> { - throw new Error("apply is not under test"); - }, - async occurrences(_request: RuleOccurrencesRequest): Promise<RuleOccurrencesResponse> { - throw new Error("occurrences is not under test"); - }, - async typeshedAction(_request: TypeshedActionRequest): Promise<TypeshedActionResult> { - throw new Error("Typeshed actions are not under test"); - }, - async executeCommand(_command: string, _args: readonly unknown[]): Promise<void> { - throw new Error("executeCommand is not under test"); - }, - }; -} - -async function pollUntil(predicate: () => boolean, timeoutMs = 5_000): Promise<void> { - const deadline = Date.now() + timeoutMs; - while (!predicate() && Date.now() < deadline) { - await delay(25); - } - assert.ok(predicate(), "condition did not become true before timeout"); -} - -suite("Configuration editor — Configure Severity deep link", () => { - // The command argument is untrusted webview/markdown input: only a bounded, - // non-empty `{ rule: string }` yields a focus target. - test("decodes only a bounded { rule } command argument", () => { - assert.strictEqual(configurationEditorFocusRule({ rule: RULE_CODE }), RULE_CODE); - assert.strictEqual(configurationEditorFocusRule(undefined), undefined); - assert.strictEqual(configurationEditorFocusRule(null), undefined); - assert.strictEqual(configurationEditorFocusRule(RULE_CODE), undefined); - assert.strictEqual(configurationEditorFocusRule({ rule: 7 }), undefined); - assert.strictEqual(configurationEditorFocusRule({ rule: "" }), undefined); - assert.strictEqual(configurationEditorFocusRule({ rule: "x".repeat(65) }), undefined); - assert.strictEqual(configurationEditorFocusRule([{ rule: RULE_CODE }]), undefined); - }); - - // Store semantics: string sets, undefined (internal refresh) preserves for - // the same root, null (plain open) clears, and a root change clears. - test("focusRule is set, survives same-root refreshes, and clears on plain open", () => { - const store = createStore(); - store.beginConfigurationLoad(ROOT_URI, RULE_CODE); - assert.strictEqual(store.configurationEditor.value.focusRule, RULE_CODE); - - store.beginConfigurationLoad(ROOT_URI); - assert.strictEqual(store.configurationEditor.value.focusRule, RULE_CODE); - - store.acceptConfigurationSnapshot(snapshotWithRule()); - assert.strictEqual(store.configurationEditor.value.focusRule, RULE_CODE); - - store.beginConfigurationLoad(ROOT_URI, null); - assert.strictEqual(store.configurationEditor.value.focusRule, undefined); - - store.beginConfigurationLoad(ROOT_URI, RULE_CODE); - store.beginConfigurationLoad("file:///elsewhere"); - assert.strictEqual(store.configurationEditor.value.focusRule, undefined); - }); - - // The controller's open() carries the focus target through its load chain - // into the state the webview renders; a later plain open clears it. - test("open(rootUri, rule) keeps the focus target through load; plain open clears it", async () => { - const store = createStore(); - const controller = new ConfigurationEditorController(store, snapshotTransport()); - try { - controller.open(ROOT_URI, RULE_CODE); - await pollUntil(() => store.configurationEditor.value.phase === "ready"); - assert.strictEqual(store.configurationEditor.value.focusRule, RULE_CODE); - - controller.open(ROOT_URI); - await pollUntil(() => store.configurationEditor.value.phase === "ready"); - assert.strictEqual(store.configurationEditor.value.focusRule, undefined); - } finally { - controller.dispose(); - } - }); - - // Hover markdown from the LSP must become clickable for exactly the one - // configuration-editor command — nothing else gets trusted. - test("hover middleware trusts exactly the openConfigurationEditor command", () => { - const markdown = new vscode.MarkdownString( - `[Configure Severity](command:${CONFIGURATION_EDITOR_COMMAND}?%5B%7B%22rule%22%3A%22${RULE_CODE}%22%7D%5D)`, - ); - const hover = trustConfigureSeverityLinks(new vscode.Hover([markdown])); - assert.ok(hover); - const [content] = hover.contents; - assert.ok(content instanceof vscode.MarkdownString); - assert.deepStrictEqual(content.isTrusted, { enabledCommands: [CONFIGURATION_EDITOR_COMMAND] }); - - assert.strictEqual(trustConfigureSeverityLinks(null), null); - assert.strictEqual(trustConfigureSeverityLinks(undefined), undefined); - }); - - // The wiring, not just the helper: buildClientOptions must actually route - // hovers through trustConfigureSeverityLinks — deleting the provideHover - // middleware line would pass the helper test above but fail this one. - test("buildClientOptions pipes hovers through the trust middleware", async () => { - const trace = vscode.window.createOutputChannel("bsk-focus-test-trace", { log: true }); - const options = buildClientOptions(undefined, trace, () => undefined); - try { - const provideHover = options.middleware?.provideHover; - assert.ok(provideHover, "client options must register the hover middleware"); - const markdown = new vscode.MarkdownString( - `[Configure Severity](command:${CONFIGURATION_EDITOR_COMMAND})`, - ); - const hover = await provideHover( - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- empty TextDocument double; the hover middleware never reads the document - {} as vscode.TextDocument, - new vscode.Position(0, 0), - new vscode.CancellationTokenSource().token, - async () => new vscode.Hover([markdown]), - ); - assert.ok(hover, "middleware must return the server's hover"); - const [content] = hover.contents; - assert.ok(content instanceof vscode.MarkdownString); - assert.deepStrictEqual( - content.isTrusted, - { enabledCommands: [CONFIGURATION_EDITOR_COMMAND] }, - "hover leaving the middleware must trust exactly the one command", - ); - } finally { - const watcher = options.synchronize?.fileEvents; - if (watcher !== undefined && !Array.isArray(watcher)) { watcher.dispose(); } - trace.dispose(); - } - }); - - // The registerCommand glue [CONFIGEDITOR-VSIX-EXPERIENCE]: executing the - // real basilisk.editConfig with an explorer resource must open the - // configuration editor panel for that resource's workspace folder. - test("basilisk.editConfig opens the configuration editor for the resource's folder", async function () { - this.timeout(60_000); - const folder = vscode.workspace.workspaceFolders?.[0]; - assert.ok(folder, "the e2e suite always opens a workspace folder"); - const resource = vscode.Uri.joinPath(folder.uri, "pyproject.toml"); - - // The command registers once the live server advertises the editor - // capability — poll execution until the capability effect has fired. - const deadline = Date.now() + 45_000; - let lastError: unknown; - let executed = false; - while (!executed && Date.now() < deadline) { - try { - await vscode.commands.executeCommand(EDIT_CONFIG_COMMAND, resource); - executed = true; - } catch (error) { - lastError = error; - await delay(250); - } - } - assert.ok(executed, `basilisk.editConfig never became executable: ${String(lastError)}`); - - function isConfigTab(tab: vscode.Tab): boolean { - return tab.input instanceof vscode.TabInputWebview - && tab.input.viewType.includes("basilisk.configurationEditor"); - } - await pollUntil(() => - vscode.window.tabGroups.all.some((group) => group.tabs.some(isConfigTab))); - const tab = vscode.window.tabGroups.all - .flatMap((group) => group.tabs) - .find(isConfigTab); - assert.ok(tab, "the configuration editor webview tab must open"); - await vscode.window.tabGroups.close(tab); - }); -}); diff --git a/vscode-extension/src/test/suite/configuration-editor-menu.test.ts b/vscode-extension/src/test/suite/configuration-editor-menu.test.ts deleted file mode 100644 index 221d2d134..000000000 --- a/vscode-extension/src/test/suite/configuration-editor-menu.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -// Implements [VSIX-CONFIGURATION-EDITOR]: file-explorer context-menu entry point. -/** - * Right-clicking pyproject.toml in the Explorer must offer an "Edit Config" - * item at the very top of the context menu, opening the configuration editor. - * Manifest-level contract, same convention as activity-panel.test.ts. - */ - -import * as assert from "assert"; -import { - manifestCommands, - manifestMenu -} from "./extension-manifest"; - -suite("Configuration editor — pyproject.toml explorer context menu", () => { - test("pyproject.toml context menu has an Edit Config item at the top", function () { - const commands = manifestCommands(); - const editConfig = commands.find((entry) => entry.title === "Edit Config"); - assert.ok( - editConfig, - 'package.json must declare a command titled "Edit Config" for the explorer context menu', - ); - - const explorerMenu = manifestMenu("explorer/context"); - const menuEntry = explorerMenu.find((entry) => entry.command === editConfig.command); - assert.ok( - menuEntry, - `"${editConfig.command}" must be contributed to the explorer/context menu; got: ${ - explorerMenu.map((entry) => entry.command).join(", ") || "(no explorer/context menu)" - }`, - ); - - // Scoped to pyproject.toml only — never on unrelated files. - assert.ok( - menuEntry.when?.includes("resourceFilename == pyproject.toml"), - `Edit Config must target pyproject.toml via resourceFilename; got when: "${menuEntry.when}"`, - ); - - // Gated on editor support so it never renders a dead item when the - // server lacks the configuration editor (same rule as the view-title gears). - assert.ok( - menuEntry.when?.includes("basilisk.configurationEditorSupported"), - `Edit Config must be gated on basilisk.configurationEditorSupported; got when: "${menuEntry.when}"`, - ); - - // "Right at the top": the navigation group always renders first in - // explorer/context, ahead of every numbered group. - assert.match( - menuEntry.group ?? "", - /^navigation(@\d+)?$/, - `Edit Config must sit in the top-most (navigation) group; got group: "${menuEntry.group}"`, - ); - }); -}); 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 deleted file mode 100644 index cbb0ad12e..000000000 --- a/vscode-extension/src/test/suite/configuration-editor-typeshed-dom.test.ts +++ /dev/null @@ -1,695 +0,0 @@ -// Tests [LSPCFGED-TYPESHED] / [LSPCFGED-TYPESHED-DOWNLOAD] in a REAL webview -// DOM, driven like a user. -// -// The reported failures this suite locks down: -// * 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 — -// only the real, mutually-exclusive sources may ever appear (a pinned -// commit, a custom folder, and a PyPI package pin [STUBRES-TYPESHED-PYPI]); -// * 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. - -import * as assert from "assert"; -import { - DRIVER_PRELUDE, - RESULT_TIMEOUT_MS, - runScenario, - ScenarioHost, - type DomStep, -} from "./webview-dom-harness"; -import { ACTIVE_COMMIT, LATEST_COMMIT, OTHER_COMMIT } from "./settings-fixture"; -import { decodeConfigurationEditorIntent } from "../../configuration-editor-intents"; -import { booleanField, rawField, recordArrayField, stringField } from "../../unknown-shape"; - -const CUSTOM_FOLDER = "/workspace/vendor/typeshed"; -const STORE_FOLDER = "/workspace/.basilisk/typeshed-store"; -const NO_SOURCE_REASON = "Pinned commit 1f2e3d4c is not in the local store"; -// A wheel SHA-256 and the pin spec built from it ([STUBRES-TYPESHED-PYPI]). -const PACKAGE_DIGEST = - "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; -const PACKAGE_PIN = `micropython-stdlib-stubs@sha256:${PACKAGE_DIGEST}`; - -// `DomStep` carries its observations under an index signature, so each one is -// read field by field below. Every field stays `| undefined` on purpose: the -// assertions compare against concrete values (`disabled === false`, -// `State === "Ready"`), so an observation the harness failed to record must -// arrive as `undefined` and fail, not be defaulted into the expected answer. - -interface Source { - readonly mode: string | undefined; - readonly checked: boolean | undefined; - readonly disabled: boolean | undefined; - readonly hint: string | undefined; -} - -interface Action { - readonly action: string | undefined; - readonly disabled: boolean | undefined; - readonly busy: boolean | undefined; -} - -/** The action buttons a recorded step rendered. */ -function actionsOf(entry: DomStep): Action[] { - return recordArrayField(entry, "actions").map((raw) => ({ - action: stringField(raw, "action"), - disabled: booleanField(raw, "disabled"), - busy: booleanField(raw, "busy"), - })); -} - -/** The source radios a recorded step rendered. */ -function sourcesOf(entry: DomStep): Source[] { - return recordArrayField(entry, "sources").map((raw) => ({ - mode: stringField(raw, "mode"), - checked: booleanField(raw, "checked"), - disabled: booleanField(raw, "disabled"), - hint: stringField(raw, "hint"), - })); -} - -/** The `State` row of the status table a recorded step rendered. */ -function statusState(entry: DomStep): string | undefined { - return stringField(rawField(entry, "status"), "State"); -} - -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 action(entry: DomStep, name: string): Action { - const found = actionsOf(entry).find((candidate) => candidate.action === name); - assert.ok(found, `step "${entry.label}" rendered no ${name} button`); - return found; -} - -/** - * Exactly the three real sources exist, in order — a "Latest" radio may NEVER - * render. `PyPIPackage` is the third step-3 source ([STUBRES-TYPESHED-PYPI]); - * it is mutually exclusive with the other two, not additive to them. - */ -const SOURCE_MODES = ["ExactCommit", "CustomFolder", "PyPIPackage"]; - -function assertSelected(entry: DomStep, mode: string): void { - const sources = sourcesOf(entry); - assert.deepStrictEqual( - sources.map((candidate) => candidate.mode), - SOURCE_MODES, - `step "${entry.label}" must offer exactly the three real sources — no Latest, ever`, - ); - assert.deepStrictEqual( - sources.filter((candidate) => candidate.checked).map((candidate) => candidate.mode), - [mode], - `step "${entry.label}" must show ${mode} as the one active source`, - ); -} - -/** 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`); - sourcesOf(entry).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<string, unknown>[], index: number): unknown { - return intents[index]?.mutations; -} - -const sourceJourneyDriver = String.raw` - (async () => { - ${DRIVER_PRELUDE} - 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('pinned'); - // 1. Reject an invalid SHA in place — nothing may be written. - await change(el('[data-typeshed-commit]'), 'not-a-sha'); - record('invalid-sha'); - // 2. A valid SHA repins, atomically clearing any folder. - await change(el('[data-typeshed-commit]'), '${OTHER_COMMIT}'); - record('repinned'); - // 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'); - // 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'); - // 6. The PyPI package source. Selecting it must reveal an EMPTY pin - // field — the server describes sources by value, so it cannot report - // this source until one exists, and this field is the only way to - // create one. - await chooseSource('PyPIPackage'); - record('package-empty'); - // 7. A name outside the PEP 508 alphabet is refused in place. - await change(el('[data-typeshed-package]'), 'stubs/json@sha256:${PACKAGE_DIGEST}'); - record('invalid-package'); - // 8. A valid pin writes it and clears BOTH competing sources at once. - await change(el('[data-typeshed-package]'), '${PACKAGE_PIN}'); - record('package-pinned'); - report({ ok: true, steps }); - } catch (error) { report({ ok: false, reason: String(error), steps }); } - })(); -`; - -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('.typeshed-advanced')) { report({ ok: false, reason: 'advanced settings never rendered' }); return; } - await click(document.querySelector('[data-section-target="project"]')); - record('initial'); - el('.typeshed-advanced').open = true; - el('.typeshed-advanced').dispatchEvent(new Event('toggle')); - 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 unpinDriver = 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. 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 }); } - })(); -`; - -const dialogDriver = String.raw` - (async () => { - ${DRIVER_PRELUDE} - try { - if (!await waitFor('select[data-rule-entry]')) { report({ ok: false, reason: 'rule rows never rendered' }); return; } - const ruleValue = () => { - const select = el('select[data-rule-entry="pep_rule_000"]'); - return select ? select.value : null; - }; - const first = record('before'); - first.ruleValue = ruleValue(); - // A rule change still costs an impact review. Wait for the dialog the - // change causes rather than for a fixed delay: the proposal travels to - // the extension host and back before anything renders, and that host is - // shared with every other suite in the run. - await change(el('select[data-rule-entry="pep_rule_000"]'), 'Warning'); - if (!await waitUntil(() => dialog().open)) { report({ ok: false, reason: 'the impact dialog never opened for the rule change', steps }); return; } - const opened = record('dialog-open'); - opened.ruleValue = ruleValue(); - // ...and dismissing it discards the change: the control must snap back. - // Dismiss through the Cancel button exactly as a user would: the - // discard intent posts synchronously with the click. A programmatic - // dialog.close() would instead lean on the QUEUED 'close' event, which - // an occluded webview's throttled task queue may never deliver. - await click(el('[data-action="close-preview"]')); - await waitUntil(() => !dialog().open); - await sleep(settleDelay); - const cancelled = record('dialog-cancelled'); - cancelled.ruleValue = ruleValue(); - // Re-run it and apply for real. Applying is ignored unless the editor is - // actually in its preview phase, so wait for the dialog to prove it is - // there before clicking — otherwise the click is silently discarded. - await change(el('select[data-rule-entry="pep_rule_000"]'), 'Info'); - if (!await waitUntil(() => dialog().open)) { report({ ok: false, reason: 'the impact dialog never reopened, so apply had nothing to confirm', steps }); return; } - await click(el('[data-action="apply-preview"]')); - await waitUntil(() => !dialog().open); - await sleep(settleDelay); - const applied = record('applied'); - applied.ruleValue = ruleValue(); - report({ ok: true, steps }); - } catch (error) { report({ ok: false, reason: String(error), steps }); } - })(); -`; - -/** 0-2: the pinned default, in-place SHA rejection, and the atomic repin. */ -function assertPinnedAndCommitEditing(steps: DomStep[] | undefined, intents: readonly Record<string, unknown>[]): void { - const pinned = step(steps, "pinned"); - assertSelected(pinned, "ExactCommit"); - 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.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( - actionsOf(pinned).map((entry) => entry.action), - ["DownloadLatest", "ViewLicense"], - "Download latest is always offered; Download pinned only without a source", - ); - assert.strictEqual(statusState(pinned), "Ready"); - assert.strictEqual(intents[0]?.type, "ready"); - - const invalid = step(steps, "invalid-sha"); - assert.strictEqual(invalid.commitInvalid, "true", "the field must report itself invalid"); - assert.ok( - String(invalid.commitError).includes("40-character"), - `the error must teach the format (got "${String(invalid.commitError)}")`, - ); - assertSelected(invalid, "ExactCommit"); - assert.ok( - !intents.some((intent) => JSON.stringify(intent).includes("not-a-sha")), - "an invalid SHA must never reach the configuration", - ); - - const repinned = step(steps, "repinned"); - 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.strictEqual(repinned.dialogOpen, false, "a Typeshed edit never opens the impact dialog"); - // Selecting one source clears BOTH others in the same atomic mutation. A - // leftover competing key would make the server reject the save as mutually - // exclusive, stranding the user in a config the UI cannot undo - // ([LSPCFGED-TYPESHED], [STUBRES-TYPESHED-PYPI]). - assert.deepStrictEqual(mutationsOf(intents, 1), [ - { kind: "SetTypeshedSetting", key: { kind: "TypeshedCommit" }, value: OTHER_COMMIT }, - { kind: "RemoveTypeshedSetting", key: { kind: "TypeshedPath" } }, - { kind: "RemoveTypeshedSetting", key: { kind: "TypeshedPackage" } }, - ]); -} - -/** - * 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<string, unknown>[]): 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)}`, - ); - } -} - -/** 3-5: the folder source, the atomic switch back, and the cancelled picker. */ -function assertCustomFolder(steps: DomStep[] | undefined, intents: readonly Record<string, unknown>[]): 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[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, "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"); - - assert.deepStrictEqual(mutationsOf(intents, 3), [ - { kind: "RemoveTypeshedSetting", key: { kind: "TypeshedPath" } }, - { kind: "RemoveTypeshedSetting", key: { kind: "TypeshedPackage" } }, - ], "returning to the pinned source clears every competing source key"); - 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, "ExactCommit"); - assert.strictEqual(cancelled.pathPresent, false, "a cancelled picker must not select the folder source"); -} - -/** - * 6-8: the PyPI package source ([STUBRES-TYPESHED-PYPI]). It is the one source - * with no value the editor can supply on the user's behalf — a commit falls - * back to the bundled SHA and a folder comes from the picker — so selecting it - * must reveal an EMPTY field to type into. If the panel only rendered the - * server's described source, this source would be unreachable: the field that - * creates a pin would exist only once a pin already existed. - */ -function assertPackagePin(steps: DomStep[] | undefined, intents: readonly Record<string, unknown>[]): void { - const empty = step(steps, "package-empty"); - assertSelected(empty, "PyPIPackage"); - assertNothingLocked(empty); - assert.strictEqual(empty.packagePresent, true, "choosing the package source must reveal its field"); - assert.strictEqual(empty.packageValue, "", "no pin exists yet, so the field starts empty"); - assert.strictEqual(empty.commitPresent, false, "a package pin and a commit can never coexist"); - assert.strictEqual(empty.pathPresent, false, "a package pin and a folder can never coexist"); - assert.strictEqual( - empty.advancedPresent, - true, - "a package resolves from the store, so the store folder stays reachable", - ); - // Merely selecting the source writes NOTHING: a pin that was never typed - // must not destroy the configuration the user already had. Proven by what - // sits immediately before the pin write — still the cancelled folder pick - // from step 5, so neither step 6 nor step 7 posted anything at all. - assert.strictEqual( - intents[intents.length - 2]?.type, - "pickTypeshedFolder", - "choosing the package source and typing an invalid pin must post no intent", - ); - - const invalid = step(steps, "invalid-package"); - assert.strictEqual(invalid.packageInvalid, "true", "the field must report itself invalid"); - assert.ok( - String(invalid.packageError).includes("letters, digits"), - `the error must teach the name alphabet (got "${String(invalid.packageError)}")`, - ); - assertSelected(invalid, "PyPIPackage"); - assert.ok( - !intents.some((intent) => JSON.stringify(intent).includes("stubs/json")), - "a name outside the PEP 508 alphabet must never reach the configuration", - ); - - const pinned = step(steps, "package-pinned"); - assert.strictEqual(pinned.packageValue, PACKAGE_PIN); - assert.strictEqual(pinned.packageError, null, "the error must clear once the pin is valid"); - assert.strictEqual(pinned.packageInvalid, null); - // Exclusivity is enforced by the write that SETS the source, in one atomic - // mutation ([LSPCFGED-TYPESHED], [STUBRES-TYPESHED-PYPI]). - assert.deepStrictEqual(mutationsOf(intents, intents.length - 1), [ - { kind: "SetTypeshedSetting", key: { kind: "TypeshedPackage" }, value: PACKAGE_PIN }, - { kind: "RemoveTypeshedSetting", key: { kind: "TypeshedCommit" } }, - { kind: "RemoveTypeshedSetting", key: { kind: "TypeshedPath" } }, - ]); -} - -/** A running Download latest: spinner on that button only, everything else live. */ -function assertDownloadLatest(steps: DomStep[] | undefined, intents: readonly Record<string, unknown>[]): void { - const ready = step(steps, "ready"); - assertNothingLocked(ready); - assert.deepStrictEqual( - actionsOf(ready).map((entry) => [entry.action, entry.disabled, entry.busy]), - [["DownloadLatest", false, false], ["ViewLicense", false, false]], - "Ready offers Download latest live and no Download pinned", - ); - - 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(statusState(downloading), "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: "TypeshedCommit" }, value: OTHER_COMMIT }, - { kind: "RemoveTypeshedSetting", key: { kind: "TypeshedPath" } }, - { kind: "RemoveTypeshedSetting", key: { kind: "TypeshedPackage" } }, - ], "an SHA edit mid-download still writes — configuration never waits on the network"); - assert.strictEqual(edited.commitValue, OTHER_COMMIT); - assertNothingLocked(edited); - - const settled = step(steps, "settled"); - assert.strictEqual(statusState(settled), "Ready"); - assert.strictEqual(settled.commitValue, LATEST_COMMIT, "the finished download wrote the resolved SHA"); - assert.deepStrictEqual( - actionsOf(settled).map((entry) => [entry.action, entry.disabled, entry.busy]), - [["DownloadLatest", false, false], ["ViewLicense", false, false]], - "settling releases the button and removes the spinner", - ); -} - -/** NO SOURCE: a persistent inline row whose fix is the Download pinned button. */ -function assertNoSource(steps: DomStep[] | undefined, intents: readonly Record<string, unknown>[]): void { - const noSource = step(steps, "no-source"); - assert.strictEqual(statusState(noSource), "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.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( - { 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(statusState(downloading), "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(statusState(resolved), "Ready"); - assert.strictEqual(resolved.noSourcePresent, false, "the row disappears once a source exists"); - assert.deepStrictEqual( - actionsOf(resolved).map((entry) => entry.action), - ["DownloadLatest", "ViewLicense"], - "Download pinned is offered only while there is no source", - ); -} - -suite("Configuration editor — Typeshed source in a real webview DOM", () => { - test("switching between 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"}`); - assertPinnedAndCommitEditing(result.steps, intents); - assertCustomFolder(result.steps, intents); - assertPackagePin(result.steps, intents); - assertEveryPostedIntentDecodes(intents); - }); - - 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(); - const { result, intents } = await runScenario(downloadLatestDriver, host); - assert.strictEqual(result.ok, true, `driver failed: ${result.reason ?? "unknown"}`); - assertDownloadLatest(result.steps, intents); - assertEveryPostedIntentDecodes(intents); - }); - - 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 }, noSourceReason: NO_SOURCE_REASON }); - const { result, intents } = await runScenario(noSourceDriver, host); - assert.strictEqual(result.ok, true, `driver failed: ${result.reason ?? "unknown"}`); - assertNoSource(result.steps, intents); - }); - - 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({ folders: [STORE_FOLDER] }); - const { result, intents } = await runScenario(advancedDriver, host); - assert.strictEqual(result.ok, true, `driver failed: ${result.reason ?? "unknown"}`); - - 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( - { type: intents[1]?.type, key: intents[1]?.key }, - { type: "pickTypeshedFolder", key: "TypeshedStorePath" }, - ); - 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"); - }); - - 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", - ); - - assert.deepStrictEqual( - { 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 () { - this.timeout(RESULT_TIMEOUT_MS + 20_000); - const host = new ScenarioHost(); - const { result, intents } = await runScenario(dialogDriver, host); - assert.strictEqual(result.ok, true, `driver failed: ${result.reason ?? "unknown"}`); - const steps = result.steps; - - 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", - "the list keeps showing the configuration while the dialog carries the proposal", - ); - assert.ok( - String(opened.dialogChanges).includes("Error → Warning"), - `the dialog must state the exact resolved change (got "${String(opened.dialogChanges)}")`, - ); - - const cancelled = step(steps, "dialog-cancelled"); - assert.strictEqual(cancelled.dialogOpen, false); - assert.strictEqual( - cancelled.ruleValue, - "Error", - "dismissing the dialog must restore the configuration's value, never leave the choice on screen", - ); - assert.ok( - intents.some((intent) => intent.type === "cancelPreview"), - "the runtime must tell the host the preview was discarded", - ); - - const applied = step(steps, "applied"); - assert.strictEqual(applied.dialogOpen, false); - assert.strictEqual(applied.ruleValue, "Info", "an applied change survives the re-render"); - assert.ok( - intents.some((intent) => intent.type === "apply"), - "applying must go through the previewed change", - ); - }); -}); diff --git a/vscode-extension/src/test/suite/configuration-editor-webview-dom.test.ts b/vscode-extension/src/test/suite/configuration-editor-webview-dom.test.ts deleted file mode 100644 index 21c76c88f..000000000 --- a/vscode-extension/src/test/suite/configuration-editor-webview-dom.test.ts +++ /dev/null @@ -1,385 +0,0 @@ -// Tests [CONFIGEDITOR-VSIX-EXPERIENCE] webview runtime behaviour in a REAL -// webview DOM. See docs/specs/LSP-CONFIGURATION-EDITOR-SPEC.md#CONFIGEDITOR-VSIX-EXPERIENCE. -// -// Regression test for the stale RULE DETAIL panel: after selecting a rule, -// every virtualized re-render called restoreFocus() without preventScroll, -// which yanked the viewport back to the previously selected rule on every -// scroll frame. Basilisk (BSK-*) rules sit below the pep rules in the catalog, -// so they could never be scrolled to or clicked — the detail panel displayed -// stale data from the previously selected rule forever. -// -// String-containment tests (configuration-editor-webview.test.ts) cannot catch -// this: the bug is an interaction between focus(), scroll anchoring, and the -// row rebuild — so this suite runs the real CSP-locked document inside a real -// VS Code webview (Chromium) and drives it like a user. - -import * as assert from "assert"; -import { - DRIVER_PRELUDE, - RESULT_TIMEOUT_MS, - runScenario, - ScenarioHost, -} from "./webview-dom-harness"; - -/** Rows are 112px tall (ROW_HEIGHT in the webview script); 3 rows visible. */ -const VIEWPORT_HEIGHT_PX = 336; - -/** - * Drives the runtime like a user: select the first (pep) rule, scroll down in - * wheel-sized increments toward the basilisk rules, click one, and report what - * the RULE DETAIL panel shows. - */ -const detailDriver = String.raw` - (async () => { - ${DRIVER_PRELUDE} - const heading = () => text(el('#detail-content h3')) || ''; - try { - const viewport = document.getElementById('rule-viewport'); - viewport.style.height = '${VIEWPORT_HEIGHT_PX}px'; - viewport.style.minHeight = '${VIEWPORT_HEIGHT_PX}px'; - viewport.style.maxHeight = '${VIEWPORT_HEIGHT_PX}px'; - if (!await waitFor('[data-show-rule]', 200)) { report({ ok: false, reason: 'snapshot never rendered' }); return; } - // 1. The user selects a pep rule; the occurrences round trip re-renders. - const pepButton = el('[data-show-rule="pep_rule_000"]'); - pepButton.focus(); - pepButton.click(); - await sleep(250); - const headingAfterPep = heading(); - // 2. The user scrolls toward the basilisk rules, one viewport at a time. - // The regression shows up as the viewport SNAPPING BACK after a - // re-render, so what matters is that repeated scrolls make progress — - // not how many timer ticks the walk takes. The loop therefore stops the - // moment the target row virtualizes in, and gives up on a scroll that - // made no progress, rather than burning a fixed 30 short timers. Chromium - // clamps setTimeout to 1Hz in a window without OS focus, so a fixed-tick - // walk silently turned a 1.2s scroll into a 30s one and blew the harness - // timeout — making the suite pass only while it held the developer's - // screen. Bounded progress keeps it honest in either state. - const maxScrollTop = viewport.scrollHeight - viewport.clientHeight; - const scrollStep = ${VIEWPORT_HEIGHT_PX}; - for (let stepIndex = 0; stepIndex < 30; stepIndex += 1) { - if (el('[data-show-rule="BSK-0005"]')) break; - const before = viewport.scrollTop; - viewport.scrollTop = Math.min(before + scrollStep, maxScrollTop); - await sleep(40); - // No movement at the bottom means the walk is done; no movement short - // of the bottom is the snap-back regression itself — stop either way - // and let the assertions report what the viewport actually did. - if (viewport.scrollTop === before) break; - } - await sleep(150); - const scrollTopAfterScroll = viewport.scrollTop; - // The scroll listener repaints the virtual window through - // requestAnimationFrame, which Chromium suspends outright in a window - // that does not hold OS focus — so the rows for the scrolled-to position - // would never materialise and this would look identical to the snap-back - // regression. Nudge the filter, whose applyFilter() path repaints the - // window synchronously, so the assertions below read the DOM that the - // viewport's real scrollTop implies. This cannot mask the regression: a - // yanked-back viewport still repaints at the WRONG offset and the - // basilisk rows still stay out of the window. - const search = document.getElementById('rule-search'); - search.dispatchEvent(new Event('input', { bubbles: true })); - await sleep(50); - // 3. The user clicks the last basilisk rule. - const bskButton = el('[data-show-rule="BSK-0005"]'); - if (bskButton) { - bskButton.focus(); - bskButton.click(); - await sleep(250); - } - report({ - ok: true, - headingAfterPep, - scrollTopAfterScroll, - maxScrollTop, - bskRowRendered: bskButton !== null, - detailHeading: heading(), - }); - } catch (error) { report({ ok: false, reason: String(error) }); } - })(); -`; - -/** - * The Configure Severity deep-link scenario ([CONFIGEDITOR-VSIX-EXPERIENCE]): - * the state arrives with a focusRule; the runtime must prefill the search - * filter with the code and open that rule's detail panel — no user input. - */ -const focusDriver = String.raw` - (async () => { - ${DRIVER_PRELUDE} - try { - if (!await waitFor('[data-rule-code]', 200)) { report({ ok: false, reason: 'rules never rendered' }); return; } - await sleep(300); - report({ - ok: true, - searchValue: document.getElementById('rule-search').value, - filteredCount: text(document.getElementById('filter-result')), - detailHeading: text(el('#detail-content h3')) || '', - }); - } catch (error) { report({ ok: false, reason: String(error) }); } - })(); -`; - -/** - * One-shot semantics: focus is applied on the first snapshot render ONLY — - * once the user edits the search, later host state pushes (still carrying the - * same focusRule) must never stomp their filter or selection. - */ -const oneShotDriver = String.raw` - (async () => { - ${DRIVER_PRELUDE} - try { - if (!await waitFor('[data-rule-code]', 200)) { report({ ok: false, reason: 'rules never rendered' }); return; } - await sleep(300); - const searchValue = document.getElementById('rule-search').value; - // The user retargets the filter to a pep rule... - const search = document.getElementById('rule-search'); - search.value = 'pep_rule_001'; - search.dispatchEvent(new Event('input', { bubbles: true })); - await sleep(100); - // ...then the host pushes a later state still carrying the focusRule. - window.__realApi.postMessage({ type: 'domTestSettle' }); - await sleep(300); - report({ - ok: true, - searchValue, - searchAfterPush: document.getElementById('rule-search').value, - filteredCount: text(document.getElementById('filter-result')), - detailAfterPush: text(el('#detail-content h3')) || '', - }); - } catch (error) { report({ ok: false, reason: String(error) }); } - })(); -`; - -/** - * Chrome scenario: a no-entry select must DISPLAY the resolved severity — - * an untouched pep rule/tag reads Error, an untouched analyze rule its - * effective value (Disabled for one that does not run), an untouched - * non-pep tag Disabled — and only non-pep controls offer Disabled at all. - */ -const selectValueDriver = String.raw` - (async () => { - ${DRIVER_PRELUDE} - const optionValues = (select) => Array.from(select.options).map((option) => option.value); - try { - if (!await waitFor('[data-rule-code]', 200)) { report({ ok: false, reason: 'rules never rendered' }); return; } - const pepRule = el('select[data-rule-entry="pep_rule_000"]'); - const pepTag = el('select[data-tag-entry="pep"]'); - const basiliskTag = el('select[data-tag-entry="basilisk"]'); - // The Disabled analyze rule sits below the virtual window — filter to it. - const search = document.getElementById('rule-search'); - search.value = 'BSK-0005'; - search.dispatchEvent(new Event('input', { bubbles: true })); - await sleep(150); - const disabledRule = el('select[data-rule-entry="BSK-0005"]'); - if (!pepRule || !pepTag || !basiliskTag || !disabledRule) { - report({ ok: false, reason: 'expected entry selects did not render' }); - return; - } - report({ - ok: true, - pepRuleSelect: pepRule.value, - pepRuleHasDisabledOption: optionValues(pepRule).includes('Disabled'), - disabledRuleSelect: disabledRule.value, - disabledRuleHasDisabledOption: optionValues(disabledRule).includes('Disabled'), - pepTagSelect: pepTag.value, - basiliskTagSelect: basiliskTag.value, - }); - } catch (error) { report({ ok: false, reason: String(error) }); } - })(); -`; - -/** - * [CONFIGEDITOR-VSIX-EXPERIENCE]: the editor ships FIVE navigation views — - * Overview, Rules, Adoption, Path Overrides, Project — and no Presets tab. - * Every dashboard view renders exact server-computed snapshot state; this - * driver switches views and reads back the real values it painted. - */ -const navPresenceDriver = String.raw` - (async () => { - ${DRIVER_PRELUDE} - const sectionOf = (name) => el('[data-section="' + name + '"]'); - try { - if (!await waitFor('[data-rule-code]', 200)) { report({ ok: false, reason: 'rules never rendered' }); return; } - await sleep(100); - const navLabels = all('#section-nav [data-section-target]') - .map((button) => (text(button.querySelector('span:last-child')) || '')); - // Overview: switch to it and read the exact server debt total it renders. - await click(el('[data-section-target="overview"]')); - const overviewVisible = !sectionOf('overview').hidden; - const rulesHiddenOnOverview = sectionOf('rules').hidden; - const remainingDebt = text(document.getElementById('overview-diagnostics')); - // Path Overrides: read the discovered nested-config list + open action. - await click(el('[data-section-target="paths"]')); - const pathHeads = all('#path-override-list .path-override-card h3').map(text); - const openConfigButtons = all('#path-override-list [data-open-config]').length; - // Project: real source detail rows. - await click(el('[data-section-target="project"]')); - report({ - ok: true, - navLabels, - hasOverview: !!sectionOf('overview'), - hasAdoption: !!sectionOf('adoption'), - hasPaths: !!sectionOf('paths'), - hasProject: !!sectionOf('project'), - hasPresets: !!sectionOf('presets'), - overviewVisible, - rulesHiddenOnOverview, - remainingDebt, - pathHeads, - openConfigButtons, - sourceRows: all('#source-details dt').length, - }); - } catch (error) { report({ ok: false, reason: String(error) }); } - })(); -`; - -suite("Configuration editor — rule detail panel in a real webview DOM", () => { - // The reported bug: basilisk rules show no details — clicking one leaves the - // detail panel on stale data from the previously selected (pep) rule, - // because restoreFocus() yanks every scroll back to that rule's row. - test("scrolling to and clicking a basilisk rule updates the rule detail panel", async function () { - this.timeout(RESULT_TIMEOUT_MS + 15_000); - const { result } = await runScenario(detailDriver, new ScenarioHost()); - assert.strictEqual(result.ok, true, `webview driver failed: ${result.reason ?? "unknown"}`); - assert.ok( - String(result.headingAfterPep).includes("pep_rule_000"), - `selecting a pep rule must populate the detail panel (got "${String(result.headingAfterPep)}")`, - ); - assert.ok( - result.bskRowRendered, - "scrolling toward the basilisk rules must reach them — the viewport was yanked back to the " - + `previously selected rule (scrollTop ${String(result.scrollTopAfterScroll)} of ${String(result.maxScrollTop)})`, - ); - assert.ok( - String(result.detailHeading).includes("BSK-0005"), - `clicking a basilisk rule must show ITS detail, not stale data (panel shows "${String(result.detailHeading)}")`, - ); - }); - - // [CONFIGEDITOR-VSIX-EXPERIENCE]: the Configure Severity hover deep link — - // a state carrying focusRule must open the editor "to the right place": - // search prefilled with the code, the list filtered to it, and the rule's - // detail panel open, all without any user interaction. - test("a focusRule state opens the editor focused on that rule", async function () { - this.timeout(RESULT_TIMEOUT_MS + 15_000); - const { result } = await runScenario(focusDriver, new ScenarioHost({ focusRule: "BSK-0003" })); - assert.strictEqual(result.ok, true, `webview driver failed: ${result.reason ?? "unknown"}`); - assert.strictEqual( - result.searchValue, - "BSK-0003", - `the search filter must be prefilled with the focused rule code (got "${String(result.searchValue)}")`, - ); - assert.ok( - String(result.filteredCount).startsWith("1 "), - `the rule list must be filtered to the focused rule (got "${String(result.filteredCount)}")`, - ); - assert.ok( - String(result.detailHeading).includes("BSK-0003"), - `the focused rule's detail panel must open (panel shows "${String(result.detailHeading)}")`, - ); - }); - - // One-shot: the focus target is applied on the FIRST snapshot render only. - // Later state pushes (occurrences round trips, refreshes) still carry the - // focusRule — they must never stomp the user's own search or selection. - test("a later state push never re-applies the consumed focusRule over the user's search", async function () { - this.timeout(RESULT_TIMEOUT_MS + 15_000); - const { result } = await runScenario(oneShotDriver, new ScenarioHost({ focusRule: "BSK-0003" })); - assert.strictEqual(result.ok, true, `webview driver failed: ${result.reason ?? "unknown"}`); - assert.strictEqual(result.searchValue, "BSK-0003", "the deep link must focus first"); - assert.strictEqual( - result.searchAfterPush, - "pep_rule_001", - `a later state push must keep the user's own filter (got "${String(result.searchAfterPush)}")`, - ); - assert.ok( - String(result.filteredCount).startsWith("1 "), - `the list must stay filtered to the USER's query, not the focus target (got "${String(result.filteredCount)}")`, - ); - }); - - // A focus target the snapshot does not contain (stale hover, wrong server) - // must be ignored gracefully: no crash, no vacuous filter, no detail panel. - test("an unknown focusRule is ignored without filtering or crashing", async function () { - this.timeout(RESULT_TIMEOUT_MS + 15_000); - const { result } = await runScenario(focusDriver, new ScenarioHost({ focusRule: "BSK-9999" })); - assert.strictEqual(result.ok, true, `webview driver failed: ${result.reason ?? "unknown"}`); - assert.strictEqual( - result.searchValue, - "", - `an unknown focus target must not prefill the search (got "${String(result.searchValue)}")`, - ); - assert.ok( - !String(result.detailHeading).includes("BSK-9999"), - `an unknown focus target must not open a detail panel (panel shows "${String(result.detailHeading)}")`, - ); - }); - - // [CHKARCH-CONFIG-MODEL] resolution shown honestly: a no-entry select must - // DISPLAY what no entry resolves to — never a blank or a lying default. - test("no-entry selects display the resolved severity (pep→Error, analyze→effective, non-pep tag→Disabled)", async function () { - this.timeout(RESULT_TIMEOUT_MS + 15_000); - const { result } = await runScenario(selectValueDriver, new ScenarioHost()); - assert.strictEqual(result.ok, true, `webview driver failed: ${result.reason ?? "unknown"}`); - assert.strictEqual( - result.pepRuleSelect, - "Error", - `an untouched pep rule runs at error and its select must say so (got "${String(result.pepRuleSelect)}")`, - ); - assert.strictEqual( - result.pepRuleHasDisabledOption, - false, - "pep rule selects must not offer Disabled ([CHKARCH-CONFIG-MODEL])", - ); - assert.strictEqual( - result.disabledRuleSelect, - "Disabled", - `an untouched analyze rule that does not run must display Disabled (got "${String(result.disabledRuleSelect)}")`, - ); - assert.strictEqual( - result.disabledRuleHasDisabledOption, - true, - "analyze rule selects must offer Disabled", - ); - assert.strictEqual( - result.pepTagSelect, - "Error", - `an untouched pep tag grades at error and its select must say so (got "${String(result.pepTagSelect)}")`, - ); - assert.strictEqual( - result.basiliskTagSelect, - "Disabled", - `an untouched non-pep tag does not run and its select must say so (got "${String(result.basiliskTagSelect)}")`, - ); - }); -}); - -suite("Configuration editor — restored navigation views in a real webview DOM", () => { - // The reported regression: the config editor lost every view except Rules. - // The nav rail must offer all five views, each dashboard view must render - // exact server-computed snapshot state, and there must be NO Presets tab. - test("renders the five navigation views with real server data and no presets tab", async function () { - this.timeout(RESULT_TIMEOUT_MS + 15_000); - const { result } = await runScenario(navPresenceDriver, new ScenarioHost()); - assert.strictEqual(result.ok, true, `webview driver failed: ${result.reason ?? "unknown"}`); - assert.deepStrictEqual( - result.navLabels, - ["Overview", "Rules", "Adoption", "Path Overrides", "Project"], - "the nav rail must offer exactly the five restored views in order", - ); - assert.ok( - result.hasOverview === true && result.hasAdoption === true - && result.hasPaths === true && result.hasProject === true, - "all four restored view sections must exist in the DOM", - ); - assert.strictEqual(result.hasPresets, false, "there is no Presets tab ([CHKARCH-CONFIGURATION-ONLY])"); - assert.strictEqual(result.overviewVisible, true, "selecting Overview must reveal its section"); - assert.strictEqual(result.rulesHiddenOnOverview, true, "selecting Overview must hide the Rules section"); - assert.strictEqual(result.remainingDebt, "795", "Overview renders the exact server debt total, not a synthetic score"); - assert.deepStrictEqual(result.pathHeads, ["legacy"], "Path Overrides lists the discovered nested config"); - assert.strictEqual(result.openConfigButtons, 1, "each path override exposes a real open-file action"); - assert.ok(Number(result.sourceRows ?? 0) >= 3, "the Project view renders the real source details"); - }); -}); diff --git a/vscode-extension/src/test/suite/configuration-editor-webview.test.ts b/vscode-extension/src/test/suite/configuration-editor-webview.test.ts deleted file mode 100644 index c78b0a0e6..000000000 --- a/vscode-extension/src/test/suite/configuration-editor-webview.test.ts +++ /dev/null @@ -1,266 +0,0 @@ -// Implements [CONFIGEDITOR-ACCESSIBILITY-SECURITY] / [CONFIGEDITOR-VSIX-EXPERIENCE]. - -import * as assert from "assert"; -import { buildConfigurationEditorDocument } from "../../configuration-editor-document"; -import { decodeConfigurationEditorIntent } from "../../configuration-editor-intents"; - -suite("Configuration editor — untrusted intent decoder", () => { - // [CONFIGEDITOR-MODEL]: the four rule/tag mutations and two allowlisted - // Typeshed setting mutations are the complete write vocabulary. - test("accepts the six EditorMutation kinds with typed values", () => { - for (const severity of ["Error", "Warning", "Info", "Disabled"]) { - const setRule = decodeConfigurationEditorIntent({ - type: "preview", - mutations: [{ kind: "SetRule", code: "BSK-0001", severity: { kind: severity } }], - }); - assert.strictEqual(setRule?.type, "preview", `SetRule ${severity} must be accepted`); - const setTag = decodeConfigurationEditorIntent({ - type: "preview", - mutations: [{ kind: "SetTag", tag: "basilisk", severity: { kind: severity } }], - }); - assert.strictEqual(setTag?.type, "preview", `SetTag ${severity} must be accepted`); - } - assert.strictEqual(decodeConfigurationEditorIntent({ - type: "preview", mutations: [{ kind: "RemoveRule", code: "BSK-0001" }], - })?.type, "preview"); - assert.strictEqual(decodeConfigurationEditorIntent({ - type: "preview", mutations: [{ kind: "RemoveTag", tag: "basilisk" }], - })?.type, "preview"); - // [LSPCFGED-TYPESHED] / [STUBRES-TYPESHED-PYPI]: every surviving key is - // text-typed, so the model carries a bare String — - // `SetTypeshedSetting { key, value: String }` in - // models/configuration_editor.td. This list must hold EVERY variant of the - // Rust `TypeshedSettingKey`: a key the webview can post but the decoder - // does not know is silently dropped, losing the user's edit. - for (const key of ["TypeshedPath", "TypeshedCommit", "TypeshedPackage", "TypeshedStorePath"]) { - assert.strictEqual(decodeConfigurationEditorIntent({ - type: "preview", - 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: "Text", value: "configured" } }], - }), undefined, `the retired tagged value shape must be rejected for ${key}`); - } - assert.strictEqual(decodeConfigurationEditorIntent({ - type: "preview", mutations: [{ kind: "RemoveTypeshedSetting", key: { kind: "TypeshedStorePath" } }], - })?.type, "preview"); - 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, - // scopes, and fix-safety selectors were removed from the contract. - test("rejects malformed payloads and every removed legacy shape", () => { - assert.strictEqual(decodeConfigurationEditorIntent({ type: "preview", mutations: [] }), undefined); - assert.strictEqual(decodeConfigurationEditorIntent({ - type: "preview", - mutations: [{ kind: "SetRule", code: "BSK-0001", severity: { kind: "Inherit" } }], - }), undefined); - assert.strictEqual(decodeConfigurationEditorIntent({ - type: "preview", - mutations: [{ kind: "SetRule", code: "BSK-0001", severity: { kind: "Native" } }], - }), undefined); - assert.strictEqual(decodeConfigurationEditorIntent({ - type: "preview", - mutations: [{ selector: { kind: "All" }, setting: { kind: "Error" }, scope: { kind: "Project" } }], - }), undefined); - assert.strictEqual(decodeConfigurationEditorIntent({ - type: "preview", mutations: [{ kind: "SetRule", severity: { kind: "Error" } }], - }), undefined); - 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: "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 - // selectors; fixability selectors no longer exist. - test("accepts read-side occurrence selectors and rejects removed ones", () => { - for (const selector of [ - { kind: "All" }, - { kind: "Codes", codes: ["BSK-0001"] }, - { kind: "Tags", tags: ["pep"], matchAll: false }, - ]) { - assert.strictEqual(decodeConfigurationEditorIntent({ - type: "occurrences", selector, cursor: undefined, limit: 100, - })?.type, "occurrences"); - } - for (const selector of [ - { kind: "CurrentViolations" }, - { kind: "SafeFixable" }, - { kind: "WithoutSafeFix" }, - ]) { - assert.strictEqual(decodeConfigurationEditorIntent({ - type: "occurrences", selector, cursor: undefined, limit: 100, - }), undefined, `${selector.kind} was removed from the contract`); - } - assert.strictEqual(decodeConfigurationEditorIntent({ type: "occurrences", selector: { kind: "All" }, limit: 0 }), undefined); - }); -}); - -suite("Configuration editor — hardened, accessible document", () => { - 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)); - assert.ok(/script-src 'nonce-[^']+'/.test(html)); - assert.ok(!html.includes("unsafe-inline")); - // `default-src 'none'` is the enforcement; assert the document also never - // ASKS for a remote resource (an example URL in placeholder copy is not a - // fetch, so match resource attributes rather than the bare scheme). - assert.ok( - !/(?:src|href)\s*=\s*["']https?:/i.test(html), - "the shell must load no remote resources", - ); - assert.ok(!/url\(\s*["']?https?:/i.test(html), "no stylesheet may fetch a remote asset"); - assert.ok(html.includes("img-src data:"), "images are inline data only"); - assert.ok(html.includes("var(--vscode-editor-background)")); - 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"')); - // 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"')); - assert.ok(html.includes("row.setAttribute('role', 'listitem')")); - assert.ok(html.includes("row.setAttribute('aria-posinset'")); - assert.ok(html.includes("row.setAttribute('aria-setsize'")); - assert.ok(html.includes("['ArrowUp', 'ArrowDown', 'PageUp', 'PageDown', 'Home', 'End']")); - assert.ok(html.includes("viewport.scrollTop = target * ROW_HEIGHT")); - }); - - // [LSPCFGED-TYPESHED] / [LSPCFGED-TYPESHED-DOWNLOAD] / [STUBRES-TYPESHED-PYPI]: - // the three real sources and no invented fourth, download buttons instead of - // lifecycle locks, and none of the deleted cache/verify/URL controls. - test("ships the three-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("'PyPI package'"), "the PyPI package 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]). - test("renders the tag-first Rules view with pep-gated entry controls", () => { - const html = buildConfigurationEditorDocument(); - assert.ok(html.includes("select.dataset.tagEntry = tag.name"), "tag groups expose the tag-entry control"); - assert.ok(html.includes("select.dataset.ruleEntry = rule.descriptor.code"), "rows expose per-rule entry controls"); - assert.ok(html.includes("const PEP_TAG = 'pep'")); - assert.ok( - html.includes("SEVERITIES.filter((value) => value !== 'Disabled')"), - "pep controls must offer error/warning/info and never Disabled", - ); - assert.ok(html.includes("isPepRule(rule)")); - assert.ok(html.includes("{ kind: 'SetRule', code, severity: { kind: value } }")); - assert.ok(html.includes("{ kind: 'SetTag', tag, severity: { kind: value } }")); - assert.ok(html.includes("Load more occurrences")); - assert.ok(html.includes("Exact resolved changes")); - assert.ok(html.includes("impactCell(impact.errorsBefore, impact.errorsAfter, 'errors')")); - assert.ok(html.includes('id="rule-search"'), "search stays"); - assert.ok(html.includes("Open raw")); - }); - - // [CONFIGEDITOR-VSIX-EXPERIENCE] / [CHKARCH-CONFIG-MODEL]: an entry dropdown - // lists concrete severities only. "No entry" duplicated Disabled — an analyze - // rule or tag with no entry does not run (resolution step 3) — so the - // redundant choice is gone from every select. Disabled is gone from every - // pep-affecting control (pep rows, the pep source tag, PEP-category tags) - // because no disable exists for pep rules. - test("entry dropdowns never offer No entry and pep-affecting controls omit Disabled", () => { - const html = buildConfigurationEditorDocument(); - assert.ok( - !html.includes("[NO_ENTRY].concat"), - "no dropdown may offer a No-entry option", - ); - assert.ok( - html.includes("severityOptions(isPepRule(rule))"), - "rule rows must gate Disabled on pep provenance", - ); - assert.ok( - html.includes("severityOptions(isPepTag(tag))"), - "tag controls must gate Disabled on pep-affecting tags", - ); - assert.ok( - !html.includes("'RemoveRule'"), - "a dropdown change always writes a rule entry — never removes one", - ); - assert.ok( - !html.includes("'RemoveTag'"), - "a dropdown change always writes a tag entry — never removes one", - ); - }); - - // [CONFIGEDITOR-VSIX-EXPERIENCE]: the five navigation views are server-data - // projections; removed preset and Inherit/Native mutation concepts stay out. - test("retains the five views without preset or Inherit/Native UI", () => { - const html = buildConfigurationEditorDocument(); - assert.ok(html.includes("adoption"), "the Adoption view is present"); - assert.ok(html.includes("pathOverrides"), "the Path Overrides view is present"); - assert.ok(html.includes("data-section-target"), "multi-section navigation is present"); - assert.ok(!html.includes("preset"), "preset UI is deleted"); - assert.ok(!html.includes("'Inherit'"), "no Inherit control survives"); - assert.ok(!html.includes("'Native'"), "no Native control survives"); - assert.ok(html.includes("fixSafe"), "the standalone safe-fix action is available"); - }); -}); diff --git a/vscode-extension/src/test/suite/configuration-editor.test.ts b/vscode-extension/src/test/suite/configuration-editor.test.ts deleted file mode 100644 index ffd4bca09..000000000 --- a/vscode-extension/src/test/suite/configuration-editor.test.ts +++ /dev/null @@ -1,1087 +0,0 @@ -// Implements [VSIX-CONFIGURATION-EDITOR] / [CONFIGEDITOR-ACCESSIBILITY-SECURITY]. -/** Contract, thin-shell, security, accessibility, and lifecycle tests. */ - -import { delay } from "../../timeouts"; -import * as assert from "assert"; -import * as fs from "fs"; -import * as os from "os"; -import * as path from "path"; -import * as vscode from "vscode"; -import type { LanguageClient } from "vscode-languageclient/node"; -import type { - ApplyConfigurationRequest, - ConfigurationPreview, - ConfigurationSnapshot, - EditorMutation, - PreviewConfigurationRequest, - RuleOccurrencesRequest, - RuleOccurrencesResponse, - TypeshedActionRequest, - TypeshedActionResult, -} from "../../configuration-editor-model"; -import { - ConfigurationEditorController, - configurationRepairUri, - supportsConfigurationEditor, - type ConfigurationEditorTransport, -} from "../../configuration-editor"; -import { - decodeConfigurationChanged, - IDLE_CONFIGURATION_EDITOR, -} from "../../configuration-editor-state"; -import { decodeConfigurationEditorIntent } from "../../configuration-editor-intents"; -import { readBasiliskSettings } from "../../lsp-client"; -import { createStore } from "../../store"; -import { removeTestDir } from './test-helpers'; -import { cacheFixture, LATEST_COMMIT, typeshedFixture } from "./settings-fixture"; -import { booleanField, recordField } from "../../unknown-shape"; - -const ROOT_URI = "file:///workspace"; -const OTHER_ROOT_URI = "file:///workspace-other"; -const PEP_CODE = "BSK-0001"; -const ANALYZE_CODE = "BSK-0060"; - -/** - * [CONFIGEDITOR-MODEL]: one pep rule (check scope, never disabled) and one - * analyze rule, plus one tag with an explicit `rule-tags` entry. - */ -function configurationSnapshot(revision = "revision-1"): ConfigurationSnapshot { - return { - rootUri: ROOT_URI, - configUri: `${ROOT_URI}/pyproject.toml`, - revision, - rules: [{ - descriptor: { - code: PEP_CODE, - title: "Incompatible assignment", - summary: "Assignments must satisfy the declared type.", - docsUrl: `https://example.test/errors/${PEP_CODE}`, - tags: ["pep", "assignability"], - }, - entry: undefined, - effectiveSeverity: { kind: "Error" }, - diagnosticCount: 3, - }, { - descriptor: { - code: ANALYZE_CODE, - title: "Active code-specific directive", - summary: "Audit inline suppressions.", - docsUrl: `https://example.test/errors/${ANALYZE_CODE}`, - tags: ["basilisk", "suppressions"], - }, - entry: { kind: "Warning" }, - effectiveSeverity: { kind: "Warning" }, - diagnosticCount: 1, - }], - tags: [{ - name: "basilisk", - kind: { kind: "Provenance" }, - entry: { kind: "Error" }, - ruleCount: 1, - diagnosticCount: 1, - }, { - name: "pep", - kind: { kind: "Provenance" }, - entry: undefined, - ruleCount: 1, - diagnosticCount: 3, - }], - source: { - uri: `${ROOT_URI}/pyproject.toml`, - exists: true, - readOnly: false, - }, - pathOverrides: [{ - path: "legacy", - configUri: `${ROOT_URI}/legacy/pyproject.toml`, - rules: [{ code: PEP_CODE, severity: { kind: "Warning" } }], - tags: [], - }], - debt: { - remainingDiagnostics: 4, - errorDiagnostics: 3, - warningDiagnostics: 1, - infoDiagnostics: 0, - adoptedRules: 0, - disabledRules: 0, - }, - problems: [], - typeshed: typeshedFixture(), - cache: cacheFixture(), - }; -} - -function configurationPreview(baseRevision = "revision-1"): ConfigurationPreview { - return { - previewId: "preview-1", - baseRevision, - changes: [{ - code: PEP_CODE, - before: { kind: "Error" }, - after: { kind: "Warning" }, - }], - typeshedChanges: [], - cacheChanges: [], - impact: { - errorsBefore: 3, - errorsAfter: 0, - warningsBefore: 1, - warningsAfter: 4, - infosBefore: 0, - infosAfter: 0, - }, - }; -} - -class RecordingTransport implements ConfigurationEditorTransport { - private snapshotResult = configurationSnapshot(); - public previewResult = configurationPreview(); - public applyResult = configurationSnapshot("revision-2"); - public occurrenceResult: RuleOccurrencesResponse = { items: [], nextCursor: undefined }; - public typeshedActionResult: TypeshedActionResult = { - kind: "Snapshot", - snapshot: configurationSnapshot("revision-typeshed"), - }; - public readonly snapshotRequests: string[] = []; - public readonly previewRequests: PreviewConfigurationRequest[] = []; - public readonly applyRequests: ApplyConfigurationRequest[] = []; - public readonly occurrenceRequests: RuleOccurrencesRequest[] = []; - public readonly typeshedActionRequests: TypeshedActionRequest[] = []; - public previewError: Error | undefined; - public snapshotError: Error | undefined; - public snapshotHandler: ((rootUri: string) => Promise<ConfigurationSnapshot>) | undefined; - public previewHandler: ((request: PreviewConfigurationRequest) => Promise<ConfigurationPreview>) | undefined; - public applyHandler: (() => Promise<ConfigurationSnapshot>) | undefined; - public occurrenceHandler: ((request: RuleOccurrencesRequest) => Promise<RuleOccurrencesResponse>) | undefined; - public typeshedActionHandler: ((request: TypeshedActionRequest) => Promise<TypeshedActionResult>) | undefined; - - /// The snapshot the next `snapshot()` call answers with. A method rather - /// than a public field so swapping it mid-test is one atomic step instead of - /// a read-then-write straddling an `await`. - public useSnapshot(snapshot: ConfigurationSnapshot): void { - this.snapshotResult = snapshot; - } - - public async snapshot(rootUri: string): Promise<ConfigurationSnapshot> { - this.snapshotRequests.push(rootUri); - if (this.snapshotError !== undefined) { throw this.snapshotError; } - if (this.snapshotHandler !== undefined) { return this.snapshotHandler(rootUri); } - return this.snapshotResult; - } - - public async preview(request: PreviewConfigurationRequest): Promise<ConfigurationPreview> { - this.previewRequests.push(request); - if (this.previewError !== undefined) { throw this.previewError; } - if (this.previewHandler !== undefined) { return this.previewHandler(request); } - return this.previewResult; - } - - public async apply(request: ApplyConfigurationRequest): Promise<ConfigurationSnapshot> { - this.applyRequests.push(request); - if (this.applyHandler !== undefined) { return this.applyHandler(); } - return this.applyResult; - } - - public async occurrences(request: RuleOccurrencesRequest): Promise<RuleOccurrencesResponse> { - this.occurrenceRequests.push(request); - if (this.occurrenceHandler !== undefined) { return this.occurrenceHandler(request); } - return this.occurrenceResult; - } - - public async typeshedAction(request: TypeshedActionRequest): Promise<TypeshedActionResult> { - this.typeshedActionRequests.push(request); - if (this.typeshedActionHandler !== undefined) { return this.typeshedActionHandler(request); } - return this.typeshedActionResult; - } - - public readonly executeCommandRequests: { readonly command: string; readonly args: readonly unknown[] }[] = []; - - public async executeCommand(command: string, args: readonly unknown[]): Promise<void> { - this.executeCommandRequests.push({ command, args }); - } -} - -class RevisionConflictError extends Error { - public readonly data = { kind: "revisionConflict" } as const; -} - -class InvalidConfigurationError extends Error { - public readonly data: unknown; - - constructor(sourceUri: string) { - super("The rules table is malformed"); - this.data = { kind: "invalidConfiguration", context: { sourceUri } }; - } -} - -function occurrence(line: number): RuleOccurrencesResponse["items"][number] { - return { - code: PEP_CODE, - uri: `${ROOT_URI}/source.py`, - range: { start: { line, character: 0 }, end: { line, character: 1 } }, - severity: { kind: "Error" }, - }; -} - -async function pollUntil(predicate: () => boolean, timeoutMs = 5_000): Promise<void> { - const deadline = Date.now() + timeoutMs; - while (!predicate() && Date.now() < deadline) { - await delay(25); - } - assert.ok(predicate(), "condition did not become true before timeout"); -} - -suite("Configuration editor — generated contract and central state", () => { - // [CONFIGEDITOR-MODEL]: snapshot carries rule entries + effective severity; - // preview is the resolved changes plus the errors/warnings/infos partition. - test("stores snapshots and exact previews without inventing configuration state", () => { - const store = createStore(); - store.beginConfigurationLoad(ROOT_URI); - assert.strictEqual(store.configurationEditor.value.phase, "loading"); - store.acceptConfigurationSnapshot(configurationSnapshot()); - assert.strictEqual(store.configurationEditor.value.snapshot?.rules[0]?.descriptor.code, PEP_CODE); - assert.strictEqual(store.configurationEditor.value.snapshot?.rules[1]?.entry?.kind, "Warning"); - assert.strictEqual(store.configurationEditor.value.snapshot?.tags[0]?.entry?.kind, "Error"); - - store.beginConfigurationPreview(); - store.acceptConfigurationPreview(configurationPreview()); - assert.strictEqual(store.configurationEditor.value.phase, "preview"); - assert.deepStrictEqual( - store.configurationEditor.value.preview?.changes.map((change) => change.code), - [PEP_CODE], - ); - - store.markConfigurationChanged({ rootUri: "file:///other", revision: "r2" }); - assert.strictEqual(store.configurationEditor.value.refreshRequested, false); - store.markConfigurationChanged({ rootUri: ROOT_URI, revision: "revision-2" }); - assert.strictEqual(store.configurationEditor.value.refreshRequested, true); - store.resetConfigurationEditor(); - assert.deepStrictEqual(store.configurationEditor.value, IDLE_CONFIGURATION_EDITOR); - }); - - // [LSPARCH-CONFIG-EDITOR-PROTOCOL]: configurationChanged is rootUri + - // revision — nothing else (no reason field survives the redesign). - test("validates server invalidations before shared state consumes them", () => { - assert.deepStrictEqual( - decodeConfigurationChanged({ rootUri: ROOT_URI, revision: "r2" }), - { rootUri: ROOT_URI, revision: "r2" }, - ); - assert.strictEqual(decodeConfigurationChanged({ rootUri: ROOT_URI, revision: 2 }), undefined); - assert.strictEqual(decodeConfigurationChanged(null), undefined); - }); -}); - -suite("Configuration editor — typed mutation routing", () => { - // [CONFIGEDITOR-OPERATIONS] / [CHKARCH-CONFIG-MODEL]: the editor can request - // exactly six things — rule/tag set/remove plus allowlisted Typeshed setting - // set/remove. Each is - // relayed verbatim through preview, and apply sends only root + preview id. - test("relays each of the six EditorMutation kinds verbatim through preview", async () => { - const ruleMutations: EditorMutation[] = [ - { kind: "SetRule", code: PEP_CODE, severity: { kind: "Warning" } }, - { kind: "RemoveRule", code: ANALYZE_CODE }, - { kind: "SetTag", tag: "basilisk", severity: { kind: "Info" } }, - { kind: "RemoveTag", tag: "basilisk" }, - ]; - const typeshedMutations: EditorMutation[] = [ - { kind: "SetTypeshedSetting", key: { kind: "TypeshedStorePath" }, value: "/stores/typeshed" }, - { kind: "RemoveTypeshedSetting", key: { kind: "TypeshedCommit" } }, - ]; - for (const mutation of [...ruleMutations, ...typeshedMutations]) { - const store = createStore(); - const transport = new RecordingTransport(); - const controller = new ConfigurationEditorController(store, transport); - const typeshed = typeshedMutations.includes(mutation); - try { - controller.open(ROOT_URI); - await pollUntil(() => store.configurationEditor.value.phase === "ready"); - await controller.receive({ type: "preview", mutations: [mutation] }); - assert.deepStrictEqual(transport.previewRequests, [{ - rootUri: ROOT_URI, - baseRevision: "revision-1", - mutations: [mutation], - }], `${mutation.kind} must be relayed without translation`); - // A rule/tag change costs an impact review; a Typeshed edit is a - // direct source switch and lands at once ([LSPCFGED-TYPESHED]). - assert.strictEqual( - store.configurationEditor.value.phase, - typeshed ? "ready" : "preview", - `${mutation.kind} must ${typeshed ? "apply immediately" : "wait for review"}`, - ); - assert.deepStrictEqual( - transport.applyRequests, - typeshed ? [{ rootUri: ROOT_URI, previewId: "preview-1" }] : [], - `${mutation.kind} apply must carry only root + preview id`, - ); - assert.strictEqual( - store.configurationEditor.value.snapshot?.revision, - typeshed ? "revision-2" : "revision-1", - `${mutation.kind} must leave the snapshot the server returned`, - ); - assert.strictEqual(store.configurationEditor.value.preview, typeshed ? undefined : transport.previewResult); - } finally { - controller.dispose(); - } - } - }); - - 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: "DownloadPinned" }); - assert.deepStrictEqual(transport.typeshedActionRequests, [{ - rootUri: ROOT_URI, - baseRevision: "revision-1", - action: { kind: "DownloadPinned" }, - }]); - assert.strictEqual(store.configurationEditor.value.snapshot?.revision, "revision-typeshed"); - } finally { - controller.dispose(); - } - }); - - // A same-root refresh no longer drops the action result (the action's - // snapshot is authoritative for its root). What MUST still be dropped is an - // action whose root the panel has abandoned mid-flight ([LSPCFGED-TYPESHED-DOWNLOAD]). - test("drops a Typeshed action response after the panel moves to another workspace root", async () => { - const store = createStore(); - const transport = new RecordingTransport(); - let finishAction: ((result: TypeshedActionResult) => void) | undefined; - transport.typeshedActionHandler = async () => new Promise((resolve) => { finishAction = resolve; }); - const controller = new ConfigurationEditorController(store, transport); - try { - controller.open(ROOT_URI); - await pollUntil(() => store.configurationEditor.value.phase === "ready"); - const action = controller.receive({ type: "typeshedAction", action: "DownloadLatest" }); - await pollUntil(() => transport.typeshedActionRequests.length === 1); - // The user navigates to a DIFFERENT root while the download runs. - store.beginConfigurationLoad(OTHER_ROOT_URI); - finishAction?.({ kind: "Snapshot", snapshot: configurationSnapshot("revision-stale-action") }); - await action; - const settled = store.configurationEditor.value; - assert.strictEqual(settled.rootUri, OTHER_ROOT_URI, "the panel must stay on the navigated-to root"); - assert.strictEqual(settled.snapshot, undefined, "the abandoned root's snapshot must not land"); - } finally { - controller.dispose(); - } - }); - - // The reported failure ("I tapped Download pinned and it didn't do shit"): - // the server sends the transient Downloading status BEFORE it downloads, and - // that notification triggers a snapshot refresh which bumps the load - // generation — so when the download subsequently FAILED, the stale-generation - // guard swallowed the error and the panel silently snapped back to NO - // SOURCE. A failed download must never be indistinguishable from a dead - // button: the failure must reach the user regardless of any racing refresh. - test("a Typeshed download failure is surfaced even when a status refresh raced the action", async () => { - const store = createStore(); - const transport = new RecordingTransport(); - let failAction: ((error: Error) => void) | undefined; - transport.typeshedActionHandler = async () => - new Promise((_resolve, reject) => { failAction = reject; }); - const shownErrors: string[] = []; - const originalShowError = vscode.window.showErrorMessage; - (vscode.window as { showErrorMessage: unknown }).showErrorMessage = async ( - message: string, - ): Promise<undefined> => { shownErrors.push(message); return undefined; }; - const controller = new ConfigurationEditorController(store, transport); - try { - controller.open(ROOT_URI); - await pollUntil(() => store.configurationEditor.value.phase === "ready"); - const action = controller.receive({ type: "typeshedAction", action: "DownloadPinned" }); - await pollUntil(() => transport.typeshedActionRequests.length === 1); - // The server's Downloading notification triggers exactly this refresh - // while the download is still running ([LSPCFGED-TYPESHED-DOWNLOAD]). - await controller.receive({ type: "refresh" }); - failAction?.(new Error("the typeshed download failed: connection reset")); - await action; - assert.strictEqual(shownErrors.length, 1, "the download failure must be shown to the user"); - assert.ok( - shownErrors[0]?.includes("connection reset"), - `the shown error must carry the failure reason: ${shownErrors[0] ?? "<none>"}`, - ); - } finally { - (vscode.window as { showErrorMessage: unknown }).showErrorMessage = originalShowError; - controller.dispose(); - } - }); - -}); - -suite("Configuration editor — Typeshed download snapshot authority", () => { - // The reported failure ("I downloaded the latest and it's still saying it's - // not pinned"): the server emits the transient Downloading status BEFORE the - // download finishes, and that notification triggers a SAME-ROOT snapshot - // refresh which bumps the load generation. The action then resolves with the - // AUTHORITATIVE post-download snapshot — the pin is written and the source is - // the freshly resolved commit (the server builds this snapshot LAST, after - // download_latest_and_pin lands) — but the stale-generation guard discarded - // it, so the panel stayed on the pre-download bundled/unpinned snapshot - // forever. A download's own returned snapshot is the freshest word for its - // root and must survive a refresh the download itself triggered - // ([LSPCFGED-TYPESHED-DOWNLOAD]). - test("a Download latest lands its pinned snapshot even when its own Downloading refresh raced it", async () => { - const store = createStore(); - const transport = new RecordingTransport(); - let finishAction: ((result: TypeshedActionResult) => void) | undefined; - transport.typeshedActionHandler = async () => new Promise((resolve) => { finishAction = resolve; }); - const controller = new ConfigurationEditorController(store, transport); - try { - controller.open(ROOT_URI); - await pollUntil(() => store.configurationEditor.value.phase === "ready"); - const action = controller.receive({ type: "typeshedAction", action: "DownloadLatest" }); - await pollUntil(() => transport.typeshedActionRequests.length === 1); - // The server's transient Downloading notification triggers exactly this - // same-root refresh while the download is still running; it fetches the - // pre-pin (still bundled/unpinned) snapshot and bumps the load generation. - transport.useSnapshot({ - ...configurationSnapshot("revision-downloading"), - typeshed: typeshedFixture({ downloading: true }), - }); - await controller.receive({ type: "refresh" }); - // The download finishes: the pin is written and the server returns the - // authoritative Ready snapshot pinned to the resolved commit. - finishAction?.({ - kind: "Snapshot", - snapshot: { - ...configurationSnapshot("revision-pinned"), - typeshed: typeshedFixture({ source: { kind: "ExactCommit", commit: LATEST_COMMIT } }), - }, - }); - await action; - assert.strictEqual( - store.configurationEditor.value.snapshot?.revision, - "revision-pinned", - "the authoritative post-download snapshot must replace the raced Downloading refresh", - ); - assert.deepStrictEqual( - store.configurationEditor.value.snapshot?.typeshed.source, - { kind: "ExactCommit", commit: LATEST_COMMIT }, - "the panel must show the freshly pinned commit, not the pre-download bundled default", - ); - } finally { - controller.dispose(); - } - }); -}); - -suite("Configuration editor — Typeshed action failure classification", () => { - test("a Typeshed revision conflict routes to the soft conflict phase and pops no hard error toast", async () => { - const store = createStore(); - const transport = new RecordingTransport(); - // A revision conflict is a retryable state, not a failure: the base - // revision moved under the action. It must NOT surface as a hard error - // toast — only genuine failures do ([CONFIGEDITOR-VSIX-EXPERIENCE]). - transport.typeshedActionHandler = async () => - Promise.reject( - Object.assign(new Error("configuration changed since preview"), { - data: { kind: "revisionConflict" }, - }), - ); - const shownErrors: string[] = []; - const originalShowError = vscode.window.showErrorMessage; - (vscode.window as { showErrorMessage: unknown }).showErrorMessage = async ( - message: string, - ): Promise<undefined> => { shownErrors.push(message); return undefined; }; - const controller = new ConfigurationEditorController(store, transport); - try { - controller.open(ROOT_URI); - await pollUntil(() => store.configurationEditor.value.phase === "ready"); - await controller.receive({ type: "typeshedAction", action: "DownloadPinned" }); - await pollUntil(() => store.configurationEditor.value.phase === "conflict"); - assert.strictEqual( - shownErrors.length, - 0, - `a revision conflict must not pop a hard error toast: ${shownErrors[0] ?? "<none>"}`, - ); - } finally { - (vscode.window as { showErrorMessage: unknown }).showErrorMessage = originalShowError; - controller.dispose(); - } - }); -}); - -suite("Configuration editor — direct Typeshed writes and discarded previews", () => { - // The reported failure: a dismissed dialog left the control showing a value - // the configuration never held. Discarding must restore the snapshot state - // and write nothing at all. - test("discarding a preview writes nothing and returns to the snapshot", 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: "preview", - mutations: [{ kind: "SetRule", code: PEP_CODE, severity: { kind: "Warning" } }], - }); - assert.strictEqual(store.configurationEditor.value.phase, "preview"); - await controller.receive({ type: "cancelPreview" }); - assert.strictEqual(store.configurationEditor.value.phase, "ready"); - assert.strictEqual(store.configurationEditor.value.preview, undefined); - assert.strictEqual(store.configurationEditor.value.snapshot?.revision, "revision-1"); - assert.deepStrictEqual(transport.applyRequests, [], "a discarded preview must never be applied"); - // A later apply cannot resurrect the discarded change. - await controller.receive({ type: "apply" }); - assert.deepStrictEqual(transport.applyRequests, []); - assert.strictEqual(store.configurationEditor.value.phase, "ready"); - } finally { - controller.dispose(); - } - }); - - // 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: "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: "DownloadLatest" }); - assert.deepStrictEqual(transport.typeshedActionRequests, [{ - rootUri: ROOT_URI, - baseRevision: "revision-1", - action: { kind: "DownloadLatest" }, - }]); - 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(); - } - }); - -}); - -suite("Configuration editor — project action routing", () => { - // [CONFIGEDITOR-VSIX-EXPERIENCE]: the Adoption view forwards the real, - // already-registered adopt command (all-roots; no args) then reloads. - test("the Adoption view forwards basilisk.adoptWorkspace and reloads", 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"); - const snapshotsBefore = transport.snapshotRequests.length; - await controller.receive({ type: "adopt", scope: "workspace" }); - assert.deepStrictEqual(transport.executeCommandRequests, [{ command: "basilisk.adoptWorkspace", args: [] }]); - assert.ok(transport.snapshotRequests.length > snapshotsBefore, "adopt must reload the snapshot"); - } finally { - controller.dispose(); - } - }); - - // [CONFIGEDITOR-VSIX-EXPERIENCE]: "Apply safe fixes" forwards the real fix - // command, which requires the root URI, then reloads. - test("the Adoption view forwards basilisk.fixWorkspace with the root uri and reloads", 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"); - const snapshotsBefore = transport.snapshotRequests.length; - await controller.receive({ type: "fixSafe" }); - assert.deepStrictEqual( - transport.executeCommandRequests, - [{ command: "basilisk.fixWorkspace", args: [{ rootUri: ROOT_URI }] }], - ); - assert.ok(transport.snapshotRequests.length > snapshotsBefore, "a safe fix must reload the snapshot"); - } finally { - controller.dispose(); - } - }); - - // Untrusted webview input hardening for the restored view intents: the - // decoder accepts exactly the shapes the views emit and rejects the rest. - test("decodes the restored view intents and rejects malformed ones", () => { - assert.deepStrictEqual( - decodeConfigurationEditorIntent({ type: "adopt", scope: "workspace" }), - { type: "adopt", scope: "workspace" }, - ); - assert.strictEqual(decodeConfigurationEditorIntent({ type: "adopt", scope: "file" }), undefined); - assert.strictEqual(decodeConfigurationEditorIntent({ type: "adopt" }), undefined); - assert.deepStrictEqual(decodeConfigurationEditorIntent({ type: "fixSafe" }), { type: "fixSafe" }); - assert.deepStrictEqual( - decodeConfigurationEditorIntent({ type: "openConfigFile", uri: `${ROOT_URI}/legacy/pyproject.toml` }), - { type: "openConfigFile", uri: `${ROOT_URI}/legacy/pyproject.toml` }, - ); - assert.strictEqual(decodeConfigurationEditorIntent({ type: "openConfigFile" }), undefined); - assert.strictEqual(decodeConfigurationEditorIntent({ type: "openConfigFile", uri: "" }), undefined); - }); - - // [CONFIGEDITOR-OPERATIONS]: rootUri + previewId fully identify the cached - // preview; the preview pins its own base revision, so apply carries none. - test("applies a preview with only rootUri and previewId", 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: "preview", - mutations: [{ kind: "SetRule", code: PEP_CODE, severity: { kind: "Warning" } }], - }); - await controller.receive({ type: "apply" }); - assert.deepStrictEqual(transport.applyRequests, [{ - rootUri: ROOT_URI, - previewId: "preview-1", - }]); - assert.strictEqual(store.configurationEditor.value.snapshot?.revision, "revision-2"); - } finally { - controller.dispose(); - } - }); - - // [VSIX-CONFIGURATION-EDITOR-THIN-SHELL]: legacy selector-based mutations, - // Inherit/Native settings, and scopes are no longer decodable intent. - test("rejects legacy selector/setting/scope mutation payloads outright", 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: "preview", - mutations: [{ selector: { kind: "All" }, setting: { kind: "Native" }, scope: { kind: "Project" } }], - }); - await controller.receive({ - type: "preview", - mutations: [{ kind: "SetRule", code: PEP_CODE, severity: { kind: "Inherit" } }], - }); - await controller.receive({ type: "preview", mutations: [] }); - await controller.receive({ type: "fixSafe" }); - assert.strictEqual(transport.previewRequests.length, 0); - } finally { - controller.dispose(); - } - }); -}); - -suite("Configuration editor — thin LSP shell", () => { - // [CONFIGEDITOR-OPERATIONS]: cursor-paged occurrences over the read-side - // all/codes/tags selectors; navigation is allowlisted to loaded items. - test("routes paged occurrence reads and ignores invalid or untrusted navigation", 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"); - transport.occurrenceResult = { - items: Array.from({ length: 100 }, (_unused, line) => occurrence(line)), - nextCursor: "100", - }; - await controller.receive({ - type: "occurrences", selector: { kind: "Codes", codes: [PEP_CODE] }, cursor: undefined, limit: 100, - }); - transport.occurrenceResult = { items: [occurrence(100)], nextCursor: undefined }; - await controller.receive({ - type: "occurrences", selector: { kind: "Codes", codes: [PEP_CODE] }, cursor: "100", limit: 100, - }); - assert.strictEqual(store.configurationEditor.value.occurrences?.items.length, 101); - assert.strictEqual(store.configurationEditor.value.occurrences?.nextCursor, undefined); - assert.deepStrictEqual(transport.occurrenceRequests.map((request) => request.cursor), [undefined, "100"]); - await controller.receive({ type: "preview", mutations: "not-an-array" }); - await controller.receive({ type: "openDocs", uri: "https://attacker.invalid" }); - await controller.receive({ type: "openOccurrence", uri: "file:///etc/passwd", line: 0, character: 0 }); - assert.strictEqual(transport.previewRequests.length, 0); - } finally { - controller.dispose(); - } - }); - - test("drops stale occurrence responses and resets loading for a new selector", async () => { - const store = createStore(); - const transport = new RecordingTransport(); - const pending: ((response: RuleOccurrencesResponse) => void)[] = []; - transport.occurrenceHandler = async () => new Promise((resolve) => { pending.push(resolve); }); - const controller = new ConfigurationEditorController(store, transport); - try { - controller.open(ROOT_URI); - await pollUntil(() => store.configurationEditor.value.phase === "ready"); - const stale = controller.receive({ - type: "occurrences", selector: { kind: "Codes", codes: [PEP_CODE] }, cursor: undefined, limit: 100, - }); - const newest = controller.receive({ - type: "occurrences", selector: { kind: "Tags", tags: ["pep"], matchAll: false }, cursor: undefined, limit: 100, - }); - await pollUntil(() => pending.length === 2); - pending[1]?.({ items: [occurrence(9)], nextCursor: undefined }); - await newest; - pending[0]?.({ items: [occurrence(1)], nextCursor: "100" }); - await stale; - assert.deepStrictEqual(store.configurationEditor.value.occurrences?.items, [occurrence(9)]); - assert.strictEqual(store.configurationEditor.value.occurrencesLoading, false); - } finally { - controller.dispose(); - } - }); -}); - -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 custom = { - ...configurationSnapshot("revision-1"), - typeshed: typeshedFixture({ source: { kind: "CustomFolder", path: "/workspace/vendor/typeshed" } }), - }; - const pinned = { - ...configurationSnapshot("revision-2"), - typeshed: typeshedFixture(), - }; - transport.useSnapshot(custom); - transport.previewResult = { ...configurationPreview(), typeshedChanges: [] }; - let finishApply: ((snapshot: ConfigurationSnapshot) => void) | undefined; - transport.applyHandler = async () => new Promise<ConfigurationSnapshot>((resolve) => { finishApply = resolve; }); - const controller = new ConfigurationEditorController(store, transport); - try { - controller.open(ROOT_URI); - await pollUntil(() => store.configurationEditor.value.phase === "ready"); - - const choosePinned = controller.receive({ - type: "preview", - mutations: [ - { kind: "RemoveTypeshedSetting", key: { kind: "TypeshedPath" } }, - ], - }); - await pollUntil(() => transport.applyRequests.length === 1); - - store.markConfigurationChanged({ rootUri: ROOT_URI, revision: "revision-2" }); - await pollUntil(() => transport.snapshotRequests.length === 2); - 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, "ExactCommit"); - } finally { - controller.dispose(); - } - }); - - test("keeps the newest preview and submits an applying preview only once", async () => { - const store = createStore(); - const transport = new RecordingTransport(); - const pending: ((preview: ConfigurationPreview) => void)[] = []; - transport.previewHandler = async () => new Promise<ConfigurationPreview>((resolve) => { pending.push(resolve); }); - const controller = new ConfigurationEditorController(store, transport); - try { - controller.open(ROOT_URI); - await pollUntil(() => store.configurationEditor.value.phase === "ready"); - const first = controller.receive({ - type: "preview", - mutations: [{ kind: "SetTag", tag: "basilisk", severity: { kind: "Warning" } }], - }); - const second = controller.receive({ - type: "preview", - mutations: [{ kind: "SetTag", tag: "basilisk", severity: { kind: "Error" } }], - }); - await pollUntil(() => pending.length === 2); - pending[1]?.({ ...configurationPreview(), previewId: "newest" }); - await second; - pending[0]?.({ ...configurationPreview(), previewId: "stale" }); - await first; - assert.strictEqual(store.configurationEditor.value.preview?.previewId, "newest"); - - let finishApply: ((snapshot: ConfigurationSnapshot) => void) | undefined; - transport.applyHandler = async () => new Promise<ConfigurationSnapshot>((resolve) => { finishApply = resolve; }); - const apply = controller.receive({ type: "apply" }); - await pollUntil(() => transport.applyRequests.length === 1); - await controller.receive({ type: "apply" }); - assert.strictEqual(transport.applyRequests.length, 1, "an applying preview cannot be submitted twice"); - finishApply?.(configurationSnapshot("revision-2")); - await apply; - assert.strictEqual(store.configurationEditor.value.snapshot?.revision, "revision-2"); - } finally { - controller.dispose(); - } - }); - - test("refreshes the active root after a validated server invalidation", async () => { - const store = createStore(); - const transport = new RecordingTransport(); - const controller = new ConfigurationEditorController(store, transport); - try { - controller.open(ROOT_URI); - await pollUntil(() => transport.snapshotRequests.length === 1); - transport.useSnapshot(configurationSnapshot("revision-2")); - store.markConfigurationChanged({ rootUri: ROOT_URI, revision: "revision-2" }); - await pollUntil(() => transport.snapshotRequests.length === 2); - await pollUntil(() => store.configurationEditor.value.snapshot?.revision === "revision-2"); - } finally { - controller.dispose(); - } - }); - - test("replays an invalidation that arrives while the same root is loading", async () => { - const store = createStore(); - const transport = new RecordingTransport(); - const pending: ((snapshot: ConfigurationSnapshot) => void)[] = []; - const controller = new ConfigurationEditorController(store, transport); - try { - controller.open(ROOT_URI); - await pollUntil(() => store.configurationEditor.value.phase === "ready"); - transport.snapshotHandler = async () => new Promise((resolve) => { pending.push(resolve); }); - - const refresh = controller.receive({ type: "refresh" }); - await pollUntil(() => transport.snapshotRequests.length === 2); - store.markConfigurationChanged({ rootUri: ROOT_URI, revision: "revision-3" }); - pending[0]?.(configurationSnapshot("revision-2")); - await refresh; - - await pollUntil(() => transport.snapshotRequests.length === 3); - pending[1]?.(configurationSnapshot("revision-3")); - await pollUntil(() => store.configurationEditor.value.snapshot?.revision === "revision-3"); - } finally { - controller.dispose(); - } - }); -}); - -interface ScratchConfigWorkspace { - readonly rootUri: string; - readonly configUri: vscode.Uri; - readonly configPath: string; - readonly appliedToml: string; - snapshot(revision: string): ConfigurationSnapshot; - dispose(): void; -} - -/** Real on-disk pyproject.toml scratch root, isolated from the fixture workspace. */ -function createScratchConfigWorkspace(): ScratchConfigWorkspace { - const scratchRoot = fs.mkdtempSync(path.join(os.tmpdir(), "bsk-config-apply-")); - const configPath = path.join(scratchRoot, "pyproject.toml"); - fs.writeFileSync(configPath, '[project]\nname = "demo"\n'); - const configUri = vscode.Uri.file(configPath); - const rootUri = vscode.Uri.file(scratchRoot).toString(); - return { - rootUri, - configUri, - configPath, - appliedToml: '[project]\nname = "demo"\n\n[tool.basilisk.rules]\n"BSK-0001" = "warning"\n', - snapshot: (revision: string): ConfigurationSnapshot => ({ - ...configurationSnapshot(revision), - rootUri, - configUri: configUri.toString(), - }), - dispose: (): void => { removeTestDir(scratchRoot); }, - }; -} - -/** The exact client-side effect vscode-languageclient produces for the server's workspace/applyEdit. */ -async function applyWholeDocumentEdit(target: vscode.Uri, newText: string): Promise<void> { - const document = await vscode.workspace.openTextDocument(target); - const replacement = new vscode.WorkspaceEdit(); - replacement.replace( - target, - new vscode.Range(new vscode.Position(0, 0), document.positionAt(document.getText().length)), - newText, - ); - assert.ok(await vscode.workspace.applyEdit(replacement), "harness workspace edit must apply"); -} - -suite("Configuration editor — apply persistence", () => { - // Implements [CONFIGEDITOR-SOURCES]: the server keeps its closed-source - // overlay only "until the client write is visible on disk", so a successful - // apply must actually reach disk. vscode.workspace.applyEdit (what - // vscode-languageclient runs for the server's workspace/applyEdit) only - // edits the in-memory buffer — the applied change must not die there. - test("apply persists the configuration edit to disk instead of leaving a dirty buffer", async () => { - const scratch = createScratchConfigWorkspace(); - const store = createStore(); - const transport = new RecordingTransport(); - transport.useSnapshot(scratch.snapshot("revision-1")); - transport.applyHandler = async () => { - await applyWholeDocumentEdit(scratch.configUri, scratch.appliedToml); - return scratch.snapshot("revision-2"); - }; - const controller = new ConfigurationEditorController(store, transport); - try { - controller.open(scratch.rootUri); - await pollUntil(() => store.configurationEditor.value.phase === "ready"); - await controller.receive({ - type: "preview", - mutations: [{ kind: "SetRule", code: PEP_CODE, severity: { kind: "Warning" } }], - }); - await pollUntil(() => store.configurationEditor.value.phase === "preview"); - await controller.receive({ type: "apply" }); - await pollUntil(() => store.configurationEditor.value.snapshot?.revision === "revision-2"); - - const document = await vscode.workspace.openTextDocument(scratch.configUri); - assert.strictEqual(document.isDirty, false, "apply must not strand pyproject.toml as a dirty buffer"); - assert.strictEqual( - fs.readFileSync(scratch.configPath, "utf8"), - scratch.appliedToml, - "the applied configuration must be visible on disk", - ); - } finally { - controller.dispose(); - scratch.dispose(); - } - }); -}); - -suite("Configuration editor — conflicts, capability, and lifecycle", () => { - test("carries only an allowlisted root config URI into invalid-config recovery", async () => { - assert.strictEqual( - configurationRepairUri(`${ROOT_URI}/pyproject.toml`, ROOT_URI), - `${ROOT_URI}/pyproject.toml`, - ); - assert.strictEqual(configurationRepairUri("file:///etc/passwd", ROOT_URI), undefined); - assert.strictEqual(configurationRepairUri(`${ROOT_URI}/nested/pyproject.toml`, ROOT_URI), undefined); - assert.strictEqual(configurationRepairUri(`${ROOT_URI}/basilisk.json`, ROOT_URI), undefined); - assert.strictEqual(configurationRepairUri("https://attacker.invalid/basilisk.json", ROOT_URI), undefined); - - const store = createStore(); - const transport = new RecordingTransport(); - transport.snapshotError = new InvalidConfigurationError(`${ROOT_URI}/pyproject.toml`); - const controller = new ConfigurationEditorController(store, transport); - try { - controller.open(ROOT_URI); - await pollUntil(() => store.configurationEditor.value.phase === "error"); - assert.strictEqual(store.configurationEditor.value.repairUri, `${ROOT_URI}/pyproject.toml`); - assert.strictEqual(store.configurationEditor.value.snapshot, undefined); - } finally { - controller.dispose(); - } - }); - - test("uses structured JSON-RPC data to identify a revision conflict", async () => { - const store = createStore(); - const transport = new RecordingTransport(); - transport.previewError = new RevisionConflictError("The write was rejected"); - const controller = new ConfigurationEditorController(store, transport); - try { - controller.open(ROOT_URI); - await pollUntil(() => store.configurationEditor.value.phase === "ready"); - await controller.receive({ - type: "preview", - mutations: [{ kind: "RemoveTag", tag: "basilisk" }], - }); - assert.strictEqual(store.configurationEditor.value.phase, "conflict"); - assert.strictEqual(store.configurationEditor.value.message, "The write was rejected"); - } finally { - controller.dispose(); - } - }); - - // [LSPARCH-CONFIG-EDITOR-PROTOCOL] / [VSIX-CONFIGURATION-EDITOR]: the - // capability is pure presence — the editor ships with the server, so there - // is no protocol version to negotiate. - test("gates on presence of the experimental capability", () => { - function clientWithCapability(configurationEditor: unknown): LanguageClient { - // A stand-in for the members the code under test calls. No runtime check - // can produce the rest of `LanguageClient`, so the test double itself is - // the one assertion here — it is not a payload being read. - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- see above. - return { - initializeResult: { - capabilities: { experimental: { basilisk: { configurationEditor } } }, - }, - } as unknown as LanguageClient; - } - assert.strictEqual(supportsConfigurationEditor(clientWithCapability(true)), true); - assert.strictEqual(supportsConfigurationEditor(clientWithCapability({})), true); - assert.strictEqual(supportsConfigurationEditor(clientWithCapability(false)), false); - assert.strictEqual(supportsConfigurationEditor(clientWithCapability(undefined)), false); - assert.strictEqual(supportsConfigurationEditor(clientWithCapability(null)), false); - assert.strictEqual(supportsConfigurationEditor(undefined), false); - }); - - test("capability loss clears stale configuration and invalidates occurrence loading", async () => { - const store = createStore(); - const controller = new ConfigurationEditorController(store, new RecordingTransport()); - try { - controller.open(ROOT_URI); - await pollUntil(() => store.configurationEditor.value.phase === "ready"); - store.beginRuleOccurrences(false); - controller.capabilityLost("Capability lost"); - assert.strictEqual(store.configurationEditor.value.phase, "unsupported"); - assert.strictEqual(store.configurationEditor.value.rootUri, ROOT_URI); - assert.strictEqual(store.configurationEditor.value.snapshot, undefined); - assert.strictEqual(store.configurationEditor.value.occurrencesLoading, false); - } finally { - controller.dispose(); - } - }); - - test("binds one webview message handler across singleton re-renders", async function () { - this.timeout(15_000); - const store = createStore(); - const transport = new RecordingTransport(); - const controller = new ConfigurationEditorController(store, transport); - try { - controller.open(ROOT_URI); - await pollUntil(() => controller.readyMessageCount() >= 1, 10_000); - controller.open(ROOT_URI); - await pollUntil(() => controller.readyMessageCount() >= 2, 10_000); - await delay(500); - assert.strictEqual(controller.readyMessageCount(), 2, "a stacked handler would deliver the second ready twice"); - assert.strictEqual(controller.isOpen(), true); - await vscode.commands.executeCommand("workbench.action.closeActiveEditor"); - await pollUntil(() => !controller.isOpen()); - assert.deepStrictEqual( - store.configurationEditor.value, - IDLE_CONFIGURATION_EDITOR, - "closing the tab must not retain a hidden configuration snapshot", - ); - } finally { - controller.dispose(); - } - assert.strictEqual(controller.isOpen(), false); - }); -}); - -suite("Configuration editor — diagnostic scope setting relay", () => { - // [LSPARCH-DIAGNOSTIC-SCOPE]: `basilisk.analyze` is a per-user editor - // setting relayed as initializationOptions.basilisk.analyze — it restricts - // publication to check scope and never touches project configuration. - test("basilisk.analyze defaults to true and is relayed under initializationOptions.basilisk", async () => { - const cfg = vscode.workspace.getConfiguration("basilisk"); - const inspected = cfg.inspect<boolean>("analyze"); - assert.strictEqual(inspected?.defaultValue, true, "package.json must declare the default"); - - const relayedDefault = readBasiliskSettings(); - assert.strictEqual(booleanField(recordField(relayedDefault, "basilisk"), "analyze"), true); - - try { - await cfg.update("analyze", false, vscode.ConfigurationTarget.Workspace); - const relayed = readBasiliskSettings(); - assert.strictEqual( - booleanField(recordField(relayed, "basilisk"), "analyze"), - false, - "the opt-out must reach the LSP payload", - ); - } finally { - await cfg.update("analyze", undefined, vscode.ConfigurationTarget.Workspace); - } - }); -}); diff --git a/vscode-extension/src/test/suite/dap-evaluate.test.ts b/vscode-extension/src/test/suite/dap-evaluate.test.ts deleted file mode 100644 index 8eed2e9f1..000000000 --- a/vscode-extension/src/test/suite/dap-evaluate.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -// Tests for [PROFILE-MEMORY-COURIER]. See docs/specs/LSP-PROFILING-SPEC.md#PROFILE-MEMORY-COURIER -// -// debugpy truncates a single `print()` at ~20 KB, which silently corrupts the -// large JSON a real tracemalloc snapshot produces. The injection scripts now -// write their `__BASILISK_MEM*__ + json` payload to a temp file and print only -// `__BASILISK_MEM_FILE__<path>`; the editor reads the file back. These tests -// drive that resolver over real files (no mocks). - -import * as assert from "assert"; -import * as fs from "fs"; -import * as os from "os"; -import * as path from "path"; -import { resolveMarkerFilePayload } from "../../dap-evaluate"; -import { removeTestDir } from './test-helpers'; - -suite("Memory courier — large payload file handoff", () => { - test("a file-handoff marker is replaced by the file's full contents, then cleaned up", async () => { - // 50 KB — comfortably past the ~20 KB stdout cap that truncated snapshots. - // mkdtempSync gives a private, unpredictable dir (no insecure temp-file race). - const payload = `__BASILISK_MEM__${JSON.stringify({ stats: "x".repeat(50_000) })}`; - const dir = fs.mkdtempSync(path.join(os.tmpdir(), "basilisk-courier-")); - const file = path.join(dir, "payload.txt"); - fs.writeFileSync(file, payload, "utf8"); - - const resolved = await resolveMarkerFilePayload(`__BASILISK_MEM_FILE__${file}\n`); - - assert.strictEqual(resolved, payload, "the full payload must return intact from the file"); - assert.ok(!fs.existsSync(file), "the temp payload file must be removed after reading"); - removeTestDir(dir); - }); - - test("output without a file marker passes through unchanged (CPU acks, OK markers)", async () => { - const direct = "__BASILISK_CPU_ACK__ok"; - assert.strictEqual(await resolveMarkerFilePayload(direct), direct); - }); - - test("a missing payload file falls back to the raw line instead of throwing", async () => { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), "basilisk-courier-")); - const missing = path.join(dir, "absent.txt"); - const raw = `__BASILISK_MEM_FILE__${missing}\n`; - assert.strictEqual(await resolveMarkerFilePayload(raw), raw); - removeTestDir(dir); - }); -}); diff --git a/vscode-extension/src/test/suite/dap-proxy.test.ts b/vscode-extension/src/test/suite/dap-proxy.test.ts deleted file mode 100644 index ea4fbb70f..000000000 --- a/vscode-extension/src/test/suite/dap-proxy.test.ts +++ /dev/null @@ -1,159 +0,0 @@ -// Tests for [PROFILE-LAUNCH-NOSTOP]. See docs/specs/LSP-PROFILING-SPEC.md#PROFILE-LAUNCH-NOSTOP -// -// A "Run & Profile CPU (Current File)" launch sets `profileOnLaunch` and must -// run to completion — it must NOT present as an interactive debug session that -// halts at the user's breakpoints / exception stops (#145). The DAP proxy -// neutralises breakpoints for profiling launches while leaving normal debug -// sessions (and `stopOnEntry`, which is a launch arg, not a breakpoint) -// untouched. `suppressBreakpointsForProfiling` is the transformation the proxy -// applies to every client→debugpy request before forwarding. - -import * as assert from "assert"; -import { parseDapMessage, suppressBreakpointsForProfiling, type DapMessage } from "../../dap-proxy"; -import { isRecord } from "../../unknown-shape"; - -suite("Run & Profile launches run to completion, not as a debug session (#145)", () => { - test("a profiling launch arms no user breakpoints", () => { - const setBreakpoints: DapMessage = { - type: "request", - command: "setBreakpoints", - seq: 7, - arguments: { source: { path: "/work/app.py" }, breakpoints: [{ line: 10 }, { line: 20 }] }, - }; - const forwarded = suppressBreakpointsForProfiling(setBreakpoints, true); - assert.deepStrictEqual( - forwarded.arguments?.breakpoints, - [], - "a profiling run must strip user breakpoints so debugpy never halts the run (#145)", - ); - // The source is preserved so debugpy clears the right file's breakpoints. - assert.deepStrictEqual(forwarded.arguments?.source, { path: "/work/app.py" }); - }); - - test("a profiling launch arms no function breakpoints either", () => { - const setFunctionBreakpoints: DapMessage = { - type: "request", - command: "setFunctionBreakpoints", - seq: 11, - arguments: { breakpoints: [{ name: "hot_function" }] }, - }; - const forwarded = suppressBreakpointsForProfiling(setFunctionBreakpoints, true); - assert.deepStrictEqual( - forwarded.arguments?.breakpoints, - [], - "function breakpoints must be stripped too, or a profiling run still halts (#145)", - ); - }); - - test("a profiling launch disables exception stops", () => { - const setExceptionBreakpoints: DapMessage = { - type: "request", - command: "setExceptionBreakpoints", - seq: 8, - arguments: { filters: ["raised", "uncaught"], filterOptions: [{ filterId: "raised" }] }, - }; - const forwarded = suppressBreakpointsForProfiling(setExceptionBreakpoints, true); - assert.deepStrictEqual( - forwarded.arguments?.filters, - [], - "a profiling run must not stop on raised/uncaught exceptions (#145)", - ); - assert.deepStrictEqual( - forwarded.arguments?.filterOptions, - [], - "filterOptions must be cleared too, or exception stops sneak back in", - ); - }); - - test("a normal debug launch keeps the user's breakpoints intact", () => { - const setBreakpoints: DapMessage = { - type: "request", - command: "setBreakpoints", - seq: 7, - arguments: { source: { path: "/work/app.py" }, breakpoints: [{ line: 10 }] }, - }; - const forwarded = suppressBreakpointsForProfiling(setBreakpoints, false); - assert.deepStrictEqual( - forwarded.arguments?.breakpoints, - [{ line: 10 }], - "ordinary debugging must keep user breakpoints — only profiling launches strip them", - ); - }); - - test("a profiling launch leaves non-breakpoint requests (e.g. continue) untouched", () => { - const cont: DapMessage = { type: "request", command: "continue", seq: 9, arguments: { threadId: 1 } }; - const forwarded = suppressBreakpointsForProfiling(cont, true); - assert.strictEqual( - forwarded, - cont, - "non-breakpoint requests must pass through unchanged (incl. the resume after stopOnEntry)", - ); - }); -}); - -// Tests for [VSIX-DEBUGGING]. See docs/specs/VSIX-SPEC.md#VSIX-DEBUGGING -// -// The proxy sits between the editor and debugpy and re-serialises every frame -// it forwards (`sendToClient`/`sendToDebugpy` both `JSON.stringify(msg)`). -// Whatever the decoder drops therefore never reaches the other end. The -// protocol has fields beyond the handful the proxy itself switches on — the -// standard `message` on a failed response, plus adapter-specific extensions — -// and dropping those silently degrades the debug session. -suite("The DAP proxy forwards frames without dropping fields", () => { - /** What the proxy would put back on the wire for a decoded frame. */ - function reserialize(message: DapMessage | undefined): Record<string, unknown> { - assert.notStrictEqual(message, undefined, "the frame must decode"); - const wire: unknown = JSON.parse(JSON.stringify(message)); - assert.ok(isRecord(wire), "a re-serialised DAP frame is a JSON object"); - return wire; - } - - test("a failed response keeps the adapter's error text", () => { - const wire = JSON.stringify({ - type: "response", - request_seq: 4, - success: false, - command: "evaluate", - message: "Unable to evaluate expression: name 'x' is not defined", - }); - assert.strictEqual( - reserialize(parseDapMessage(wire)).message, - "Unable to evaluate expression: name 'x' is not defined", - "`message` is the DAP field that carries an error to the user — dropping it blanks the failure", - ); - }); - - test("adapter-specific fields survive the round trip", () => { - const wire = JSON.stringify({ - type: "event", - event: "debugpySockets", - seq: 12, - body: { sockets: [] }, - pydevdAuthToken: "opaque-token", - }); - assert.strictEqual( - reserialize(parseDapMessage(wire)).pydevdAuthToken, - "opaque-token", - "the proxy is a relay: fields it does not understand must still reach the other side", - ); - }); - - test("the fields the proxy switches on are still decoded", () => { - const parsed = parseDapMessage( - JSON.stringify({ type: "request", command: "next", seq: 3, arguments: { threadId: 1 } }), - ); - assert.strictEqual(parsed?.type, "request"); - assert.strictEqual(parsed?.command, "next"); - assert.strictEqual(parsed?.seq, 3); - assert.strictEqual(parsed?.arguments?.threadId, 1); - }); - - test("bytes that are not a DAP frame are rejected", () => { - assert.strictEqual(parseDapMessage("{not json"), undefined, "unparseable bytes are dropped"); - assert.strictEqual( - parseDapMessage(JSON.stringify({ seq: 1 })), - undefined, - "a frame with no `type` matches no branch of the proxy, so it is not a DAP frame", - ); - }); -}); diff --git a/vscode-extension/src/test/suite/dap-stop-state.test.ts b/vscode-extension/src/test/suite/dap-stop-state.test.ts deleted file mode 100644 index b8f59a2bc..000000000 --- a/vscode-extension/src/test/suite/dap-stop-state.test.ts +++ /dev/null @@ -1,158 +0,0 @@ -// Tests for [PROFILE-MEMORY-COURIER] stop-state bookkeeping. See -// docs/specs/LSP-PROFILING-SPEC.md#PROFILE-MEMORY-COURIER and dap-output.ts. -// -// The DAP spec makes the `continued` event OPTIONAL after a resume request: -// "a debug adapter is not expected to send this event in response to a -// request that implies that execution continues, e.g. launch or continue" — -// the client must treat a successful `continue`/step RESPONSE as "running". -// The tracker used to clear its stopped bookkeeping only on the `continued` -// event, so in the window between the continue response and that (late, -// optional) event, `currentStoppedFrameId` saw a stale "stopped" thread, -// asked debugpy for its stack, got a sampled non-evaluable frame, and the -// memory-snapshot courier evaluated against a bogus frame and failed — the -// large-heap CI failure. These tests drive the production tracker through -// its public message hooks, exactly as VS Code delivers DAP traffic. - -import * as assert from "assert"; -import type * as vscode from "vscode"; -import { BasiliskDebugAdapterTrackerFactory } from "../../debug-adapter"; -import { clearDebugOutput, stoppedThreadIds } from "../../dap-output"; - -/** Build a tracker for a throwaway session id via the production factory. */ -function trackerFor(sessionId: string): { - tracker: vscode.DebugAdapterTracker; - stopped: () => readonly number[]; -} { - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- generic DebugSession double; the tracker reads only id and name - const session = { id: sessionId, name: "stop-state test" } as vscode.DebugSession; - const created = new BasiliskDebugAdapterTrackerFactory().createDebugAdapterTracker(session); - // ProviderResult is `T | undefined | null | Thenable<T>`; narrow it by - // checking rather than asserting, so a factory that starts returning a - // promise fails here instead of silently handing the tests a thenable. - assert.ok(created !== undefined && created !== null, "the factory must create a tracker"); - assert.ok(!("then" in created), "the factory must create the tracker synchronously"); - return { tracker: created, stopped: () => stoppedThreadIds(sessionId) }; -} - -function stoppedEvent(threadId: number): unknown { - return { - type: "event", - event: "stopped", - body: { reason: "breakpoint", threadId, allThreadsStopped: true }, - }; -} - -suite("DAP stop-state — a successful resume response means running", () => { - test("a successful continue RESPONSE clears the stopped bookkeeping without any continued event", () => { - const id = "stop-state-continue-response"; - const { tracker, stopped } = trackerFor(id); - try { - tracker.onDidSendMessage?.(stoppedEvent(1)); - assert.ok(stopped().length > 0, "the stopped event must be recorded first"); - - tracker.onWillReceiveMessage?.({ - type: "request", - command: "continue", - seq: 7, - arguments: { threadId: 1 }, - }); - tracker.onDidSendMessage?.({ - type: "response", - command: "continue", - request_seq: 7, - success: true, - body: {}, - }); - - assert.deepStrictEqual( - stopped(), - [], - "after a successful continue response the thread must read as RUNNING — " + - "the continued event is optional per the DAP spec and can land late" - ); - } finally { - clearDebugOutput(id); - } - }); - - test("a FAILED continue response leaves the stopped bookkeeping intact", () => { - const id = "stop-state-continue-failed"; - const { tracker, stopped } = trackerFor(id); - try { - tracker.onDidSendMessage?.(stoppedEvent(2)); - tracker.onWillReceiveMessage?.({ - type: "request", - command: "continue", - seq: 3, - arguments: { threadId: 2 }, - }); - tracker.onDidSendMessage?.({ - type: "response", - command: "continue", - request_seq: 3, - success: false, - message: "cannot continue", - }); - - assert.ok( - stopped().length > 0, - "a rejected continue did not resume anything — the pause must survive" - ); - } finally { - clearDebugOutput(id); - } - }); - - test("a successful step (next) response also clears the stepped thread", () => { - const id = "stop-state-step-response"; - const { tracker, stopped } = trackerFor(id); - try { - tracker.onDidSendMessage?.(stoppedEvent(5)); - tracker.onWillReceiveMessage?.({ - type: "request", - command: "next", - seq: 11, - arguments: { threadId: 5 }, - }); - tracker.onDidSendMessage?.({ - type: "response", - command: "next", - request_seq: 11, - success: true, - }); - - assert.deepStrictEqual( - stopped(), - [], - "a stepping thread is running until its own stopped event lands" - ); - } finally { - clearDebugOutput(id); - } - }); - - test("an unrelated successful response (stackTrace) never clears the pause", () => { - const id = "stop-state-unrelated-response"; - const { tracker, stopped } = trackerFor(id); - try { - tracker.onDidSendMessage?.(stoppedEvent(9)); - tracker.onWillReceiveMessage?.({ - type: "request", - command: "stackTrace", - seq: 21, - arguments: { threadId: 9 }, - }); - tracker.onDidSendMessage?.({ - type: "response", - command: "stackTrace", - request_seq: 21, - success: true, - body: { stackFrames: [] }, - }); - - assert.ok(stopped().length > 0, "probing a stopped thread must not mark it running"); - } finally { - clearDebugOutput(id); - } - }); -}); diff --git a/vscode-extension/src/test/suite/debug-adapter-readiness.test.ts b/vscode-extension/src/test/suite/debug-adapter-readiness.test.ts deleted file mode 100644 index 3404a2f19..000000000 --- a/vscode-extension/src/test/suite/debug-adapter-readiness.test.ts +++ /dev/null @@ -1,121 +0,0 @@ -// Tests for [VSIX-PYTHON-DEBUGGER-DAP-ARCHITECTURE]. See docs/specs/VSIX-SPEC.md -// Covers the readiness gate in src/debug-adapter.ts. -/** - * Starting a debug session before the language server is running. - * - * The adapter factory asks the LSP to spawn debugpy. It used to take whatever - * `store.client` held and send into it, with only a truthiness check — but a - * client that exists is not a client that is *running*. A request sent while - * the client is still `Starting` is not answered, and nothing ever rejects it: - * the debug session hangs with no error, no diagnostic and no way out but - * cancelling. - * - * The window is the whole of server startup, so on win32 — where spawning the - * server binary takes ~10s — pressing F5 on a freshly opened project lands in - * it routinely. That is what the Windows CI job reported: the first debug test - * fires at T, the server reaches Running at T+0.1s, and the request sent at T - * is never answered while every later one is served in under 300ms. - * - * The factory must therefore WAIT for readiness before sending, and say so - * plainly if readiness never comes. - */ - -import * as assert from 'assert'; -import type * as vscode from 'vscode'; -import type { LanguageClient } from 'vscode-languageclient/node'; -import { createDebugAdapterFactory } from '../../debug-adapter'; -import type { Result } from '../../result'; -import { delay } from '../../timeouts'; -import { fakeLanguageClient } from './test-helpers'; - -/** A debug session double carrying only the configuration the factory reads. */ -function sessionWith(config: vscode.DebugConfiguration): vscode.DebugSession { - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- DebugSession is host-owned; only `configuration` is read here - return { configuration: config } as unknown as vscode.DebugSession; -} - -const LAUNCH_CONFIG: vscode.DebugConfiguration = { - name: 'readiness probe', - type: 'basilisk-debug', - request: 'launch', - program: 'probe.py', -}; - -interface ClientDouble { - readonly client: LanguageClient; - requests(): number; -} - -/** A client that records requests and answers `startDebugSession` with junk. */ -function recordingClient(): ClientDouble { - let requests = 0; - const client = fakeLanguageClient({ - isRunning: (): boolean => true, - // The factory only gets this far once readiness has been granted. - // Rejecting keeps the test off the real debugpy/proxy path — what is - // under test is WHEN the send happens, not what comes back. - sendRequest: async (): Promise<never> => { - requests += 1; - throw new Error('probe: request reached the server'); - }, - }); - return { client, requests: (): number => requests }; -} - -suite('Debug adapter waits for LSP readiness [VSIX-PYTHON-DEBUGGER-DAP-ARCHITECTURE]', () => { - - test('no request is sent while the client is still starting', async () => { - const double = recordingClient(); - let ready = false; - const factory = createDebugAdapterFactory(async (): Promise<Result<LanguageClient>> => { - // Mirrors awaitLspReady: pends until the client reaches Running. - while (!ready) { await delay(10); } - return { ok: true, value: double.client }; - }); - - const descriptor = Promise.resolve( - factory.createDebugAdapterDescriptor(sessionWith(LAUNCH_CONFIG), undefined), - ); - // A pending descriptor must not surface as an unhandled rejection - // before the assertions below attach their own handler. - descriptor.catch(() => { /* asserted on below */ }); - await delay(150); - assert.strictEqual( - double.requests(), - 0, - 'the factory must not send startDebugSession before the server is running — ' + - 'that request is never answered and the session hangs silently', - ); - - ready = true; - // Once ready, the send happens (and our double rejects it, proving it ran). - await assert.rejects( - descriptor, - /probe: request reached the server|Basilisk/, - 'once the server is running the factory must send the request', - ); - assert.strictEqual(double.requests(), 1, 'exactly one request, sent after readiness'); - }); - - test('a server that never becomes ready fails with a diagnosable error, not a hang', async () => { - const factory = createDebugAdapterFactory( - async (): Promise<Result<LanguageClient>> => ({ - ok: false, - error: new Error('LSP client did not reach Running state within 1ms'), - }), - ); - - await assert.rejects( - Promise.resolve(factory.createDebugAdapterDescriptor(sessionWith(LAUNCH_CONFIG), undefined)), - (err: Error) => { - assert.match( - err.message, - /Running state|not ready|did not/i, - `the failure must name the unready server, got: ${err.message}`, - ); - return true; - }, - 'an unready server must reject the debug session rather than hang forever', - ); - }); -}); diff --git a/vscode-extension/src/test/suite/debug-e2e-helpers.ts b/vscode-extension/src/test/suite/debug-e2e-helpers.ts deleted file mode 100644 index 585866ca5..000000000 --- a/vscode-extension/src/test/suite/debug-e2e-helpers.ts +++ /dev/null @@ -1,121 +0,0 @@ -// Implements [PROFILE-MEMORY-HOWTO] + [PROFILE-MEMORY-INGEST]. -// See docs/specs/LSP-PROFILING-SPEC.md#PROFILE-MEMORY-HOWTO -// -// Shared debug-driving + editor-as-courier helpers for the memory e2e suites. -// The LSP holds no DAP connection, so every memory command is a round-trip: the -// LSP hands back a Python injection script, the editor runs it in the paused -// debuggee via DAP `evaluate`, and posts the raw output to -// `basilisk.memory.ingest`. These helpers drive a real `basilisk-debug` session -// and run that round-trip — no mocks. Centralised here so the snapshot/diff -// suite and the introspection (reference-graph / gc-collect) suite share one -// implementation instead of duplicating it. - -import * as assert from "assert"; -import * as vscode from "vscode"; -import { currentStoppedFrameId, evaluateInDebugSession } from "../../dap-evaluate"; -import { numberField, recordArrayField } from "../../unknown-shape"; -import { pollUntilResult } from "./test-helpers"; - -/** Budget for a debug session to start / stop / pause. */ -export const SESSION_WAIT_MS = 20_000; -/** Poll cadence for debug-state changes. */ -export const POLL_MS = 100; - -/** Replace all breakpoints with source breakpoints at the given 1-based lines. */ -export function setBreakpoints(filePath: string, lines: number[]): void { - vscode.debug.removeBreakpoints(vscode.debug.breakpoints); - vscode.debug.addBreakpoints( - lines.map( - (line) => - new vscode.SourceBreakpoint( - new vscode.Location(vscode.Uri.file(filePath), new vscode.Position(line - 1, 0)), - ), - ), - ); -} - -/** Wait until the active debuggee is paused, returning the stopped frame id. */ -export async function waitForPause(): Promise<number> { - const frameId = await pollUntilResult({ - fn: async () => currentStoppedFrameId(), - predicate: (frame) => frame !== null, - timeoutMs: SESSION_WAIT_MS, - intervalMs: POLL_MS, - }); - assert.ok(frameId !== null, "debuggee must reach a paused state"); - return frameId; -} - -/** Resume the debuggee (first stopped thread). */ -export async function resume(): Promise<void> { - const session = vscode.debug.activeDebugSession; - assert.ok(session, "an active debug session is required to resume"); - const threads: unknown = await session.customRequest("threads"); - const threadId = numberField(recordArrayField(threads, "threads")[0], "id"); - assert.ok(threadId !== undefined, "the debuggee must report a thread"); - await session.customRequest("continue", { threadId }); -} - -/** Wait for the active debug session to terminate. */ -export async function waitForSessionEnd(): Promise<void> { - await pollUntilResult({ - fn: async () => vscode.debug.activeDebugSession, - predicate: (session) => session === undefined, - timeoutMs: SESSION_WAIT_MS, - intervalMs: POLL_MS, - }); -} - -/** One marker-tagged ingest result. */ -export interface IngestResult { - kind: string; - [field: string]: unknown; -} - -/** - * Run one memory command's full courier round-trip against the paused debuggee: - * fetch its injection script (leg 1), evaluate it in `frameId`, and post the - * real output back through `basilisk.memory.ingest`. - * - * `ingestSessionId` routes the ingest; omit it for `basilisk.memory.start`, - * whose session is minted in its own leg-1 response. - */ -export async function memoryCourier<T extends IngestResult>(opts: { - command: string; - leg1Args: Record<string, unknown>; - frameId: number; - ingestSessionId?: string; -}): Promise<T> { - const leg1 = await vscode.commands.executeCommand< - { memorySessionId?: string; script?: string } | null - >(opts.command, opts.leg1Args); - const script = leg1?.script; - assert.ok(script !== undefined && script !== "", `${opts.command} must return an injection script`); - - const output = await evaluateInDebugSession(script, opts.frameId); - assert.ok(output !== null, `${opts.command} script must evaluate in the paused debuggee`); - - const ingested = await vscode.commands.executeCommand<T | null>("basilisk.memory.ingest", { - memorySessionId: opts.ingestSessionId ?? leg1?.memorySessionId, - output, - }); - assert.ok(ingested !== null, "ingest must return a kind-tagged result"); - return ingested; -} - -/** - * Convenience wrapper for the snapshot/diff/start commands, which take either - * `{ tracebackDepth }` (start, minting a session) or `{ memorySessionId }`. - */ -export async function memoryRoundTrip<T extends IngestResult>( - command: string, - memorySessionId: string | undefined, - frameId: number, -): Promise<T> { - return memoryCourier<T>({ - command, - leg1Args: memorySessionId === undefined ? { tracebackDepth: 25 } : { memorySessionId }, - frameId, - ingestSessionId: memorySessionId, - }); -} diff --git a/vscode-extension/src/test/suite/debug-integration.test.ts b/vscode-extension/src/test/suite/debug-integration.test.ts deleted file mode 100644 index 78fbfe6a1..000000000 --- a/vscode-extension/src/test/suite/debug-integration.test.ts +++ /dev/null @@ -1,2047 +0,0 @@ -// Tests for [LSPDEBUG]. See docs/specs/LSP-DEBUG-INTEGRATION-SPEC.md#LSPDEBUG -/* eslint-disable max-lines */ -/** - * Debug Integration E2E Tests for the Basilisk VS Code Extension. - * - * These tests exercise REAL debug sessions by: - * 1. Asking the LSP to spawn debugpy via basilisk.startDebugSession - * 2. Starting actual VS Code debug sessions with vscode.debug.startDebugging - * 3. Setting breakpoints, stepping through code, and asserting variable values - * 4. Evaluating watch expressions and verifying results - * 5. Testing error handling (missing debugpy, missing Python) - * - * Prerequisites: - * - The `basilisk` binary must be built: `cargo build -p basilisk-cli` - * - Python 3 must be available on PATH or in a workspace venv - * - `debugpy` must be installed: `pip install debugpy` - */ - -import { delay } from '../../timeouts'; -import * as assert from 'assert'; -import * as vscode from 'vscode'; -import * as path from 'path'; -import * as fs from 'fs'; -import * as os from 'os'; -import * as net from 'net'; -import { execFileSync } from 'child_process'; - -import { SUITE_SETUP_TIMEOUT_MS, findBasiliskBinary, removeTestDir, waitForLspReady } from './test-helpers'; -import { getStore } from '../../extension'; -import { currentStoppedFrameId, evaluateInDebugSession } from '../../dap-evaluate'; -import { debugOutputCursor, debugOutputSince } from '../../dap-output'; -import { booleanField, numberField, recordArrayField, recordField, stringField } from '../../unknown-shape'; -import { ACTIVE_FILE_VARIABLE, applyDebugConfigDefaults } from '../../debug-adapter'; -import { manifestDebuggers } from "./extension-manifest"; - -const EXTENSION_ID = 'Nimblesite.basilisk'; - -/** - * Maximum time (ms) for a debug session to start. - * - * Sized for a COLD server, which is what the first test in this file gets. - * Starting a session spawns a real interpreter, and the first Python process a - * freshly started server spawns is far more expensive than the rest — it - * competes with everything else initialization is doing. The server pays that - * once, in the background ([LSPDEBUG-PYRES-WARM]); a session started before it - * finishes waits for it rather than paying it twice, so the first session can - * legitimately take several seconds on win32 where every later one takes ~250ms. - * - * This budget bounds a hang. It is not an assertion about latency — no test - * here passes or fails on how quickly the session came up. - */ -const DEBUG_SESSION_TIMEOUT_MS = 60_000; - -/** Maximum time (ms) to wait for a stopped event (breakpoint/step). */ -const STOPPED_EVENT_TIMEOUT_MS = 10_000; - -/** Path to the debug stepping fixture. */ -const FIXTURE_DIR = path.resolve(__dirname, '../../src/test/fixtures'); -const STEPPING_FIXTURE = path.join(FIXTURE_DIR, 'debug_stepping.py'); - -/** Timeout (ms) for subprocess commands (binary/python detection). */ -const SUBPROCESS_TIMEOUT_MS = 5_000; - -/** Timeout (ms) for TCP port checks. */ -const PORT_CHECK_TIMEOUT_MS = 3_000; - -/** Short timeout (ms) for port-closed verification. */ -const PORT_CLOSED_CHECK_MS = 1_000; - -/** Maximum stack trace levels to request from DAP. */ -const MAX_STACK_LEVELS = 20; - -/** Polling interval (ms) for stop detection. */ -const STOP_POLL_INTERVAL_MS = 100; - -/** Short settle time (ms) after debug session stops. */ -const SESSION_SETTLE_MS = 500; - -/** Timeout (ms) for DAP handshake. */ -const DAP_HANDSHAKE_TIMEOUT_MS = 5_000; - -/** Timeout (ms) for individual debug tests. */ -const DEBUG_TEST_TIMEOUT_MS = 30_000; - -/** Timeout (ms) for the loop/accumulate test (more stepping). */ -const LOOP_TEST_TIMEOUT_MS = 45_000; - -/** Timeout (ms) to wait for debug session end. */ -const SESSION_END_WAIT_MS = 15_000; - -/** Max poll iterations waiting for debug session to clear. */ -const SESSION_CLEAR_MAX_POLLS = 20; - -// ── Fixture line numbers ──────────────────────────────────────────────────── -// These refer to 1-based line numbers in debug_stepping.py. - -/** Line: `x = 10` in arithmetic(). */ -const ARITH_X_LINE = 11; -/** Line: `y = 20` in arithmetic(). */ -const ARITH_Y_LINE = 12; -/** Line: `z = x + y` in arithmetic(). */ -const ARITH_Z_LINE = 13; -/** Line: `w = z * 2` in arithmetic(). */ -const ARITH_W_LINE = 14; -/** Line: `result = w - 5` in arithmetic(). */ -const ARITH_RESULT_LINE = 15; -/** Line: `return result` in arithmetic(). */ -const ARITH_RETURN_LINE = 16; - -/** Line: `greeting = "hello"` in string_ops(). */ -const STRING_OPS_START_LINE = 21; -/** Line: `message = ...` in string_ops(). */ -const STRING_OPS_MESSAGE_LINE = 23; - -/** Line: `items = [1, 2, 3]` in list_ops(). */ -const LIST_OPS_START_LINE = 31; - -/** Line: `data = {"a": 1, "b": 2}` in dict_ops(). */ -const DICT_OPS_START_LINE = 41; - -/** Line: `a = 5` in nested_call(). */ -const NESTED_CALL_START_LINE = 51; - -/** Line: `result = n * 2` in double(). */ -const DOUBLE_RESULT_LINE = 59; - -/** Line: `total = 0` in loop_and_accumulate(). */ -const LOOP_START_LINE = 65; - -/** Line: `x = 42` in conditional_branches(). */ -const COND_START_LINE = 74; - -/** Line: `caught = False` in exception_handling(). */ -const EXCEPT_START_LINE = 86; - -/** Line: `an_int = 42` in type_variety(). */ -const TYPE_VARIETY_START_LINE = 98; - -/** Line: `p = Point(3, 4)` in class_instance(). */ -const CLASS_INSTANCE_START_LINE = 119; - -// ── Large-heap memory evaluation-budget fixture (written at test time) ─────── - -/** Line `anchor_start = 1` in the generated bigheap_main.py (tracemalloc start). */ -const BIGHEAP_START_LINE = 4; -/** Line `anchor_ready = len(keep)` in bigheap_main.py (snapshot point). */ -const BIGHEAP_READY_LINE = 6; -/** Overall budget for the large-heap snapshot test (heap build + round-trip). */ -const LARGE_HEAP_MEM_TIMEOUT_MS = 90_000; -/** Wait for the traced ~600k-item heap build between the two breakpoints. */ -const HEAP_BUILD_STOP_TIMEOUT_MS = 45_000; -/** pydevd's default PYDEVD_WARN_EVALUATION_TIMEOUT (seconds). */ -const PYDEVD_WARN_TIMEOUT_SECS = 3; -/** The stable core of pydevd's evaluation-stall warning text. */ -const PYDEVD_STALL_WARNING = 'did not finish after'; - -// ── Helpers ────────────────────────────────────────────────────────────────── - -/** - * Check if debugpy is installed in the system Python. - */ -function isDebugpyInstalled(): boolean { - for (const python of ['python3', 'python']) { - try { - execFileSync(python, ['-c', 'import debugpy'], { - timeout: SUBPROCESS_TIMEOUT_MS, - stdio: 'pipe', - }); - return true; - } catch { - // try next - } - } - return false; -} - -/** - * Find a working Python 3 interpreter. - */ -function findPython(): string | undefined { - for (const python of ['python3', 'python']) { - try { - execFileSync(python, ['--version'], { timeout: SUBPROCESS_TIMEOUT_MS, stdio: 'pipe' }); - return python; - } catch { - // try next - } - } - return undefined; -} - -/** - * Attempt a TCP connection to verify a port is accepting connections. - */ -async function checkPortListening(host: string, port: number, timeoutMs = PORT_CHECK_TIMEOUT_MS): Promise<boolean> { - return new Promise((resolve) => { - const socket = new net.Socket(); - const timer = setTimeout(() => { - socket.destroy(); - resolve(false); - }, timeoutMs); - socket.connect(port, host, () => { - clearTimeout(timer); - socket.destroy(); - resolve(true); - }); - socket.on('error', () => { - clearTimeout(timer); - socket.destroy(); - resolve(false); - }); - }); -} - -/** - * Send a basilisk.startDebugSession command through the LSP. - */ -async function startDebugSession( - pythonOverride?: string -): Promise<{ host: string; port: number; sessionId: string }> { - const result = await vscode.commands.executeCommand( - 'basilisk.startDebugSession', - { python: pythonOverride ?? null } - ); - const host = stringField(result, 'host'); - const port = numberField(result, 'port'); - const sessionId = stringField(result, 'sessionId'); - assert.ok( - host !== undefined && port !== undefined && sessionId !== undefined, - 'basilisk.startDebugSession must return host, port and sessionId', - ); - return { host, port, sessionId }; -} - -/** - * Send a basilisk.stopDebugSession command through the LSP. - */ -async function stopDebugSession(sessionId: string): Promise<{ stopped: boolean }> { - const result = await vscode.commands.executeCommand( - 'basilisk.stopDebugSession', - { sessionId } - ); - const stopped = booleanField(result, 'stopped'); - assert.ok(stopped !== undefined, 'basilisk.stopDebugSession must report whether it stopped'); - return { stopped }; -} - -/** - * Wait for the debug session to be fully started. - */ -async function waitForDebugSessionStart(timeoutMs: number = DEBUG_SESSION_TIMEOUT_MS): Promise<vscode.DebugSession> { - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - disposable.dispose(); - reject(new Error(`Debug session did not start within ${timeoutMs}ms`)); - }, timeoutMs); - - const disposable = vscode.debug.onDidStartDebugSession((session) => { - clearTimeout(timer); - disposable.dispose(); - resolve(session); - }); - }); -} - -/** - * Wait for the debug session to terminate. - */ -async function waitForDebugSessionEnd(timeoutMs: number = DEBUG_SESSION_TIMEOUT_MS): Promise<void> { - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - disposable.dispose(); - reject(new Error(`Debug session did not terminate within ${timeoutMs}ms`)); - }, timeoutMs); - - const disposable = vscode.debug.onDidTerminateDebugSession(() => { - clearTimeout(timer); - disposable.dispose(); - resolve(); - }); - }); -} - -// The adapter responses below are read field by field rather than asserted into -// their DAP shapes. An `as` would hand a missing field to the assertions as -// `undefined` — which several of them compare away — whereas a checked read -// fails here, naming the field the adapter did not send. - -/** One frame of a `stackTrace` response. */ -interface StackFrame { - id: number; - name: string; - source?: { path?: string }; - line: number; - column: number; -} - -/** Read one stack frame, requiring the fields every DAP frame carries. */ -function narrowStackFrame(raw: Record<string, unknown>): StackFrame { - const id = numberField(raw, 'id'); - const name = stringField(raw, 'name'); - const line = numberField(raw, 'line'); - const column = numberField(raw, 'column'); - assert.ok( - id !== undefined && name !== undefined && line !== undefined && column !== undefined, - 'a stackTrace frame carries id, name, line and column', - ); - const sourcePath = stringField(recordField(raw, 'source'), 'path'); - return { - id, name, line, column, - source: sourcePath === undefined ? undefined : { path: sourcePath }, - }; -} - -/** - * Get the stack trace for the given thread. - */ -async function getStackTrace(session: vscode.DebugSession, threadId: number): Promise<{ - stackFrames: StackFrame[]; - totalFrames: number; -}> { - const response: unknown = await session.customRequest('stackTrace', { - threadId, - startFrame: 0, - levels: MAX_STACK_LEVELS, - }); - const totalFrames = numberField(response, 'totalFrames'); - assert.ok(totalFrames !== undefined, 'a stackTrace response reports totalFrames'); - return { - stackFrames: recordArrayField(response, 'stackFrames').map(narrowStackFrame), - totalFrames, - }; -} - -/** - * Get the scopes for a given stack frame. - */ -async function getScopes(session: vscode.DebugSession, frameId: number): Promise<{ - scopes: { - name: string; - variablesReference: number; - expensive: boolean; - }[]; -}> { - const response: unknown = await session.customRequest('scopes', { frameId }); - return { - scopes: recordArrayField(response, 'scopes').map((raw) => { - const name = stringField(raw, 'name'); - const variablesReference = numberField(raw, 'variablesReference'); - const expensive = booleanField(raw, 'expensive'); - assert.ok( - name !== undefined && variablesReference !== undefined && expensive !== undefined, - 'a scopes entry carries name, variablesReference and expensive', - ); - return { name, variablesReference, expensive }; - }), - }; -} - -/** - * Get variables for a given variables reference (scope or structured variable). - */ -async function getVariables(session: vscode.DebugSession, variablesReference: number): Promise<{ - variables: { - name: string; - value: string; - type?: string; - variablesReference: number; - }[]; -}> { - const response: unknown = await session.customRequest('variables', { variablesReference }); - return { - variables: recordArrayField(response, 'variables').map((raw) => { - const name = stringField(raw, 'name'); - const value = stringField(raw, 'value'); - const reference = numberField(raw, 'variablesReference'); - assert.ok( - name !== undefined && value !== undefined && reference !== undefined, - 'a variables entry carries name, value and variablesReference', - ); - return { name, value, type: stringField(raw, 'type'), variablesReference: reference }; - }), - }; -} - -/** Options for evaluating an expression in a debug session. */ -interface EvaluateExpressionOptions { - session: vscode.DebugSession; - expression: string; - frameId: number; - context?: 'watch' | 'repl' | 'hover'; -} - -/** - * Evaluate an expression in the context of a stack frame (watch expression). - */ -async function evaluateExpression(options: EvaluateExpressionOptions): Promise<{ - result: string; - type?: string; - variablesReference: number; -}> { - const { session, expression, frameId, context = 'watch' } = options; - const response: unknown = await session.customRequest('evaluate', { - expression, - frameId, - context, - }); - const result = stringField(response, 'result'); - const variablesReference = numberField(response, 'variablesReference'); - assert.ok( - result !== undefined && variablesReference !== undefined, - `evaluating "${expression}" must return a result and a variablesReference`, - ); - return { result, type: stringField(response, 'type'), variablesReference }; -} - -/** - * Step over (next) in the given thread. - */ -async function stepOver(session: vscode.DebugSession, threadId: number): Promise<void> { - await session.customRequest('next', { threadId }); -} - -/** - * Step into in the given thread. - */ -async function stepIn(session: vscode.DebugSession, threadId: number): Promise<void> { - await session.customRequest('stepIn', { threadId }); -} - -/** - * Step out of the current function. - */ -async function stepOut(session: vscode.DebugSession, threadId: number): Promise<void> { - await session.customRequest('stepOut', { threadId }); -} - -/** - * Continue execution. - */ -async function continueExecution(session: vscode.DebugSession, threadId: number): Promise<void> { - await session.customRequest('continue', { threadId }); -} - -/** - * Wait for the debugger to stop (after a step or continue), returning the thread ID. - * Uses polling on the active session's stack trace availability. - */ -async function waitForStop(timeoutMs: number = STOPPED_EVENT_TIMEOUT_MS): Promise<number> { - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - clearInterval(poll); - reject(new Error(`Timed out waiting for debugger to stop after ${timeoutMs}ms`)); - }, timeoutMs); - - const poll = setInterval(() => { - void (async () => { - const session = vscode.debug.activeDebugSession; - if (session === undefined) { - return; - } - try { - const threadsResponse: unknown = await session.customRequest('threads'); - const threadId = numberField(recordArrayField(threadsResponse, 'threads')[0], 'id'); - if (threadId !== undefined) { - try { - const stack = await getStackTrace(session, threadId); - if (stack.stackFrames.length > 0) { - clearInterval(poll); - clearTimeout(timer); - resolve(threadId); - } - } catch { - // Thread is running, not stopped yet. - } - } - } catch { - // Session not ready yet. - } - })(); - }, STOP_POLL_INTERVAL_MS); - }); -} - -/** - * Helper: Find a local variable by name in the current frame's local scope. - */ -async function getLocalVariable( - session: vscode.DebugSession, - threadId: number, - varName: string -): Promise<{ name: string; value: string; type?: string } | undefined> { - const stack = await getStackTrace(session, threadId); - assert.ok(stack.stackFrames.length > 0, 'Expected at least one stack frame'); - const frameId = stack.stackFrames[0].id; - const scopesResponse = await getScopes(session, frameId); - const localsScope = scopesResponse.scopes.find( - (s) => s.name === 'Locals' || s.name === 'Local' - ); - assert.ok(localsScope, `Expected a Locals scope, got: ${scopesResponse.scopes.map(s => s.name).join(', ')}`); - const varsResponse = await getVariables(session, localsScope.variablesReference); - return varsResponse.variables.find((v) => v.name === varName); -} - -/** Options for asserting a local variable's value. */ -interface AssertLocalVariableOptions { - session: vscode.DebugSession; - threadId: number; - varName: string; - expectedValue: string; - message?: string; -} - -/** - * Helper: Assert a local variable has the expected value string. - */ -async function assertLocalVariable(options: AssertLocalVariableOptions): Promise<void> { - const { session, threadId, varName, expectedValue, message } = options; - const variable = await getLocalVariable(session, threadId, varName); - assert.ok(variable, `Variable '${varName}' not found in locals`); - assert.strictEqual( - variable.value, - expectedValue, - message ?? `Expected ${varName} = ${expectedValue}, got ${variable.value}` - ); -} - -/** Options for asserting a watch expression's result. */ -interface AssertWatchOptions { - session: vscode.DebugSession; - threadId: number; - expression: string; - expectedResult: string; - message?: string; -} - -/** - * Helper: Assert a watch expression evaluates to the expected result. - */ -async function assertWatch(options: AssertWatchOptions): Promise<void> { - const { session, threadId, expression, expectedResult, message } = options; - const stack = await getStackTrace(session, threadId); - const frameId = stack.stackFrames[0].id; - const result = await evaluateExpression({ session, expression, frameId, context: 'watch' }); - assert.strictEqual( - result.result, - expectedResult, - message ?? `Watch '${expression}': expected ${expectedResult}, got ${result.result}` - ); -} - -/** Options for asserting the current line number. */ -interface AssertCurrentLineOptions { - session: vscode.DebugSession; - threadId: number; - expectedLine: number; - message?: string; -} - -/** - * Helper: Assert the current line number in the top frame. - */ -async function assertCurrentLine(options: AssertCurrentLineOptions): Promise<void> { - const { session, threadId, expectedLine, message } = options; - const stack = await getStackTrace(session, threadId); - assert.ok(stack.stackFrames.length > 0, 'Expected at least one stack frame'); - assert.strictEqual( - stack.stackFrames[0].line, - expectedLine, - message ?? `Expected to be on line ${expectedLine}, but on line ${stack.stackFrames[0].line}` - ); -} - -/** Options for asserting the current function name. */ -interface AssertCurrentFunctionOptions { - session: vscode.DebugSession; - threadId: number; - expectedName: string; - message?: string; -} - -/** - * Helper: Assert the current function name in the top frame. - */ -async function assertCurrentFunction(options: AssertCurrentFunctionOptions): Promise<void> { - const { session, threadId, expectedName, message } = options; - const stack = await getStackTrace(session, threadId); - assert.ok(stack.stackFrames.length > 0, 'Expected at least one stack frame'); - assert.strictEqual( - stack.stackFrames[0].name, - expectedName, - message ?? `Expected function '${expectedName}', got '${stack.stackFrames[0].name}'` - ); -} - -/** - * Set breakpoints on specific lines of a file. - */ -function setBreakpoints(filePath: string, lines: number[]): void { - const uri = vscode.Uri.file(filePath); - const breakpoints = lines.map( - (line) => new vscode.SourceBreakpoint(new vscode.Location(uri, new vscode.Position(line - 1, 0))) - ); - vscode.debug.addBreakpoints(breakpoints); -} - -/** - * Clear all breakpoints. - */ -function clearAllBreakpoints(): void { - vscode.debug.removeBreakpoints(vscode.debug.breakpoints); -} - -/** - * Stop the active debug session during cleanup, then let the runtime settle. - * - * VS Code rejects the terminate/disconnect request with "Canceled" when the - * session ends on its own before the request resolves — a benign shutdown race. - * Cleanup hooks must never fail the suite over that race, so it is swallowed; - * any other rejection is re-thrown. [LSPDEBUG] - */ -async function stopActiveDebugSession(): Promise<void> { - if (vscode.debug.activeDebugSession === undefined) { - return; - } - // Arm the termination listener BEFORE issuing the stop. `stopDebugging` - // can complete and the session terminate before a listener registered - // afterwards would see the event, which would then wait out the full - // budget for an event that already fired. - const ended = waitForDebugSessionEnd(SESSION_END_WAIT_MS); - // If the stop below re-throws, `ended` is never awaited; attaching a - // handler keeps that path from surfacing as an unhandled rejection. It does - // not swallow anything — `await ended` still rejects on the normal path. - void ended.catch(() => undefined); - try { - await vscode.debug.stopDebugging(); - } catch (error) { - const message = (error instanceof Error ? error.message : String(error)).toLowerCase(); - if (!message.includes('cancel')) { - throw error; - } - } - // Was `await delay(SESSION_SETTLE_MS)` — a blind 500ms settle run ~20 times - // per suite. Waiting on the actual `onDidTerminateDebugSession` is both - // faster (it returns the instant the session ends) and STRICTER: the blind - // sleep let a session that never terminated leak into the following test, - // whereas this fails and names it. [LSPDEBUG] - await ended; - // The terminate event does NOT clear `activeDebugSession` synchronously - // (same runtime lag the 'terminates cleanly' test polls for). Without - // draining it here this helper is not idempotent: a test that stops its own - // session, then hits the `teardown` hook that stops it again, would find a - // stale non-undefined session, skip the early return above, and wait out the - // full SESSION_END_WAIT_MS budget for a terminate event that already fired. - // The blind sleep this replaced happened to cover that; the event wait does - // not, so drain the handle explicitly. [LSPDEBUG] - for (let i = 0; i < SESSION_CLEAR_MAX_POLLS && vscode.debug.activeDebugSession; i++) { - await delay(STOP_POLL_INTERVAL_MS); - } -} - -/** - * Start a debug session on the stepping fixture, wait for it to stop, return session + threadId. - */ -async function launchAndWaitForBreakpoint( - breakpointLines: number[], - pythonPath?: string -): Promise<{ session: vscode.DebugSession; threadId: number }> { - clearAllBreakpoints(); - setBreakpoints(STEPPING_FIXTURE, breakpointLines); - - const sessionPromise = waitForDebugSessionStart(); - const stoppedPromise = waitForStop(); - - const started = await vscode.debug.startDebugging(undefined, { - name: 'Basilisk Debug Test', - type: 'basilisk-debug', - request: 'launch', - program: STEPPING_FIXTURE, - python: pythonPath, - stopOnEntry: false, - justMyCode: true, - console: 'internalConsole', - }); - assert.ok(started, 'vscode.debug.startDebugging should return true'); - - const session = await sessionPromise; - assert.ok(session !== undefined, 'Debug session should start'); - - const threadId = await stoppedPromise; - assert.ok(threadId > 0, `Thread ID should be positive, got ${threadId}`); - - return { session, threadId }; -} - -// ── Test Suite ────────────────────────────────────────────────────────────── -// Exercises [VSIX-PYTHON-DEBUGGER-DAP] / [VSIX-PYTHON-DEBUGGER-DAP-FEATURES] -// end-to-end against real debugpy: launch/attach, breakpoints, step in/over/out, -// variable inspection, watch expressions, call stack, hover/REPL evaluation, and -// clean termination. The PID-capture test covers [VSIX-PYTHON-DEBUGGER-DAP-TRACKER]. - -// eslint-disable-next-line max-lines-per-function -suite('Debug Integration E2E Tests', () => { - let basiliskBinary: string | undefined; - let debugpyAvailable: boolean; - let pythonPath: string | undefined; - let tmpDir: string; - - suiteSetup(async function () { - // Sized for the readiness POLL, not for a fixed sleep: a cold win32 - // start is minutes, and a hook killed early reports as a mystery - // failure in the first test rather than as "the server never came up". - this.timeout(SUITE_SETUP_TIMEOUT_MS); - - basiliskBinary = findBasiliskBinary(); - debugpyAvailable = isDebugpyInstalled(); - pythonPath = findPython(); - - if (basiliskBinary === undefined) { - throw new Error( - 'Basilisk binary not found. Build with: cargo build -p basilisk-cli' - ); - } - if (!debugpyAvailable) { - throw new Error( - 'debugpy not installed. Install with: pip install debugpy' - ); - } - if (pythonPath === undefined) { - throw new Error('Python not found. Install Python 3.12+.'); - } - - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'basilisk-debug-test-')); - - // Ensure fixture exists. - assert.ok( - fs.existsSync(STEPPING_FIXTURE), - `Fixture not found: ${STEPPING_FIXTURE}` - ); - - // Wait for the server to actually ADVERTISE its commands, rather than - // sleeping a fixed guess and hoping. - // - // Every test below sends `basilisk.startDebugSession` straight through - // `executeCommand`, so it does not go past the debug adapter factory - // and does not inherit the factory's readiness gate. A request sent - // while the client is still `Starting` is never answered and never - // rejected — it just hangs, and every later test in the file times out - // behind it. - // - // The fixed sleep this replaces was 10s, which is longer than a warm - // Linux start and SHORTER than a cold win32 one, so the race was - // invisible on Linux and reliable on Windows: one hung request and 14 - // cascading timeouts. Polling until `serverCommands` is populated ends - // when the server is genuinely ready, on whatever platform, instead of - // encoding one machine's start time as a constant - // ([VSIX-CI-PLATFORM-COVERAGE]). - await waitForLspReady(); - }); - - suiteTeardown(async () => { - clearAllBreakpoints(); - await stopActiveDebugSession(); - if (tmpDir !== undefined && tmpDir !== '' && fs.existsSync(tmpDir)) { - removeTestDir(tmpDir); - } - }); - - teardown(async () => { - clearAllBreakpoints(); - await stopActiveDebugSession(); - }); - - // ──────────────────────────────────────────────────────────────────────── - // 1. Package.json contributes basilisk-debug - // ──────────────────────────────────────────────────────────────────────── - - // [LSPDEBUG-WIRE], [VSIX-PYTHON-DEBUGGER-DAP-ARCHITECTURE]: both commands are - // advertised by the extension contribution. - test('LSP advertises startDebugSession and stopDebugSession commands', function () { - this.timeout(SUBPROCESS_TIMEOUT_MS); - const ext = vscode.extensions.getExtension(EXTENSION_ID); - assert.ok(ext, 'Extension must be installed'); - const debuggers = manifestDebuggers(); - assert.ok(debuggers.length > 0, 'Extension must contribute debuggers'); - assert.ok( - debuggers.some((d) => d.type === 'basilisk-debug'), - 'Extension must contribute basilisk-debug debugger type' - ); - }); - - // [VSIX-PYTHON-DEBUGGER-DAP-LAUNCH-CONFIGURATIONS]: the contributed launch/ - // attach config attribute schema for the basilisk-debug debugger. - // eslint-disable-next-line complexity - test('basilisk-debug type has correct configuration attributes', function () { - this.timeout(SUBPROCESS_TIMEOUT_MS); - const ext = vscode.extensions.getExtension(EXTENSION_ID); - assert.ok(ext, 'Extension must be installed'); - - const debuggerContrib = manifestDebuggers().find( - (d) => d.type === 'basilisk-debug' - ); - assert.ok(debuggerContrib !== undefined, 'basilisk-debug debugger must be contributed'); - assert.strictEqual(debuggerContrib.label, 'Python (Basilisk)'); - assert.ok(debuggerContrib.configurationAttributes?.launch !== undefined, 'Launch config must be defined'); - assert.ok(debuggerContrib.configurationAttributes?.attach !== undefined, 'Attach config must be defined'); - assert.ok( - debuggerContrib.configurationAttributes?.launch?.properties?.program !== undefined, - 'Launch must have program property' - ); - assert.ok( - debuggerContrib.configurationAttributes?.launch?.properties?.args !== undefined, - 'Launch must have args property' - ); - assert.ok( - debuggerContrib.configurationAttributes?.launch?.properties?.justMyCode !== undefined, - 'Launch must have justMyCode property' - ); - assert.ok( - debuggerContrib.configurationAttributes?.launch?.properties?.stopOnEntry !== undefined, - 'Launch must have stopOnEntry property' - ); - assert.ok( - debuggerContrib.configurationAttributes?.launch?.properties?.python !== undefined, - 'Launch must have python property' - ); - assert.ok( - debuggerContrib.configurationAttributes?.attach?.properties?.connect !== undefined, - 'Attach must have connect property' - ); - }); - - // ──────────────────────────────────────────────────────────────────────── - // 2. LSP-level: start/stop debug session via raw LSP commands - // ──────────────────────────────────────────────────────────────────────── - - // [LSPDEBUG-START]: response shape (host=127.0.0.1, port>0, sessionId dbg-…) - // and debugpy actually listening on the returned port. - test('startDebugSession spawns debugpy on a TCP port', async function () { - this.timeout(DEBUG_SESSION_TIMEOUT_MS); - - const result = await startDebugSession(pythonPath); - assert.ok(result !== undefined, 'Expected startDebugSession to return a result'); - // The IPv4 literal, not the name `localhost`: the adapter binds IPv4, - // and on Windows `localhost` resolves to `::1` first — where nothing - // listens, so the connect below is refused ([LSPDEBUG-START]). - assert.strictEqual( - result.host, - '127.0.0.1', - 'the session must be advertised on the IPv4 address it binds, not a name' - ); - assert.ok(result.port > 0, `Port should be positive, got ${result.port}`); - assert.ok( - result.sessionId.startsWith('dbg-'), - `Session ID should start with "dbg-", got "${result.sessionId}"` - ); - - const listening = await checkPortListening(result.host, result.port); - assert.ok(listening, `Expected debugpy to be listening on ${result.host}:${result.port}`); - - await stopDebugSession(result.sessionId); - }); - - // [LSPDEBUG-STOP]: stop returns { stopped: true } and the port stops listening. - test('stopDebugSession kills the debugpy process', async function () { - this.timeout(DEBUG_SESSION_TIMEOUT_MS); - - const result = await startDebugSession(pythonPath); - assert.ok(result.port > 0); - - const stopResult = await stopDebugSession(result.sessionId); - assert.strictEqual(stopResult.stopped, true, 'Session should be reported as stopped'); - - await delay(SESSION_SETTLE_MS); - const stillListening = await checkPortListening(result.host, result.port, PORT_CLOSED_CHECK_MS); - assert.strictEqual(stillListening, false, `Port ${result.port} should stop listening`); - }); - - // [LSPDEBUG-STOP]: an unknown sessionId resolves to { stopped: false }. - test('stopDebugSession with invalid sessionId returns stopped: false', async function () { - this.timeout(SUBPROCESS_TIMEOUT_MS); - const result = await stopDebugSession('nonexistent-session-id'); - assert.strictEqual(result.stopped, false); - }); - - test('can start multiple debug sessions on different ports', async function () { - this.timeout(DEBUG_SESSION_TIMEOUT_MS * 2); - - const session1 = await startDebugSession(pythonPath); - const session2 = await startDebugSession(pythonPath); - - assert.notStrictEqual(session1.port, session2.port, 'Different ports'); - assert.notStrictEqual(session1.sessionId, session2.sessionId, 'Different IDs'); - - const listening1 = await checkPortListening(session1.host, session1.port); - const listening2 = await checkPortListening(session2.host, session2.port); - assert.ok(listening1, `Session 1 listening on ${session1.port}`); - assert.ok(listening2, `Session 2 listening on ${session2.port}`); - - await stopDebugSession(session1.sessionId); - await stopDebugSession(session2.sessionId); - }); - - // [LSPDEBUG-ERRORS] / [LSPDEBUG-PYRES]: a bad interpreter yields a structured - // error (debugpy/python not found) rather than a crash. - test('startDebugSession with bad Python path returns error', async function () { - this.timeout(DEBUG_SESSION_TIMEOUT_MS); - try { - await startDebugSession('/nonexistent/python3.99'); - assert.fail('Expected startDebugSession to throw with a bad Python path'); - } catch (err: unknown) { - assert.ok(err !== null && err !== undefined, 'Expected an error to be thrown'); - const message = err instanceof Error ? err.message : JSON.stringify(err); - assert.ok(message.length > 0, `Expected a meaningful error message, got: "${message}"`); - } - }); - - // ──────────────────────────────────────────────────────────────────────── - // 3. Full DAP handshake test - // ──────────────────────────────────────────────────────────────────────── - - test('full debug lifecycle: start, verify DAP handshake, stop', async function () { - this.timeout(DEBUG_SESSION_TIMEOUT_MS + SUBPROCESS_TIMEOUT_MS); - - const session = await startDebugSession(pythonPath); - - const dapResponse = await new Promise<string>((resolve, reject) => { - const socket = new net.Socket(); - const timer = setTimeout(() => { - socket.destroy(); - reject(new Error('DAP handshake timed out')); - }, DAP_HANDSHAKE_TIMEOUT_MS); - - socket.connect(session.port, session.host, () => { - const initRequest = JSON.stringify({ - seq: 1, - type: 'request', - command: 'initialize', - arguments: { - clientID: 'basilisk-test', - adapterID: 'debugpy', - pathFormat: 'path', - linesStartAt1: true, - columnsStartAt1: true, - }, - }); - const header = `Content-Length: ${Buffer.byteLength(initRequest)}\r\n\r\n`; - socket.write(header + initRequest); - }); - - let data = ''; - socket.on('data', (chunk) => { - data += chunk.toString(); - // Parse the Content-Length header so we extract exactly one - // DAP message, even if multiple arrive back-to-back. - const headerEnd = data.indexOf('\r\n\r\n'); - if (headerEnd === -1) {return;} - const header = data.slice(0, headerEnd); - const match = /Content-Length:\s*(\d+)/i.exec(header); - if (!match) {return;} - const contentLength = parseInt(match[1], 10); - const httpHeaderTerminatorLength = 4; // \r\n\r\n - const bodyStart = headerEnd + httpHeaderTerminatorLength; - if (data.length >= bodyStart + contentLength) { - const body = data.slice(bodyStart, bodyStart + contentLength); - clearTimeout(timer); - socket.destroy(); - resolve(body); - } - }); - - socket.on('error', (err) => { - clearTimeout(timer); - reject(err); - }); - }); - - const parsed: unknown = JSON.parse(dapResponse); - const type = stringField(parsed, 'type'); - assert.ok( - type === 'response' || type === 'event', - `Expected DAP response or event, got type: ${String(type)}` - ); - - if (type === 'response') { - assert.strictEqual(stringField(parsed, 'command'), 'initialize', 'Should be initialize response'); - assert.strictEqual(booleanField(parsed, 'success'), true, 'Initialize should succeed'); - const body = recordField(parsed, 'body'); - assert.ok(body !== undefined, 'Initialize response should have a body'); - assert.ok( - booleanField(body, 'supportsConfigurationDoneRequest') !== undefined, - 'Should report supportsConfigurationDoneRequest' - ); - } - - await stopDebugSession(session.sessionId); - }); - - // ──────────────────────────────────────────────────────────────────────── - // 4. REAL DEBUG SESSION: Arithmetic — step through, check every variable - // ──────────────────────────────────────────────────────────────────────── - - test('arithmetic: step through and assert variable values at each line', async function () { - this.timeout(DEBUG_TEST_TIMEOUT_MS); - - // Break on line 11: x = 10 - const { session, threadId } = await launchAndWaitForBreakpoint([ARITH_X_LINE], pythonPath); - - // Stopped at line 11: x = 10 (not yet executed) - await assertCurrentLine({ session: session, threadId: threadId, expectedLine: ARITH_X_LINE }); - await assertCurrentFunction({ session: session, threadId: threadId, expectedName: 'arithmetic' }); - - // Step over: execute x = 10, now on line 12 - await stepOver(session, threadId); - const tid2 = await waitForStop(); - await assertCurrentLine({ session: session, threadId: tid2, expectedLine: ARITH_Y_LINE }); - await assertLocalVariable({ session: session, threadId: tid2, varName: 'x', expectedValue: '10' }); - - // Step over: execute y = 20, now on line 13 - await stepOver(session, tid2); - const tid3 = await waitForStop(); - await assertCurrentLine({ session: session, threadId: tid3, expectedLine: ARITH_Z_LINE }); - await assertLocalVariable({ session: session, threadId: tid3, varName: 'x', expectedValue: '10' }); - await assertLocalVariable({ session: session, threadId: tid3, varName: 'y', expectedValue: '20' }); - - // Step over: execute z = x + y, now on line 14 - await stepOver(session, tid3); - const tid4 = await waitForStop(); - await assertCurrentLine({ session: session, threadId: tid4, expectedLine: ARITH_W_LINE }); - await assertLocalVariable({ session: session, threadId: tid4, varName: 'z', expectedValue: '30' }); - - // Watch expressions - await assertWatch({ session: session, threadId: tid4, expression: 'x + y', expectedResult: '30' }); - await assertWatch({ session: session, threadId: tid4, expression: 'z == 30', expectedResult: 'True' }); - await assertWatch({ session: session, threadId: tid4, expression: 'type(z).__name__', expectedResult: "'int'" }); - - // Step over: execute w = z * 2, now on line 15 - await stepOver(session, tid4); - const tid5 = await waitForStop(); - await assertCurrentLine({ session: session, threadId: tid5, expectedLine: ARITH_RESULT_LINE }); - await assertLocalVariable({ session: session, threadId: tid5, varName: 'w', expectedValue: '60' }); - - // Step over: execute result = w - 5, now on line 16 - await stepOver(session, tid5); - const tid6 = await waitForStop(); - await assertCurrentLine({ session: session, threadId: tid6, expectedLine: ARITH_RETURN_LINE }); - await assertLocalVariable({ session: session, threadId: tid6, varName: 'result', expectedValue: '55' }); - - // Watch: verify final computed value - await assertWatch({ session: session, threadId: tid6, expression: 'result == 55', expectedResult: 'True' }); - await assertWatch({ session: session, threadId: tid6, expression: 'result * 2', expectedResult: '110' }); - }); - - // ──────────────────────────────────────────────────────────────────────── - // 5. REAL DEBUG SESSION: String operations - // ──────────────────────────────────────────────────────────────────────── - - test('string_ops: step through and assert string values', async function () { - this.timeout(DEBUG_TEST_TIMEOUT_MS); - - const { session, threadId } = await launchAndWaitForBreakpoint([STRING_OPS_START_LINE], pythonPath); - - await assertCurrentLine({ session: session, threadId: threadId, expectedLine: STRING_OPS_START_LINE }); - await assertCurrentFunction({ session: session, threadId: threadId, expectedName: 'string_ops' }); - - // Step: greeting = "hello" - await stepOver(session, threadId); - const t1 = await waitForStop(); - await assertLocalVariable({ session: session, threadId: t1, varName: 'greeting', expectedValue: "'hello'" }); - - // Step: name = "world" - await stepOver(session, t1); - const t2 = await waitForStop(); - await assertLocalVariable({ session: session, threadId: t2, varName: 'name', expectedValue: "'world'" }); - - // Step: message = greeting + " " + name - await stepOver(session, t2); - const t3 = await waitForStop(); - await assertLocalVariable({ session: session, threadId: t3, varName: 'message', expectedValue: "'hello world'" }); - - // Watch: string operations - await assertWatch({ session: session, threadId: t3, expression: 'len(message)', expectedResult: '11' }); - await assertWatch({ session: session, threadId: t3, expression: 'message.startswith("hello")', expectedResult: 'True' }); - await assertWatch({ session: session, threadId: t3, expression: '"world" in message', expectedResult: 'True' }); - - // Step: upper = message.upper() - await stepOver(session, t3); - const t4 = await waitForStop(); - await assertLocalVariable({ session: session, threadId: t4, varName: 'upper', expectedValue: "'HELLO WORLD'" }); - - // Step: length = len(upper) - await stepOver(session, t4); - const t5 = await waitForStop(); - await assertLocalVariable({ session: session, threadId: t5, varName: 'length', expectedValue: '11' }); - - // Watch: verify everything - await assertWatch({ session: session, threadId: t5, expression: 'upper == "HELLO WORLD"', expectedResult: 'True' }); - await assertWatch({ session: session, threadId: t5, expression: 'length == len(upper)', expectedResult: 'True' }); - }); - - // ──────────────────────────────────────────────────────────────────────── - // 6. REAL DEBUG SESSION: List operations - // ──────────────────────────────────────────────────────────────────────── - - test('list_ops: step through and assert list contents', async function () { - this.timeout(DEBUG_TEST_TIMEOUT_MS); - - const { session, threadId } = await launchAndWaitForBreakpoint([LIST_OPS_START_LINE], pythonPath); - - await assertCurrentLine({ session: session, threadId: threadId, expectedLine: LIST_OPS_START_LINE }); - await assertCurrentFunction({ session: session, threadId: threadId, expectedName: 'list_ops' }); - - // Step: items = [1, 2, 3] - await stepOver(session, threadId); - const t1 = await waitForStop(); - await assertLocalVariable({ session: session, threadId: t1, varName: 'items', expectedValue: '[1, 2, 3]' }); - - // Watch: list properties - await assertWatch({ session: session, threadId: t1, expression: 'len(items)', expectedResult: '3' }); - await assertWatch({ session: session, threadId: t1, expression: 'items[0]', expectedResult: '1' }); - await assertWatch({ session: session, threadId: t1, expression: 'items[-1]', expectedResult: '3' }); - await assertWatch({ session: session, threadId: t1, expression: 'sum(items)', expectedResult: '6' }); - - // Step: items.append(4) - await stepOver(session, t1); - const t2 = await waitForStop(); - await assertWatch({ session: session, threadId: t2, expression: 'len(items)', expectedResult: '4' }); - await assertWatch({ session: session, threadId: t2, expression: 'items[-1]', expectedResult: '4' }); - await assertWatch({ session: session, threadId: t2, expression: '4 in items', expectedResult: 'True' }); - - // Step: items.insert(0, 0) - await stepOver(session, t2); - const t3 = await waitForStop(); - await assertWatch({ session: session, threadId: t3, expression: 'items[0]', expectedResult: '0' }); - await assertWatch({ session: session, threadId: t3, expression: 'len(items)', expectedResult: '5' }); - - // Step: total = sum(items) - await stepOver(session, t3); - const t4 = await waitForStop(); - await assertLocalVariable({ session: session, threadId: t4, varName: 'total', expectedValue: '10' }); - - // Step: count = len(items) - await stepOver(session, t4); - const t5 = await waitForStop(); - await assertLocalVariable({ session: session, threadId: t5, varName: 'count', expectedValue: '5' }); - - // Watch: final assertions - await assertWatch({ session: session, threadId: t5, expression: 'total == sum(items)', expectedResult: 'True' }); - await assertWatch({ session: session, threadId: t5, expression: 'count == len(items)', expectedResult: 'True' }); - }); - - // ──────────────────────────────────────────────────────────────────────── - // 7. REAL DEBUG SESSION: Dictionary operations - // ──────────────────────────────────────────────────────────────────────── - - test('dict_ops: step through and assert dict contents', async function () { - this.timeout(DEBUG_TEST_TIMEOUT_MS); - - const { session, threadId } = await launchAndWaitForBreakpoint([DICT_OPS_START_LINE], pythonPath); - - await assertCurrentLine({ session: session, threadId: threadId, expectedLine: DICT_OPS_START_LINE }); - await assertCurrentFunction({ session: session, threadId: threadId, expectedName: 'dict_ops' }); - - // Step: data = {"a": 1, "b": 2} - await stepOver(session, threadId); - const t1 = await waitForStop(); - - // Watch: dict operations - await assertWatch({ session: session, threadId: t1, expression: 'len(data)', expectedResult: '2' }); - await assertWatch({ session: session, threadId: t1, expression: 'data["a"]', expectedResult: '1' }); - await assertWatch({ session: session, threadId: t1, expression: 'data["b"]', expectedResult: '2' }); - await assertWatch({ session: session, threadId: t1, expression: '"a" in data', expectedResult: 'True' }); - await assertWatch({ session: session, threadId: t1, expression: '"c" in data', expectedResult: 'False' }); - - // Step: data["c"] = 3 - await stepOver(session, t1); - const t2 = await waitForStop(); - await assertWatch({ session: session, threadId: t2, expression: 'len(data)', expectedResult: '3' }); - await assertWatch({ session: session, threadId: t2, expression: 'data["c"]', expectedResult: '3' }); - await assertWatch({ session: session, threadId: t2, expression: '"c" in data', expectedResult: 'True' }); - - // Step: keys = list(data.keys()) - await stepOver(session, t2); - const t3 = await waitForStop(); - await assertWatch({ session: session, threadId: t3, expression: 'len(keys)', expectedResult: '3' }); - - // Step: total = sum(data.values()) - await stepOver(session, t3); - const t4 = await waitForStop(); - await assertLocalVariable({ session: session, threadId: t4, varName: 'total', expectedValue: '6' }); - await assertWatch({ session: session, threadId: t4, expression: 'total == sum(data.values())', expectedResult: 'True' }); - - // Step: has_a = "a" in data - await stepOver(session, t4); - const t5 = await waitForStop(); - await assertLocalVariable({ session: session, threadId: t5, varName: 'has_a', expectedValue: 'True' }); - }); - - // ──────────────────────────────────────────────────────────────────────── - // 8. REAL DEBUG SESSION: Step into nested function calls - // ──────────────────────────────────────────────────────────────────────── - - test('nested_call: step into function, verify call stack', async function () { - this.timeout(DEBUG_TEST_TIMEOUT_MS); - - const { session, threadId } = await launchAndWaitForBreakpoint([NESTED_CALL_START_LINE], pythonPath); - - await assertCurrentLine({ session: session, threadId: threadId, expectedLine: NESTED_CALL_START_LINE }); - await assertCurrentFunction({ session: session, threadId: threadId, expectedName: 'nested_call' }); - - // Step: a = 5 - await stepOver(session, threadId); - const t1 = await waitForStop(); - await assertLocalVariable({ session: session, threadId: t1, varName: 'a', expectedValue: '5' }); - - // Step INTO: b = double(a) — should enter the double() function - await stepIn(session, t1); - const t2 = await waitForStop(); - await assertCurrentFunction({ session: session, threadId: t2, expectedName: 'double' }); - - // We're inside double(). Check the parameter. - await assertLocalVariable({ session: session, threadId: t2, varName: 'n', expectedValue: '5' }); - - // Step over inside double: result = n * 2 - await stepOver(session, t2); - const t3 = await waitForStop(); - await assertLocalVariable({ session: session, threadId: t3, varName: 'result', expectedValue: '10' }); - await assertWatch({ session: session, threadId: t3, expression: 'result == n * 2', expectedResult: 'True' }); - - // Step out back to nested_call - await stepOut(session, t3); - const t4 = await waitForStop(); - await assertCurrentFunction({ session: session, threadId: t4, expectedName: 'nested_call' }); - await assertLocalVariable({ session: session, threadId: t4, varName: 'b', expectedValue: '10' }); - - // Verify stack depth - const stack = await getStackTrace(session, t4); - assert.ok(stack.stackFrames.length >= 1, 'Should have at least 1 frame'); - assert.strictEqual(stack.stackFrames[0].name, 'nested_call'); - }); - - // ──────────────────────────────────────────────────────────────────────── - // 9. REAL DEBUG SESSION: Loop stepping and accumulator verification - // ──────────────────────────────────────────────────────────────────────── - - test('loop_and_accumulate: step through loop, verify accumulator', async function () { - this.timeout(LOOP_TEST_TIMEOUT_MS); - - const { session, threadId } = await launchAndWaitForBreakpoint([LOOP_START_LINE], pythonPath); - - await assertCurrentLine({ session: session, threadId: threadId, expectedLine: LOOP_START_LINE }); - await assertCurrentFunction({ session: session, threadId: threadId, expectedName: 'loop_and_accumulate' }); - - // Step: total = 0 - await stepOver(session, threadId); - const t1 = await waitForStop(); - await assertLocalVariable({ session: session, threadId: t1, varName: 'total', expectedValue: '0' }); - - // Step into the for loop header - await stepOver(session, t1); - const t2 = await waitForStop(); - - // Step through the loop body: total += i (i=0) - await stepOver(session, t2); - const t3 = await waitForStop(); - await assertWatch({ session: session, threadId: t3, expression: 'total', expectedResult: '0' }); // 0 + 0 = 0 - - // Continue through iterations — step over the for line + body for i=1 - await stepOver(session, t3); - const t4 = await waitForStop(); - await stepOver(session, t4); - const t5 = await waitForStop(); - await assertWatch({ session: session, threadId: t5, expression: 'total', expectedResult: '1' }); // 0 + 1 = 1 - - // i=2 - await stepOver(session, t5); - const t6 = await waitForStop(); - await stepOver(session, t6); - const t7 = await waitForStop(); - await assertWatch({ session: session, threadId: t7, expression: 'total', expectedResult: '3' }); // 1 + 2 = 3 - - // i=3 - await stepOver(session, t7); - const t8 = await waitForStop(); - await stepOver(session, t8); - const t9 = await waitForStop(); - await assertWatch({ session: session, threadId: t9, expression: 'total', expectedResult: '6' }); // 3 + 3 = 6 - - // i=4 - await stepOver(session, t9); - const t10 = await waitForStop(); - await stepOver(session, t10); - const t11 = await waitForStop(); - await assertWatch({ session: session, threadId: t11, expression: 'total', expectedResult: '10' }); // 6 + 4 = 10 - }); - - // ──────────────────────────────────────────────────────────────────────── - // 10. REAL DEBUG SESSION: Conditional branches - // ──────────────────────────────────────────────────────────────────────── - - test('conditional_branches: verify correct branch taken', async function () { - this.timeout(DEBUG_TEST_TIMEOUT_MS); - - const { session, threadId } = await launchAndWaitForBreakpoint([COND_START_LINE], pythonPath); - - await assertCurrentLine({ session: session, threadId: threadId, expectedLine: COND_START_LINE }); - await assertCurrentFunction({ session: session, threadId: threadId, expectedName: 'conditional_branches' }); - - // Step: x = 42 - await stepOver(session, threadId); - const t1 = await waitForStop(); - await assertLocalVariable({ session: session, threadId: t1, varName: 'x', expectedValue: '42' }); - await assertWatch({ session: session, threadId: t1, expression: 'x > 100', expectedResult: 'False' }); - await assertWatch({ session: session, threadId: t1, expression: 'x > 10', expectedResult: 'True' }); - - // Step: if x > 100 — should go to elif - await stepOver(session, t1); - const t2 = await waitForStop(); - - // Step: elif x > 10 — should be true, enter that branch - await stepOver(session, t2); - const t3 = await waitForStop(); - - // Step: label = "medium" - await stepOver(session, t3); - const t4 = await waitForStop(); - await assertLocalVariable({ session: session, threadId: t4, varName: 'label', expectedValue: "'medium'" }); - - // Watch: verify the branch result - await assertWatch({ session: session, threadId: t4, expression: 'label == "medium"', expectedResult: 'True' }); - await assertWatch({ session: session, threadId: t4, expression: 'label != "big"', expectedResult: 'True' }); - await assertWatch({ session: session, threadId: t4, expression: 'label != "small"', expectedResult: 'True' }); - }); - - // ──────────────────────────────────────────────────────────────────────── - // 11. REAL DEBUG SESSION: Exception handling - // ──────────────────────────────────────────────────────────────────────── - - test('exception_handling: step through try/except, verify caught state', async function () { - this.timeout(DEBUG_TEST_TIMEOUT_MS); - - const { session, threadId } = await launchAndWaitForBreakpoint([EXCEPT_START_LINE], pythonPath); - - await assertCurrentLine({ session: session, threadId: threadId, expectedLine: EXCEPT_START_LINE }); - await assertCurrentFunction({ session: session, threadId: threadId, expectedName: 'exception_handling' }); - - // Step: caught = False - await stepOver(session, threadId); - const t1 = await waitForStop(); - await assertLocalVariable({ session: session, threadId: t1, varName: 'caught', expectedValue: 'False' }); - - // Step: error_msg = "" - await stepOver(session, t1); - const t2 = await waitForStop(); - await assertLocalVariable({ session: session, threadId: t2, varName: 'error_msg', expectedValue: "''" }); - - // Step into try block: value = 1 / 0 — this raises ZeroDivisionError - await stepOver(session, t2); - const t3 = await waitForStop(); - - // Step: the exception is caught, now in the except handler - await stepOver(session, t3); - const t4 = await waitForStop(); - - // Step: caught = True - await stepOver(session, t4); - const t5 = await waitForStop(); - await assertLocalVariable({ session: session, threadId: t5, varName: 'caught', expectedValue: 'True' }); - - // Step: error_msg = str(exc) - await stepOver(session, t5); - const t6 = await waitForStop(); - await assertWatch({ session: session, threadId: t6, expression: 'caught', expectedResult: 'True' }); - await assertWatch({ session: session, threadId: t6, expression: 'len(error_msg) > 0', expectedResult: 'True' }); - }); - - // ──────────────────────────────────────────────────────────────────────── - // 12. REAL DEBUG SESSION: Type variety — verify type representations - // ──────────────────────────────────────────────────────────────────────── - - test('type_variety: verify different Python types in debugger', async function () { - this.timeout(DEBUG_TEST_TIMEOUT_MS); - - const { session, threadId } = await launchAndWaitForBreakpoint([TYPE_VARIETY_START_LINE], pythonPath); - - await assertCurrentLine({ session: session, threadId: threadId, expectedLine: TYPE_VARIETY_START_LINE }); - await assertCurrentFunction({ session: session, threadId: threadId, expectedName: 'type_variety' }); - - // an_int = 42 - await stepOver(session, threadId); - const t1 = await waitForStop(); - await assertLocalVariable({ session: session, threadId: t1, varName: 'an_int', expectedValue: '42' }); - await assertWatch({ session: session, threadId: t1, expression: 'type(an_int).__name__', expectedResult: "'int'" }); - - // a_float = 3.14 - await stepOver(session, t1); - const t2 = await waitForStop(); - await assertLocalVariable({ session: session, threadId: t2, varName: 'a_float', expectedValue: '3.14' }); - await assertWatch({ session: session, threadId: t2, expression: 'type(a_float).__name__', expectedResult: "'float'" }); - - // a_bool = True - await stepOver(session, t2); - const t3 = await waitForStop(); - await assertLocalVariable({ session: session, threadId: t3, varName: 'a_bool', expectedValue: 'True' }); - await assertWatch({ session: session, threadId: t3, expression: 'type(a_bool).__name__', expectedResult: "'bool'" }); - - // a_none = None - await stepOver(session, t3); - const t4 = await waitForStop(); - await assertLocalVariable({ session: session, threadId: t4, varName: 'a_none', expectedValue: 'None' }); - await assertWatch({ session: session, threadId: t4, expression: 'a_none is None', expectedResult: 'True' }); - - // a_tuple = (1, "two", 3.0) - await stepOver(session, t4); - const t5 = await waitForStop(); - await assertWatch({ session: session, threadId: t5, expression: 'len(a_tuple)', expectedResult: '3' }); - await assertWatch({ session: session, threadId: t5, expression: 'a_tuple[0]', expectedResult: '1' }); - await assertWatch({ session: session, threadId: t5, expression: 'type(a_tuple).__name__', expectedResult: "'tuple'" }); - - // a_set = {10, 20, 30} - await stepOver(session, t5); - const t6 = await waitForStop(); - await assertWatch({ session: session, threadId: t6, expression: 'len(a_set)', expectedResult: '3' }); - await assertWatch({ session: session, threadId: t6, expression: '10 in a_set', expectedResult: 'True' }); - await assertWatch({ session: session, threadId: t6, expression: 'type(a_set).__name__', expectedResult: "'set'" }); - - // a_bytes = b"hello" - await stepOver(session, t6); - const t7 = await waitForStop(); - await assertWatch({ session: session, threadId: t7, expression: 'len(a_bytes)', expectedResult: '5' }); - await assertWatch({ session: session, threadId: t7, expression: 'type(a_bytes).__name__', expectedResult: "'bytes'" }); - }); - - // ──────────────────────────────────────────────────────────────────────── - // 13. REAL DEBUG SESSION: Class instance — check object attributes - // ──────────────────────────────────────────────────────────────────────── - - test('class_instance: step through, inspect object attributes', async function () { - this.timeout(DEBUG_TEST_TIMEOUT_MS); - - // Break at line 119: p = Point(3, 4) - const { session, threadId } = await launchAndWaitForBreakpoint([CLASS_INSTANCE_START_LINE], pythonPath); - - await assertCurrentLine({ session: session, threadId: threadId, expectedLine: CLASS_INSTANCE_START_LINE }); - await assertCurrentFunction({ session: session, threadId: threadId, expectedName: 'class_instance' }); - - // Step over: p = Point(3, 4) - await stepOver(session, threadId); - const t1 = await waitForStop(); - - // Verify object attributes via watch - await assertWatch({ session: session, threadId: t1, expression: 'p.x', expectedResult: '3' }); - await assertWatch({ session: session, threadId: t1, expression: 'p.y', expectedResult: '4' }); - await assertWatch({ session: session, threadId: t1, expression: 'type(p).__name__', expectedResult: "'Point'" }); - - // Step: mag = p.magnitude() - await stepOver(session, t1); - const t2 = await waitForStop(); - await assertLocalVariable({ session: session, threadId: t2, varName: 'mag', expectedValue: '5.0' }); - - // Watch: verify computed value - await assertWatch({ session: session, threadId: t2, expression: 'mag == 5.0', expectedResult: 'True' }); - await assertWatch({ session: session, threadId: t2, expression: 'p.x ** 2 + p.y ** 2', expectedResult: '25' }); - }); - - // ──────────────────────────────────────────────────────────────────────── - // 14. REAL DEBUG SESSION: Multiple breakpoints, continue between them - // ──────────────────────────────────────────────────────────────────────── - - test('continue between multiple breakpoints', async function () { - this.timeout(DEBUG_TEST_TIMEOUT_MS); - - // Set breakpoints in arithmetic() and string_ops() - const { session, threadId } = await launchAndWaitForBreakpoint([ARITH_Z_LINE, STRING_OPS_MESSAGE_LINE], pythonPath); - - // Should stop at line 13 first (z = x + y in arithmetic()) - await assertCurrentLine({ session: session, threadId: threadId, expectedLine: ARITH_Z_LINE }); - await assertCurrentFunction({ session: session, threadId: threadId, expectedName: 'arithmetic' }); - - // Verify x and y are set - await assertLocalVariable({ session: session, threadId: threadId, varName: 'x', expectedValue: '10' }); - await assertLocalVariable({ session: session, threadId: threadId, varName: 'y', expectedValue: '20' }); - - // Continue to next breakpoint — line 23 (message = ... in string_ops()) - await continueExecution(session, threadId); - const t2 = await waitForStop(); - - await assertCurrentLine({ session: session, threadId: t2, expectedLine: STRING_OPS_MESSAGE_LINE }); - await assertCurrentFunction({ session: session, threadId: t2, expectedName: 'string_ops' }); - await assertLocalVariable({ session: session, threadId: t2, varName: 'greeting', expectedValue: "'hello'" }); - await assertLocalVariable({ session: session, threadId: t2, varName: 'name', expectedValue: "'world'" }); - }); - - // ──────────────────────────────────────────────────────────────────────── - // 15. REAL DEBUG SESSION: Stack trace depth verification - // ──────────────────────────────────────────────────────────────────────── - - test('stack trace shows correct call hierarchy', async function () { - this.timeout(DEBUG_TEST_TIMEOUT_MS); - - // Break inside double(), called from nested_call() - const { session, threadId } = await launchAndWaitForBreakpoint([DOUBLE_RESULT_LINE], pythonPath); - - await assertCurrentLine({ session: session, threadId: threadId, expectedLine: DOUBLE_RESULT_LINE }); - await assertCurrentFunction({ session: session, threadId: threadId, expectedName: 'double' }); - - // Verify the call stack - const stack = await getStackTrace(session, threadId); - assert.ok(stack.stackFrames.length >= 2, `Stack should have >= 2 frames, got ${stack.stackFrames.length}`); - - // Top frame: double - assert.strictEqual(stack.stackFrames[0].name, 'double'); - assert.strictEqual(stack.stackFrames[0].line, DOUBLE_RESULT_LINE); - - // Second frame: nested_call (the caller) - assert.strictEqual(stack.stackFrames[1].name, 'nested_call'); - - // Both frames should reference the fixture file - assert.ok( - stack.stackFrames[0].source?.path?.includes('debug_stepping.py'), - 'Top frame should be in debug_stepping.py' - ); - assert.ok( - stack.stackFrames[1].source?.path?.includes('debug_stepping.py'), - 'Caller frame should be in debug_stepping.py' - ); - }); - - // ──────────────────────────────────────────────────────────────────────── - // 16. REAL DEBUG SESSION: Scopes enumeration - // ──────────────────────────────────────────────────────────────────────── - - test('scopes show Locals and variable details', async function () { - this.timeout(DEBUG_TEST_TIMEOUT_MS); - - const { session, threadId } = await launchAndWaitForBreakpoint([ARITH_Z_LINE], pythonPath); - - await assertCurrentLine({ session: session, threadId: threadId, expectedLine: ARITH_Z_LINE }); - - const stack = await getStackTrace(session, threadId); - const frameId = stack.stackFrames[0].id; - const scopesResponse = await getScopes(session, frameId); - - assert.ok(scopesResponse.scopes.length >= 1, 'Should have at least 1 scope'); - - const scopeNames = scopesResponse.scopes.map((s) => s.name); - assert.ok( - scopeNames.some((n) => n === 'Locals' || n === 'Local'), - `Should have a Locals scope, got: ${scopeNames.join(', ')}` - ); - - // Locals scope should have variables - const localsScope = scopesResponse.scopes.find( - (s) => s.name === 'Locals' || s.name === 'Local' - ); - assert.ok(localsScope, 'Locals scope must exist'); - assert.ok(localsScope.variablesReference > 0, 'Locals must have variablesReference > 0'); - - const varsResponse = await getVariables(session, localsScope.variablesReference); - assert.ok(varsResponse.variables.length > 0, 'Locals should have variables'); - - // x and y should be visible (set before line 13) - const xVar = varsResponse.variables.find((v) => v.name === 'x'); - const yVar = varsResponse.variables.find((v) => v.name === 'y'); - assert.ok(xVar, 'x should be in locals'); - assert.ok(yVar, 'y should be in locals'); - assert.strictEqual(xVar.value, '10'); - assert.strictEqual(yVar.value, '20'); - }); - - // ──────────────────────────────────────────────────────────────────────── - // 17. REAL DEBUG SESSION: Watch expressions — complex evaluations - // ──────────────────────────────────────────────────────────────────────── - - test('watch expressions: evaluate complex expressions', async function () { - this.timeout(DEBUG_TEST_TIMEOUT_MS); - - // Stop at line 15 in arithmetic where x=10, y=20, z=30, w=60 - const { session, threadId } = await launchAndWaitForBreakpoint([ARITH_RESULT_LINE], pythonPath); - - await assertCurrentLine({ session: session, threadId: threadId, expectedLine: ARITH_RESULT_LINE }); - - // Arithmetic watch expressions - await assertWatch({ session: session, threadId: threadId, expression: 'x', expectedResult: '10' }); - await assertWatch({ session: session, threadId: threadId, expression: 'y', expectedResult: '20' }); - await assertWatch({ session: session, threadId: threadId, expression: 'z', expectedResult: '30' }); - await assertWatch({ session: session, threadId: threadId, expression: 'w', expectedResult: '60' }); - - // Computed expressions - await assertWatch({ session: session, threadId: threadId, expression: 'x + y + z', expectedResult: '60' }); - await assertWatch({ session: session, threadId: threadId, expression: 'w // x', expectedResult: '6' }); - await assertWatch({ session: session, threadId: threadId, expression: 'w % 7', expectedResult: '4' }); - await assertWatch({ session: session, threadId: threadId, expression: 'w ** 0', expectedResult: '1' }); - await assertWatch({ session: session, threadId: threadId, expression: 'abs(-w)', expectedResult: '60' }); - await assertWatch({ session: session, threadId: threadId, expression: 'min(x, y, z, w)', expectedResult: '10' }); - await assertWatch({ session: session, threadId: threadId, expression: 'max(x, y, z, w)', expectedResult: '60' }); - await assertWatch({ session: session, threadId: threadId, expression: 'sorted([w, z, y, x])', expectedResult: '[10, 20, 30, 60]' }); - - // Boolean expressions - await assertWatch({ session: session, threadId: threadId, expression: 'x < y', expectedResult: 'True' }); - await assertWatch({ session: session, threadId: threadId, expression: 'x > y', expectedResult: 'False' }); - await assertWatch({ session: session, threadId: threadId, expression: 'x == 10 and y == 20', expectedResult: 'True' }); - await assertWatch({ session: session, threadId: threadId, expression: 'z == x + y', expectedResult: 'True' }); - await assertWatch({ session: session, threadId: threadId, expression: 'w == z * 2', expectedResult: 'True' }); - - // Type checking via watch - await assertWatch({ session: session, threadId: threadId, expression: 'isinstance(x, int)', expectedResult: 'True' }); - await assertWatch({ session: session, threadId: threadId, expression: 'isinstance(x, str)', expectedResult: 'False' }); - - // String formatting via watch - await assertWatch({ session: session, threadId: threadId, expression: 'f"{x} + {y} = {z}"', expectedResult: "'10 + 20 = 30'" }); - - // List comprehension via watch - await assertWatch({ session: session, threadId: threadId, expression: '[v * 2 for v in [x, y, z]]', expectedResult: '[20, 40, 60]' }); - }); - - // ──────────────────────────────────────────────────────────────────────── - // 18. REAL DEBUG SESSION: Hover-style evaluation - // ──────────────────────────────────────────────────────────────────────── - - test('hover evaluation: evaluate expressions in hover context', async function () { - this.timeout(DEBUG_TEST_TIMEOUT_MS); - - const { session, threadId } = await launchAndWaitForBreakpoint([ARITH_Z_LINE], pythonPath); - - const stack = await getStackTrace(session, threadId); - const frameId = stack.stackFrames[0].id; - - // Hover evaluation (simulates mouse hover in editor) - const hoverResult = await evaluateExpression({ session: session, expression: 'x', frameId: frameId, context: 'hover' }); - assert.strictEqual(hoverResult.result, '10'); - assert.ok(hoverResult.type !== undefined && hoverResult.type !== '', 'Hover result should include type info'); - - const hoverResult2 = await evaluateExpression({ session: session, expression: 'y', frameId: frameId, context: 'hover' }); - assert.strictEqual(hoverResult2.result, '20'); - }); - - // ──────────────────────────────────────────────────────────────────────── - // 19. REAL DEBUG SESSION: REPL evaluation - // ──────────────────────────────────────────────────────────────────────── - - test('REPL evaluation: evaluate expressions in debug console context', async function () { - this.timeout(DEBUG_TEST_TIMEOUT_MS); - - const { session, threadId } = await launchAndWaitForBreakpoint([ARITH_Z_LINE], pythonPath); - - const stack = await getStackTrace(session, threadId); - const frameId = stack.stackFrames[0].id; - - // REPL evaluation (simulates Debug Console) - const replResult = await evaluateExpression({ session: session, expression: 'x + y', frameId: frameId, context: 'repl' }); - assert.strictEqual(replResult.result, '30'); - - const replResult2 = await evaluateExpression({ session: session, expression: '[x, y]', frameId: frameId, context: 'repl' }); - assert.strictEqual(replResult2.result, '[10, 20]'); - - const replResult3 = await evaluateExpression({ session: session, expression: 'dict(a=x, b=y)', frameId: frameId, context: 'repl' }); - assert.ok(replResult3.result.includes('a'), 'REPL dict result should contain key a'); - assert.ok(replResult3.result.includes('b'), 'REPL dict result should contain key b'); - }); - - // ──────────────────────────────────────────────────────────────────────── - // 20. Debug session terminates cleanly - // [VSIX-PYTHON-DEBUGGER-DAP-PROXY] Quirk 4: exited-before-terminated ordering - // so activeDebugSession clears when the session ends. - // ──────────────────────────────────────────────────────────────────────── - - test('debug session terminates cleanly after continue past end', async function () { - this.timeout(DEBUG_TEST_TIMEOUT_MS); - - // Break at the return of arithmetic() - const { session, threadId } = await launchAndWaitForBreakpoint([ARITH_RETURN_LINE], pythonPath); - - await assertCurrentLine({ session: session, threadId: threadId, expectedLine: ARITH_RETURN_LINE }); - - const endPromise = waitForDebugSessionEnd(SESSION_END_WAIT_MS); - - // Continue — the program will run through remaining functions and exit - await continueExecution(session, threadId); - - await endPromise; - - // VS Code may not clear activeDebugSession synchronously with the - // terminate event — poll briefly to let the runtime settle. - for (let i = 0; i < SESSION_CLEAR_MAX_POLLS && vscode.debug.activeDebugSession; i++) { - await delay(STOP_POLL_INTERVAL_MS); - } - - assert.strictEqual( - vscode.debug.activeDebugSession, - undefined, - 'No debug session should be active after program completes' - ); - }); - - // ──────────────────────────────────────────────────────────────────────── - // 21. Attach mode test - // ──────────────────────────────────────────────────────────────────────── - - // [LSPDEBUG-ATTACH], [VSIX-PYTHON-DEBUGGER-DAP-PROXY] Quirk 3 (non-destructive - // single-connection slot check): attach connects the editor's DAP client - // directly to a running debugpy server (the LSP is not involved in attach - // traffic). - test('attach to manually spawned debugpy server', async function () { - this.timeout(DEBUG_TEST_TIMEOUT_MS); - - // Start debugpy via LSP command to get a running server - const lspSession = await startDebugSession(pythonPath); - assert.ok(lspSession.port > 0); - - const listening = await checkPortListening(lspSession.host, lspSession.port); - assert.ok(listening, 'debugpy should be listening before attach'); - - // Now try attach mode - const sessionPromise = waitForDebugSessionStart(); - - const started = await vscode.debug.startDebugging(undefined, { - name: 'Basilisk Attach Test', - type: 'basilisk-debug', - request: 'attach', - connect: { - host: lspSession.host, - port: lspSession.port, - }, - }); - assert.ok(started, 'Attach debug session should start'); - - const attachSession = await sessionPromise; - assert.ok(attachSession !== undefined, 'Attach session should be created'); - - // Clean up - await stopActiveDebugSession(); - await stopDebugSession(lspSession.sessionId); - }); - - // ──────────────────────────────────────────────────────────────────────── - // 22. Error notification: bad python path - // ──────────────────────────────────────────────────────────────────────── - - test('startDebugSession with bad python shows error', async function () { - this.timeout(DEBUG_SESSION_TIMEOUT_MS); - - try { - await startDebugSession('/nonexistent/python_for_debugpy_test'); - assert.fail('Expected an error'); - } catch (err: unknown) { - const message = err instanceof Error ? err.message : String(err); - assert.ok( - message.length > 0, - 'Error should have a message explaining what went wrong' - ); - } - }); - - // ──────────────────────────────────────────────────────────────────────── - // 23. Profiler "same process": the debuggee PID is captured from the DAP - // `process` event so CPU profiling can target the same process. [LSPPROF], - // [VSIX-PYTHON-DEBUGGER-DAP-TRACKER] - // ──────────────────────────────────────────────────────────────────────── - - test('captures debuggee PID from the debug session for same-process profiling', async function () { - this.timeout(DEBUG_SESSION_TIMEOUT_MS + STOPPED_EVENT_TIMEOUT_MS); - - const { session } = await launchAndWaitForBreakpoint([34], pythonPath); - - // The DAP `process` event carries systemProcessId; the proxy captures it - // into the store keyed by VS Code session id. Poll briefly because the - // event can arrive shortly after the first stop. - let pid: number | undefined; - for (let i = 0; i < 40 && pid === undefined; i++) { - pid = getStore()?.getDebuggeeProcessId(session.id); - if (pid === undefined) { - await delay(50); - } - } - - assert.ok( - pid !== undefined && pid > 0, - `debuggee PID should be captured for session ${session.id}, got ${String(pid)}` - ); - - await stopActiveDebugSession(); - }); - - // ──────────────────────────────────────────────────────────────────────── - // 24. Memory profiling round-trip against REAL debugpy: the editor couriers - // the LSP's injection scripts via DAP `evaluate` and posts the output - // back to `basilisk.memory.ingest`, which parses a real tracemalloc - // snapshot. [LSPPROF] PROFILE-MEMORY - // ──────────────────────────────────────────────────────────────────────── - - test('memory round-trip: tracemalloc start + snapshot via DAP evaluate', async function () { - this.timeout(DEBUG_SESSION_TIMEOUT_MS + STOPPED_EVENT_TIMEOUT_MS); - - await launchAndWaitForBreakpoint([34], pythonPath); - - // Exercise the real bridge (dap-evaluate.ts): resolve the stopped frame - // the same way the memory commands do. - const frameId = await currentStoppedFrameId(); - if (frameId === null) { - assert.fail('currentStoppedFrameId should resolve a frame while paused'); - } - - // 1. Mint a memory session + fetch the start script from the LSP. - const start = await vscode.commands.executeCommand<{ memorySessionId?: string; script?: string }>( - 'basilisk.memory.start', - { tracebackDepth: 25 } - ); - assert.ok(start.memorySessionId !== undefined, 'start should return a memorySessionId'); - assert.ok(start.script?.includes('tracemalloc.start'), 'start script should start tracemalloc'); - - // 2. Inject tracemalloc into the live debuggee, then allocate ~2 MB so the - // snapshot has something concrete to report. - await evaluateInDebugSession(start.script ?? '', frameId); - await evaluateInDebugSession( - 'global _bsk_leak\n_bsk_leak = [bytearray(1024) for _ in range(2000)]', - frameId - ); - - // 3. Fetch the snapshot script, run it in the debuggee, courier output back. - const snapCmd = await vscode.commands.executeCommand<{ script?: string }>( - 'basilisk.memory.snapshot', - { memorySessionId: start.memorySessionId } - ); - assert.ok(snapCmd.script?.includes('__BASILISK_MEM__'), 'snapshot script should print the marker'); - - const output = await evaluateInDebugSession(snapCmd.script ?? '', frameId); - assert.ok(output !== null, 'evaluate should return the snapshot output'); - const result = await vscode.commands.executeCommand<{ kind?: string; currentMemory?: number; snapshotId?: string }>( - 'basilisk.memory.ingest', - { memorySessionId: start.memorySessionId, output } - ); - - assert.strictEqual(result.kind, 'snapshot', 'ingest should yield a snapshot'); - assert.ok(typeof result.snapshotId === 'string', 'snapshot should have an id'); - assert.ok( - typeof result.currentMemory === 'number' && result.currentMemory > 0, - `tracemalloc should report tracked memory, got ${String(result.currentMemory)}` - ); - - await stopActiveDebugSession(); - }); - - // ──────────────────────────────────────────────────────────────────────── - // 25. Memory snapshot on a LARGE heap must not stall the paused evaluate: - // pydevd warns after PYDEVD_WARN_EVALUATION_TIMEOUT (3 s) with a wall - // of timeout text in the debug console — the user-reported "take a - // snapshot → did not finish after 3.00 seconds" bug. The snapshot - // round-trip must both succeed AND never trip that warning. - // [LSPPROF] PROFILE-MEMORY-HOWTO - // ──────────────────────────────────────────────────────────────────────── - - test('large-heap memory snapshot does not trip the pydevd evaluation stall warning', async function () { - this.timeout(LARGE_HEAP_MEM_TIMEOUT_MS); - - // A helper module with NO breakpoints builds the heap, so pydevd's - // line-tracing of breakpoint files doesn't dominate the build; the - // ~600k-item comprehension yields ~3M live tracemalloc traces. - const heapHelper = path.join(tmpDir, 'bigheap_build.py'); - const heapMain = path.join(tmpDir, 'bigheap_main.py'); - fs.writeFileSync(heapHelper, [ - '"""Builds a large traced heap (~3M tracemalloc traces)."""', - 'HEAP_ITEMS = 600000', - '', - '', - 'def build():', - ' return [(str(i), [i]) for i in range(HEAP_ITEMS)]', - '', - ].join('\n')); - fs.writeFileSync(heapMain, [ - '"""Large-heap fixture for the memory evaluation-budget regression."""', - 'import bigheap_build', - '', - 'anchor_start = 1', // line 4: BP A — inject tracemalloc - 'keep = bigheap_build.build()', // line 5: heap built while traced - 'anchor_ready = len(keep)', // line 6: BP B — take the snapshot - 'print(anchor_ready)', - '', - ].join('\n')); - - clearAllBreakpoints(); - setBreakpoints(heapMain, [BIGHEAP_START_LINE, BIGHEAP_READY_LINE]); - const sessionPromise = waitForDebugSessionStart(); - const stoppedPromise = waitForStop(); - const started = await vscode.debug.startDebugging(undefined, { - name: 'Basilisk Big Heap Memory Test', - type: 'basilisk-debug', - request: 'launch', - program: heapMain, - python: pythonPath, - stopOnEntry: false, - justMyCode: true, - console: 'internalConsole', - }); - assert.ok(started, 'debug session should start'); - const session = await sessionPromise; - const threadId = await stoppedPromise; - - // BP A: start tracemalloc in the live debuggee (the real start leg). - const frameA = await currentStoppedFrameId(); - assert.ok(frameA !== null, 'should resolve a frame at the start anchor'); - const start = await vscode.commands.executeCommand<{ memorySessionId?: string; script?: string }>( - 'basilisk.memory.start', - { tracebackDepth: 25 } - ); - assert.ok(start.memorySessionId !== undefined && start.script !== undefined, 'start leg should mint a session + script'); - await evaluateInDebugSession(start.script, frameA); - - // Run to BP B — the big heap is built under tracemalloc on the way. - // waitForStop() would resolve early (debugpy answers stackTrace even - // for a RUNNING thread with a sampled frame), so poll the tracker-gated - // currentStoppedFrameId until the breakpoint genuinely lands. - await continueExecution(session, threadId); - let frameB: number | null = null; - const buildDeadline = Date.now() + HEAP_BUILD_STOP_TIMEOUT_MS; - while (frameB === null && Date.now() < buildDeadline) { - frameB = await currentStoppedFrameId(); - if (frameB === null) { - await delay(STOP_POLL_INTERVAL_MS); - } - } - assert.ok(frameB !== null, 'should resolve a frame at the ready anchor'); - - // BP B: take the snapshot exactly as the command path does, and watch - // the debug console for pydevd's evaluation-stall warning. - const consoleCursor = debugOutputCursor(session.id); - const snapCmd = await vscode.commands.executeCommand<{ script?: string }>( - 'basilisk.memory.snapshot', - { memorySessionId: start.memorySessionId } - ); - assert.ok(snapCmd.script !== undefined, 'snapshot leg should return a script'); - const output = await evaluateInDebugSession(snapCmd.script, frameB); - assert.ok(output !== null, 'evaluate should return the snapshot output'); - const result = await vscode.commands.executeCommand<{ kind?: string; currentMemory?: number }>( - 'basilisk.memory.ingest', - { memorySessionId: start.memorySessionId, output } - ); - - assert.strictEqual(result.kind, 'snapshot', 'the large-heap snapshot must still ingest'); - assert.ok( - typeof result.currentMemory === 'number' && result.currentMemory > 50_000_000, - `the ~600k-item heap must be measured, got ${String(result.currentMemory)}` - ); - const consoleOut = debugOutputSince(session.id, consoleCursor); - assert.ok( - !consoleOut.includes(PYDEVD_STALL_WARNING), - `the snapshot evaluate stalled past pydevd's ${String(PYDEVD_WARN_TIMEOUT_SECS)}s budget — the debug console got the ` + - `user-visible timeout wall of text:\n${consoleOut.slice(0, 800)}` - ); - - await stopActiveDebugSession(); - }); -}); - -// ── Zero-config debug start [VSIX-PYTHON-DEBUGGER-START] ───────────────────── -// Pure tests for the DebugConfigurationProvider's defaulting logic that lets -// "Run and Debug" / F5 start without a launch.json. -/** - * The truly-empty object VS Code hands the provider when there is no launch.json. - * - * `DebugConfiguration` declares `type`, `name` and `request` as required, so the - * empty case cannot be spelled without one assertion — and reproducing it exactly - * is the whole point: the defaulting logic under test exists precisely because - * VS Code passes a value its own declared shape forbids. - */ -function emptyLaunchConfig(): vscode.DebugConfiguration { - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- see above. - return {} as vscode.DebugConfiguration; -} - -suite('Basilisk Debug Config Provider', () => { - test('empty config + Python file synthesizes a current-file launch', () => { - // VS Code passes a truly-empty {} when starting with no launch.json. - const resolved = applyDebugConfigDefaults(emptyLaunchConfig(), 'python'); - assert.strictEqual(resolved.type, 'basilisk-debug'); - assert.strictEqual(resolved.request, 'launch'); - assert.strictEqual(resolved.program, ACTIVE_FILE_VARIABLE); - }); - - test('empty config + non-Python file is left untouched', () => { - const empty = emptyLaunchConfig(); - const resolved = applyDebugConfigDefaults(empty, 'rust'); - assert.strictEqual(resolved.type, undefined); - assert.strictEqual(resolved.program, undefined); - }); - - test('launch config missing program defaults to the current file', () => { - const resolved = applyDebugConfigDefaults( - { name: 'x', type: 'basilisk-debug', request: 'launch' }, - 'python' - ); - assert.strictEqual(resolved.program, ACTIVE_FILE_VARIABLE); - }); - - test('a complete config passes through unchanged', () => { - const full: vscode.DebugConfiguration = { - name: "x", type: "basilisk-debug", request: "launch", program: "/tmp/a.py", - }; - const resolved = applyDebugConfigDefaults(full, 'python'); - assert.strictEqual(resolved.program, '/tmp/a.py'); - }); - - // [PROFILE-LAUNCH-NOSTOP] #145: the global `basilisk.profiler.profileOnLaunch` - // setting is a second, equivalent trigger of an auto-profiling run (see - // shouldProfileOnLaunch). It must mark the launch so the DAP proxy strips - // breakpoints — otherwise a plain F5 with the global setting on still halts - // at user breakpoints, the exact dead-stop #145 forbids. - test('global profiler.profileOnLaunch marks an ordinary launch as a profiling run (#145)', () => { - const resolved = applyDebugConfigDefaults(emptyLaunchConfig(), 'python', true); - assert.strictEqual(resolved.type, 'basilisk-debug', 'still synthesizes a current-file launch'); - assert.strictEqual( - resolved.profileOnLaunch, - true, - 'global profile-on-launch must mark the launch so the proxy neutralises breakpoints (#145)', - ); - }); - - // dap-1: a "Run & Track Memory" launch is NOT a CPU run. The global CPU - // setting must not stamp it `profileOnLaunch` — otherwise the proxy strips - // its breakpoints and the CPU sampler auto-starts alongside tracemalloc, - // both fighting over the single entry pause. - test('global profiler.profileOnLaunch does NOT contaminate a memory-tracking launch (dap-1)', () => { - const memory: vscode.DebugConfiguration = { - name: 'Run & Track Memory (Current File)', type: 'basilisk-debug', request: 'launch', - program: '/tmp/a.py', memoryTrackOnLaunch: true, - }; - const resolved = applyDebugConfigDefaults(memory, 'python', true); - assert.notStrictEqual( - resolved.profileOnLaunch, - true, - 'a memory launch must never be stamped as a CPU profiling run, even with the global setting on (dap-1)', - ); - assert.strictEqual(resolved.memoryTrackOnLaunch, true, 'the memory-tracking flag is preserved'); - }); - - test('an explicit profiling launch stays marked (idempotent) and a normal launch is not', () => { - const explicit = applyDebugConfigDefaults( - { name: 'x', type: 'basilisk-debug', request: 'launch', program: '/tmp/a.py', profileOnLaunch: true }, - 'python', - false, - ); - assert.strictEqual(explicit.profileOnLaunch, true, 'an explicit Run & Profile launch stays a profiling run'); - - const normal = applyDebugConfigDefaults(emptyLaunchConfig(), 'python', false); - assert.notStrictEqual(normal.profileOnLaunch, true, 'ordinary F5 (global off) must remain a real debug session with breakpoints'); - }); -}); diff --git a/vscode-extension/src/test/suite/extension-manifest.test.ts b/vscode-extension/src/test/suite/extension-manifest.test.ts deleted file mode 100644 index fb6b22e04..000000000 --- a/vscode-extension/src/test/suite/extension-manifest.test.ts +++ /dev/null @@ -1,290 +0,0 @@ -// Tests for [VSIX]. See docs/specs/VSIX-SPEC.md#VSIX -/** - * Contract tests for ./extension-manifest. - * - * Every other suite reads the manifest through that module, so a reader that - * quietly returned `[]` would turn each of those suites green while asserting - * against nothing. These tests hold the readers to the file itself: the manifest - * is parsed straight off disk and compared with what the readers report, so a - * narrowing step that drops a contribution fails here rather than hiding - * everywhere else. - */ - -import * as assert from "assert"; -import * as fs from "fs"; -import * as path from "path"; -import { - manifestActivationEvents, - manifestCommands, - manifestConfigurationProperties, - manifestContributes, - manifestDebuggers, - manifestDisplayName, - manifestKeybindings, - manifestMenu, - manifestMenus, - manifestViews, - manifestViewsWelcome, -} from "./extension-manifest"; -import { asRecord, rawField, recordField } from "../../unknown-shape"; - -/** The container that hosts every Basilisk view. */ -const EXPLORER_CONTAINER = "basilisk-explorer"; - -/** The debugger type the extension contributes. */ -const DEBUGGER_TYPE = "basilisk-debug"; - -/** Every command, menu and setting the extension owns is namespaced. */ -const NAMESPACE = "basilisk."; - -/** `package.json` as parsed straight off disk, bypassing the readers. */ -function manifestOnDisk(): Record<string, unknown> { - const manifestPath = path.resolve(__dirname, "../../../package.json"); - const parsed: unknown = JSON.parse(fs.readFileSync(manifestPath, "utf8")); - return asRecord(parsed); -} - -/** `contributes` as parsed straight off disk. */ -function contributesOnDisk(): Record<string, unknown> { - return recordField(manifestOnDisk(), "contributes") ?? {}; -} - -/** The raw entries of one on-disk contribution array. */ -function onDisk(key: string): Record<string, unknown>[] { - const raw = rawField(contributesOnDisk(), key); - return Array.isArray(raw) ? raw.map(asRecord) : []; -} - -/** - * The raw entries of one TOP-LEVEL manifest array. - * - * `activationEvents` sits beside `contributes` rather than inside it, so - * reading it with `onDisk` would find nothing and quietly compare the readers - * against an empty list — the exact failure mode this suite exists to catch. - */ -function topLevelOnDisk(key: string): unknown[] { - const raw = rawField(manifestOnDisk(), key); - return Array.isArray(raw) ? raw : []; -} - -suite("Extension manifest readers [VSIX]", () => { - test("identity fields match the file", () => { - const displayName = manifestDisplayName(); - assert.strictEqual(displayName, "Basilisk", "displayName must be Basilisk"); - assert.strictEqual(displayName, manifestOnDisk().displayName, "must match the file"); - assert.ok(displayName.length > 0, "displayName must never fall back to empty"); - assert.strictEqual(displayName.trim(), displayName, "displayName must not be padded"); - }); - - test("activation events match the file exactly", () => { - const events = manifestActivationEvents(); - const declared = topLevelOnDisk("activationEvents"); - assert.ok(Array.isArray(events), "activationEvents must be an array"); - assert.ok(events.length > 0, "the extension must declare activation events"); - assert.ok(events.includes("onLanguage:python"), "must activate on Python"); - assert.ok(events.includes("onDebug"), "must activate for debugging"); - assert.ok( - events.includes(`onDebugResolve:${DEBUGGER_TYPE}`), - "must activate when resolving its own debug type", - ); - assert.deepStrictEqual( - events, - declared, - "the reader must report the file's activation events verbatim — order included", - ); - for (const event of events) { - assert.strictEqual(typeof event, "string", `activation event ${event} must be a string`); - assert.ok(event.length > 0, "an activation event must never be empty"); - } - }); - - test("every contributed command is read whole", () => { - const commands = manifestCommands(); - assert.ok(commands.length > 0, "the extension must contribute commands"); - assert.strictEqual( - commands.length, - onDisk("commands").length, - "no contributed command may be dropped by narrowing", - ); - for (const command of commands) { - assert.ok(command.command.length > 0, "a command id must never be empty"); - assert.ok( - command.command.startsWith(NAMESPACE), - `command "${command.command}" must be namespaced`, - ); - assert.ok(command.title.length > 0, `command "${command.command}" must have a title`); - assert.ok( - command.category === undefined || command.category.length > 0, - `command "${command.command}" must not declare an empty category`, - ); - assert.ok( - command.icon === undefined || typeof command.icon === "string" || typeof command.icon === "object", - `command "${command.command}" icon must be a glyph or a light/dark pair`, - ); - } - }); - - test("command ids are unique", () => { - const ids = manifestCommands().map((command) => command.command); - assert.strictEqual(new Set(ids).size, ids.length, `duplicate command ids: ${ids.join(", ")}`); - }); - - test("command titles and ids survive narrowing verbatim", () => { - const read = new Map(manifestCommands().map((command) => [command.command, command.title])); - for (const entry of onDisk("commands")) { - const id = entry.command; - assert.strictEqual(typeof id, "string", "every on-disk command needs an id"); - assert.ok(read.has(String(id)), `command "${String(id)}" must be reported`); - assert.strictEqual(read.get(String(id)), entry.title, `title of "${String(id)}" must match`); - } - }); -}); - -suite("Extension manifest menus and views [VSIX]", () => { - test("keybindings bind declared commands", () => { - const keybindings = manifestKeybindings(); - const ids = new Set(manifestCommands().map((command) => command.command)); - assert.ok(keybindings.length > 0, "the extension must contribute keybindings"); - assert.strictEqual(keybindings.length, onDisk("keybindings").length, "none may be dropped"); - for (const binding of keybindings) { - assert.ok(binding.command.length > 0, "a keybinding must name a command"); - assert.ok(ids.has(binding.command), `keybinding "${binding.command}" must be a real command`); - assert.ok( - binding.key === undefined || binding.key.length > 0, - `keybinding "${binding.command}" must not declare an empty key`, - ); - } - }); - - test("every menu section is reported, and each entry names a real command", () => { - const menus = manifestMenus(); - const sections = Object.keys(menus); - const ids = new Set(manifestCommands().map((command) => command.command)); - assert.ok(sections.length > 0, "the extension must contribute menus"); - assert.ok(sections.includes("view/title"), "the panel toolbar must be contributed"); - assert.ok(sections.includes("view/item/context"), "row context menus must be contributed"); - for (const section of sections) { - const entries = menus[section]; - assert.ok(Array.isArray(entries), `menu "${section}" must read as an array`); - assert.deepStrictEqual( - entries, - manifestMenu(section), - `manifestMenu("${section}") must agree with manifestMenus()`, - ); - for (const entry of entries) { - assert.ok(entry.command.length > 0, `an entry in "${section}" must name a command`); - assert.ok( - ids.has(entry.command), - `menu "${section}" references undeclared command "${entry.command}"`, - ); - assert.ok( - entry.group === undefined || entry.group.length > 0, - `entry "${entry.command}" must not declare an empty group`, - ); - } - } - }); - - test("an unknown menu id reads as empty, never as a throw", () => { - assert.deepStrictEqual(manifestMenu("no/such/menu"), [], "unknown menus must read empty"); - assert.deepStrictEqual(manifestMenu(""), [], "an empty menu id must read empty"); - }); - - test("views are contributed to the Basilisk container with unique ids", () => { - const views = manifestViews(); - const explorer = views[EXPLORER_CONTAINER] ?? []; - assert.ok(EXPLORER_CONTAINER in views, "views must live in the Basilisk container"); - assert.ok(explorer.length > 0, "the container must host at least one view"); - const ids = explorer.map((view) => view.id); - assert.strictEqual(new Set(ids).size, ids.length, `duplicate view ids: ${ids.join(", ")}`); - for (const view of explorer) { - assert.ok(view.id.length > 0, "a view id must never be empty"); - assert.ok(view.id.startsWith(NAMESPACE), `view "${view.id}" must be namespaced`); - assert.ok(view.name.length > 0, `view "${view.id}" must have a name`); - assert.ok( - view.when === undefined || view.when.length > 0, - `view "${view.id}" must not declare an empty when clause`, - ); - } - }); - - test("welcome content targets views that exist", () => { - const welcome = manifestViewsWelcome(); - const ids = new Set((manifestViews()[EXPLORER_CONTAINER] ?? []).map((view) => view.id)); - assert.ok(welcome.length > 0, "the extension must contribute welcome content"); - assert.strictEqual(welcome.length, onDisk("viewsWelcome").length, "none may be dropped"); - for (const entry of welcome) { - assert.ok(entry.view.length > 0, "welcome content must name a view"); - assert.ok(ids.has(entry.view), `welcome content targets unknown view "${entry.view}"`); - assert.ok(entry.contents.length > 0, `welcome content for "${entry.view}" must not be empty`); - } - }); -}); - -suite("Extension manifest debuggers and settings [VSIX]", () => { - test("the Basilisk debugger is contributed with both config schemas", () => { - const debuggers = manifestDebuggers(); - assert.ok(debuggers.length > 0, "the extension must contribute a debugger"); - const basilisk = debuggers.find((entry) => entry.type === DEBUGGER_TYPE); - assert.ok(basilisk, `${DEBUGGER_TYPE} must be contributed`); - assert.strictEqual(basilisk.label, "Python (Basilisk)", "the debugger must be labelled"); - assert.ok(basilisk.configurationAttributes.launch, "launch config must be declared"); - assert.ok(basilisk.configurationAttributes.attach, "attach config must be declared"); - const launch = basilisk.configurationAttributes.launch?.properties ?? {}; - assert.ok("program" in launch, "launch must accept a program"); - assert.ok("args" in launch, "launch must accept args"); - assert.ok("justMyCode" in launch, "launch must accept justMyCode"); - const attach = basilisk.configurationAttributes.attach?.properties ?? {}; - assert.ok(Object.keys(attach).length > 0, "attach must declare properties"); - }); - - test("settings are namespaced and typed", () => { - const properties = manifestConfigurationProperties(); - const keys = Object.keys(properties); - assert.ok(keys.length > 0, "the extension must declare settings"); - for (const key of keys) { - assert.ok(key.startsWith(NAMESPACE), `setting "${key}" must be namespaced`); - const schema = properties[key]; - assert.strictEqual(typeof schema, "object", `setting "${key}" must have a schema object`); - assert.ok( - "type" in schema || "anyOf" in schema || "enum" in schema, - `setting "${key}" must declare a type`, - ); - } - }); - - test("every declared setting is reported", () => { - const properties = manifestConfigurationProperties(); - const configuration = recordField(contributesOnDisk(), "configuration") ?? {}; - const declared = recordField(configuration, "properties") ?? {}; - assert.strictEqual( - Object.keys(properties).length, - Object.keys(declared).length, - "no setting may be dropped by narrowing", - ); - for (const key of Object.keys(declared)) { - assert.ok(key in properties, `setting "${key}" must be reported`); - } - }); - - test("the aggregate reader agrees with every granular reader", () => { - const contributes = manifestContributes(); - assert.deepStrictEqual(contributes.commands, manifestCommands(), "commands must agree"); - assert.deepStrictEqual(contributes.keybindings, manifestKeybindings(), "keybindings must agree"); - assert.deepStrictEqual(contributes.menus, manifestMenus(), "menus must agree"); - assert.deepStrictEqual(contributes.views, manifestViews(), "views must agree"); - assert.deepStrictEqual(contributes.viewsWelcome, manifestViewsWelcome(), "welcome must agree"); - assert.deepStrictEqual(contributes.debuggers, manifestDebuggers(), "debuggers must agree"); - assert.deepStrictEqual( - contributes.configurationProperties, - manifestConfigurationProperties(), - "settings must agree", - ); - }); - - test("readers are stable across calls", () => { - assert.deepStrictEqual(manifestCommands(), manifestCommands(), "commands must be stable"); - assert.deepStrictEqual(manifestViews(), manifestViews(), "views must be stable"); - assert.strictEqual(manifestDisplayName(), manifestDisplayName(), "identity must be stable"); - }); -}); diff --git a/vscode-extension/src/test/suite/extension-manifest.ts b/vscode-extension/src/test/suite/extension-manifest.ts deleted file mode 100644 index 13fd5d6f5..000000000 --- a/vscode-extension/src/test/suite/extension-manifest.ts +++ /dev/null @@ -1,277 +0,0 @@ -// Implements [VSIX]. See docs/specs/VSIX-SPEC.md#VSIX -/** - * The live extension manifest, narrowed once for every suite that reads it. - * - * `vscode.Extension.packageJSON` is typed `any`, so every suite that asserted - * against a contribution used to write its own `PackageJSON` interface and - * assert the manifest into it. Six near-identical copies of that interface - * drifted apart, and each `as PackageJSON` told the compiler a shape nobody - * had checked: rename a contribution in `package.json` and the reads keep - * type-checking while silently yielding `undefined`, so the assertion passes - * against nothing. - * - * These readers narrow the manifest at the moment of reading. A contribution - * that is missing or reshaped comes back empty rather than as a lie, which is - * what makes the suites that assert on it fail loudly. - */ - -import * as assert from "assert"; -import * as vscode from "vscode"; -import { - asRecord, - isRecord, - rawField, - recordArrayField, - recordField, - stringArrayField, - stringField, -} from "../../unknown-shape"; -import { EXTENSION_ID } from "./test-helpers"; - -/** A `contributes.commands` entry. */ -export interface CommandContribution { - readonly command: string; - readonly title: string; - readonly category?: string; - readonly icon?: string | Record<string, unknown>; - readonly enablement?: string; -} - -/** A `contributes.menus[*]` entry. */ -export interface MenuContribution { - readonly command: string; - readonly when: string; - readonly group?: string; -} - -/** A `contributes.views[*]` entry. */ -export interface ViewContribution { - readonly id: string; - readonly name: string; - readonly when?: string; - readonly visibility?: string; -} - -/** A `contributes.viewsWelcome` entry. */ -export interface WelcomeContribution { - readonly view: string; - readonly contents: string; - readonly when?: string; -} - -/** A `contributes.keybindings` entry. */ -export interface KeybindingContribution { - readonly command: string; - readonly key?: string; - readonly when?: string; -} - -/** One `launch`/`attach` schema of a contributed debugger. */ -export interface DebuggerConfigSection { - readonly properties: Record<string, unknown>; -} - -/** A `contributes.debuggers` entry. */ -export interface DebuggerContribution { - readonly type: string; - readonly label: string; - readonly configurationAttributes: { - readonly launch?: DebuggerConfigSection; - readonly attach?: DebuggerConfigSection; - }; -} - -/** Every declared setting, keyed by its dotted configuration id. */ -export type ConfigurationProperties = Record<string, Record<string, unknown>>; - -/** Every contribution the manifest declares, already narrowed. */ -export interface Contributes { - readonly commands: CommandContribution[]; - readonly keybindings: KeybindingContribution[]; - readonly menus: Record<string, MenuContribution[]>; - readonly views: Record<string, ViewContribution[]>; - readonly viewsWelcome: WelcomeContribution[]; - readonly debuggers: DebuggerContribution[]; - readonly configurationProperties: ConfigurationProperties; -} - -/** The manifest VS Code loaded for the installed extension. */ -function manifest(): Record<string, unknown> { - const extension = vscode.extensions.getExtension(EXTENSION_ID); - assert.ok(extension, `Extension ${EXTENSION_ID} must be installed`); - const packageJson: unknown = extension.packageJSON; - return asRecord(packageJson); -} - -/** The manifest's `contributes` object, or an empty one. */ -function contributes(): Record<string, unknown> { - return recordField(manifest(), "contributes") ?? {}; -} - -/** The manifest's `contributes.menus` object, or an empty one. */ -function menuSections(): Record<string, unknown> { - return recordField(contributes(), "menus") ?? {}; -} - -/** - * A command's icon, which the manifest may give as a glyph or as a - * light/dark pair. Passed through unchanged so equality assertions still - * compare against whatever `package.json` actually declares. - */ -function iconOf(entry: Record<string, unknown>): string | Record<string, unknown> | undefined { - const icon = rawField(entry, "icon"); - if (typeof icon === "string") { - return icon; - } - return isRecord(icon) ? icon : undefined; -} - -function toCommand(entry: Record<string, unknown>): CommandContribution { - return { - command: stringField(entry, "command") ?? "", - title: stringField(entry, "title") ?? "", - category: stringField(entry, "category"), - icon: iconOf(entry), - enablement: stringField(entry, "enablement"), - }; -} - -function toMenu(entry: Record<string, unknown>): MenuContribution { - return { - command: stringField(entry, "command") ?? "", - when: stringField(entry, "when") ?? "", - group: stringField(entry, "group"), - }; -} - -function toView(entry: Record<string, unknown>): ViewContribution { - return { - id: stringField(entry, "id") ?? "", - name: stringField(entry, "name") ?? "", - when: stringField(entry, "when"), - visibility: stringField(entry, "visibility"), - }; -} - -function toWelcome(entry: Record<string, unknown>): WelcomeContribution { - return { - view: stringField(entry, "view") ?? "", - contents: stringField(entry, "contents") ?? "", - when: stringField(entry, "when"), - }; -} - -function toKeybinding(entry: Record<string, unknown>): KeybindingContribution { - return { - command: stringField(entry, "command") ?? "", - key: stringField(entry, "key"), - when: stringField(entry, "when"), - }; -} - -function toConfigSection( - attributes: Record<string, unknown>, - key: string, -): DebuggerConfigSection | undefined { - const section = recordField(attributes, key); - if (section === undefined) { - return undefined; - } - return { properties: recordField(section, "properties") ?? {} }; -} - -function toDebugger(entry: Record<string, unknown>): DebuggerContribution { - const attributes = recordField(entry, "configurationAttributes") ?? {}; - return { - type: stringField(entry, "type") ?? "", - label: stringField(entry, "label") ?? "", - configurationAttributes: { - launch: toConfigSection(attributes, "launch"), - attach: toConfigSection(attributes, "attach"), - }, - }; -} - -/** The manifest's `displayName`, or `""` when it declares none. */ -export function manifestDisplayName(): string { - return stringField(manifest(), "displayName") ?? ""; -} - -/** The manifest's `activationEvents`, or `[]` when it declares none. */ -export function manifestActivationEvents(): string[] { - return stringArrayField(manifest(), "activationEvents"); -} - -/** Every contributed command. */ -export function manifestCommands(): CommandContribution[] { - return recordArrayField(contributes(), "commands").map(toCommand); -} - -/** Every contributed keybinding. */ -export function manifestKeybindings(): KeybindingContribution[] { - return recordArrayField(contributes(), "keybindings").map(toKeybinding); -} - -/** The entries of one menu, e.g. `view/title` or `debug/toolBar`. */ -export function manifestMenu(menuId: string): MenuContribution[] { - return recordArrayField(menuSections(), menuId).map(toMenu); -} - -/** Every contributed menu, keyed by menu id. */ -export function manifestMenus(): Record<string, MenuContribution[]> { - const sections = menuSections(); - const entries: [string, MenuContribution[]][] = Object.keys(sections).map( - (menuId) => [menuId, manifestMenu(menuId)], - ); - return Object.fromEntries(entries); -} - -/** Every contributed view, keyed by the container that hosts it. */ -export function manifestViews(): Record<string, ViewContribution[]> { - const views = recordField(contributes(), "views") ?? {}; - const entries: [string, ViewContribution[]][] = Object.keys(views).map( - (container) => [container, recordArrayField(views, container).map(toView)], - ); - return Object.fromEntries(entries); -} - -/** Every contributed viewsWelcome entry. */ -export function manifestViewsWelcome(): WelcomeContribution[] { - return recordArrayField(contributes(), "viewsWelcome").map(toWelcome); -} - -/** Every contributed debugger. */ -export function manifestDebuggers(): DebuggerContribution[] { - return recordArrayField(contributes(), "debuggers").map(toDebugger); -} - -/** - * Every declared setting. - * - * `contributes.configuration` is allowed to be a single section or an array of - * them; both forms are flattened into one map so callers never branch on it. - */ -export function manifestConfigurationProperties(): ConfigurationProperties { - const declared = rawField(contributes(), "configuration"); - const sections = Array.isArray(declared) ? declared.filter(isRecord) : [asRecord(declared)]; - const entries: [string, Record<string, unknown>][] = sections.flatMap((section) => { - const properties = recordField(section, "properties") ?? {}; - return Object.keys(properties).map( - (key): [string, Record<string, unknown>] => [key, asRecord(properties[key])], - ); - }); - return Object.fromEntries(entries); -} - -/** Every contribution at once, for suites that assert across several. */ -export function manifestContributes(): Contributes { - return { - commands: manifestCommands(), - keybindings: manifestKeybindings(), - menus: manifestMenus(), - views: manifestViews(), - viewsWelcome: manifestViewsWelcome(), - debuggers: manifestDebuggers(), - configurationProperties: manifestConfigurationProperties(), - }; -} diff --git a/vscode-extension/src/test/suite/extension.test.ts b/vscode-extension/src/test/suite/extension.test.ts deleted file mode 100644 index fc34fd6e9..000000000 --- a/vscode-extension/src/test/suite/extension.test.ts +++ /dev/null @@ -1,275 +0,0 @@ -// Tests for [VSIX]. See docs/specs/VSIX-SPEC.md#VSIX -import { delay } from '../../timeouts'; -import * as assert from 'assert'; -import * as vscode from 'vscode'; -import * as path from 'path'; -import { getStore } from '../../extension'; -import { POLL_INTERVAL_MS, WAIT_MS } from "./test-helpers"; -import { - manifestActivationEvents, - manifestDisplayName -} from "./extension-manifest"; - -const EXTENSION_ID = 'Nimblesite.basilisk'; - -// eslint-disable-next-line max-lines-per-function -suite('Basilisk Extension E2E Tests', () => { - - suiteSetup(async () => { - // Ensure the extension is activated by opening a Python file. - const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? __dirname; - const pyFilePath = path.join(workspaceRoot, '__basilisk_test__.py'); - const pyUri = vscode.Uri.file(pyFilePath); - - // Create a minimal Python file to trigger activation. - await vscode.workspace.fs.writeFile(pyUri, Buffer.from('x: int = 1\n')); - const doc = await vscode.workspace.openTextDocument(pyUri); - await vscode.window.showTextDocument(doc); - - // Poll until the extension is active. - const ext = vscode.extensions.getExtension('Nimblesite.basilisk'); - if (ext && !ext.isActive) { - await ext.activate(); - } - const deadline = Date.now() + WAIT_MS; - while (Date.now() < deadline) { - if (ext?.isActive) {break;} - await delay(POLL_INTERVAL_MS); - } - }); - - // ---------------------------------------------------------------- - // 1. Extension activates on Python file - // ---------------------------------------------------------------- - test('Extension activates on Python file', async () => { - const ext = vscode.extensions.getExtension(EXTENSION_ID); - assert.ok(ext, `Extension ${EXTENSION_ID} should be installed`); - - // The extension may already be active from suiteSetup, but - // if not, activate it explicitly. - if (!ext.isActive) { - await ext.activate(); - } - assert.strictEqual(ext.isActive, true, 'Extension should be active after opening a Python file'); - }); - - // ---------------------------------------------------------------- - // 2. Extension registers expected commands [VSIX-COMMANDS] - // ---------------------------------------------------------------- - test('Extension registers basilisk.restartServer command', () => { - const store = getStore(); - assert.ok(store, 'Store should be available after activation'); - assert.ok( - store.isClientCommandRegistered('basilisk.restartServer'), - 'basilisk.restartServer should be tracked in internal VSIX state' - ); - }); - - test('Extension registers basilisk.showOutput command', () => { - const store = getStore(); - assert.ok(store, 'Store should be available after activation'); - assert.ok( - store.isClientCommandRegistered('basilisk.showOutput'), - 'basilisk.showOutput should be tracked in internal VSIX state' - ); - }); - - test('LSP server advertises basilisk.organizeImports command', () => { - const store = getStore(); - assert.ok(store, 'Store should be available after activation'); - assert.ok( - store.isServerCommandAdvertised('basilisk.organizeImports'), - 'basilisk.organizeImports should be advertised by the LSP server' - ); - }); - - // ---------------------------------------------------------------- - // 3. Extension contributes configuration settings - // [VSIX-CONFIGURATION-SETTINGS], [VSIX-CONFIGURATION-SETTINGS-VS-CODE-ONLY] - // (useLsp/trace.server). executablePath/bundled resolution → [VSIX-BINARY- - // RESOLUTION] / [VSIX-BINARY-DISTRIBUTION]. - // ---------------------------------------------------------------- - test('Extension contributes basilisk.executablePath setting', () => { - const cfg = vscode.workspace.getConfiguration('basilisk'); - const inspected = cfg.inspect<string>('executablePath'); - assert.ok(inspected, 'basilisk.executablePath should be a contributed setting'); - assert.strictEqual( - inspected.defaultValue, - '', - 'Default executablePath should be empty so Shipwright uses the bundled binary' - ); - }); - - // Tests [VSIX-BINARY-RESOLUTION]: the default resolution cascade picks the - // bundled per-platform VSIX binary (Shipwright source = "bundled"). - test('Shipwright resolves basilisk from the bundled VSIX binary by default', () => { - const store = getStore(); - assert.ok(store, 'Store should be available after activation'); - const resolution = store.runtimeResolution.value; - assert.ok(resolution, 'Shipwright runtime resolution should be recorded'); - assert.strictEqual(resolution.componentId, 'basilisk'); - assert.strictEqual(resolution.source, 'bundled'); - const normalized = resolution.path.split(path.sep).join('/'); - assert.ok(normalized.includes('/bin/'), `Expected bundled bin path, got ${resolution.path}`); - assert.ok( - normalized.endsWith('/basilisk') || normalized.endsWith('/basilisk.exe'), - `Expected basilisk executable path, got ${resolution.path}` - ); - }); - - test('Extension contributes Shipwright binary directory setting', () => { - const cfg = vscode.workspace.getConfiguration('basilisk'); - const inspected = cfg.inspect<string>('binaries.path'); - assert.ok(inspected, 'basilisk.binaries.path should be a contributed setting'); - assert.strictEqual(inspected.defaultValue, ''); - }); - - test('Extension contributes Shipwright per-component basilisk setting', () => { - const cfg = vscode.workspace.getConfiguration('basilisk'); - const inspected = cfg.inspect<string>('binaries.basilisk'); - assert.ok(inspected, 'basilisk.binaries.basilisk should be a contributed setting'); - assert.strictEqual(inspected.defaultValue, ''); - }); - - test('Extension contributes basilisk.enabled setting', () => { - const cfg = vscode.workspace.getConfiguration('basilisk'); - const inspected = cfg.inspect<boolean>('enabled'); - assert.ok(inspected, 'basilisk.enabled should be a contributed setting'); - assert.strictEqual( - inspected.defaultValue, - true, - 'Default enabled should be true' - ); - }); - - test('Extension contributes basilisk.useLsp setting', () => { - const cfg = vscode.workspace.getConfiguration('basilisk'); - const inspected = cfg.inspect<boolean>('useLsp'); - assert.ok(inspected, 'basilisk.useLsp should be a contributed setting'); - assert.strictEqual( - inspected.defaultValue, - true, - 'Default useLsp should be true' - ); - }); - - test('Extension contributes basilisk.trace.server setting', () => { - const cfg = vscode.workspace.getConfiguration('basilisk'); - const inspected = cfg.inspect<string>('trace.server'); - assert.ok(inspected, 'basilisk.trace.server should be a contributed setting'); - assert.strictEqual( - inspected.defaultValue, - 'off', - 'Default trace.server should be "off"' - ); - }); - - test('Extension contributes basilisk.inlayHints.parameterNames setting', () => { - const cfg = vscode.workspace.getConfiguration('basilisk'); - const inspected = cfg.inspect<boolean>('inlayHints.parameterNames'); - assert.ok(inspected, 'basilisk.inlayHints.parameterNames should be a contributed setting'); - assert.strictEqual( - inspected.defaultValue, - true, - 'Default inlayHints.parameterNames should be true' - ); - }); - - test('Extension contributes basilisk.inlayHints.variableTypes setting', () => { - const cfg = vscode.workspace.getConfiguration('basilisk'); - const inspected = cfg.inspect<boolean>('inlayHints.variableTypes'); - assert.ok(inspected, 'basilisk.inlayHints.variableTypes should be a contributed setting'); - assert.strictEqual( - inspected.defaultValue, - true, - 'Default inlayHints.variableTypes should be true' - ); - }); - - // The formatter is the Ruff engine embedded in the Basilisk binary — there is - // no external `ruff` binary, so there is no `ruff.executablePath`. The only - // formatter setting is the engine selector. [LSPFMT-CONFIG] - test('Extension contributes basilisk.formatter setting defaulting to "ruff"', () => { - const cfg = vscode.workspace.getConfiguration('basilisk'); - const inspected = cfg.inspect<string>('formatter'); - assert.ok(inspected, 'basilisk.formatter should be a contributed setting'); - assert.strictEqual( - inspected.defaultValue, - 'ruff', - 'Default formatter should be the embedded Ruff engine ("ruff")' - ); - }); - - test('Extension does NOT contribute any basilisk.ruff.* setting', () => { - // The external ruff binary is jettisoned; a ruff path/toggle would be a - // dead, misleading setting. [LSPFMT-DECISION] - const cfg = vscode.workspace.getConfiguration('basilisk'); - assert.strictEqual( - cfg.inspect('ruff.enabled')?.defaultValue, - undefined, - 'basilisk.ruff.enabled must not exist' - ); - assert.strictEqual( - cfg.inspect('ruff.executablePath')?.defaultValue, - undefined, - 'basilisk.ruff.executablePath must not exist' - ); - }); - - // ---------------------------------------------------------------- - // 4. Status bar item is created after activation [VSIX-STATUS-BAR] - // (also exercises basilisk.showOutput → [VSIX-OUTPUT-CHANNELS]) - // ---------------------------------------------------------------- - test('Status bar item is created after activation', async () => { - const ext = vscode.extensions.getExtension(EXTENSION_ID); - assert.ok(ext, `Extension ${EXTENSION_ID} should be installed`); - - if (!ext.isActive) { - await ext.activate(); - } - - // The extension creates a status bar item that is shown on activation. - // We verify the extension exports are available and the extension is active, - // which implies the status bar was created (since it's created in activate()). - // Direct status bar item inspection is not exposed by the VS Code API, - // but we can verify that the extension activated without error and that - // the showOutput command (linked to the status bar) works. - assert.strictEqual(ext.isActive, true, 'Extension must be active for status bar to exist'); - - // Execute the showOutput command (which is bound to the status bar item). - // If the status bar and output channel were not created, this would throw. - await vscode.commands.executeCommand('basilisk.showOutput'); - }); - - // ---------------------------------------------------------------- - // 5. Extension package metadata is correct - // ---------------------------------------------------------------- - test('Extension has correct display name', () => { - const ext = vscode.extensions.getExtension(EXTENSION_ID); - assert.ok(ext, `Extension ${EXTENSION_ID} should be installed`); - assert.strictEqual(manifestDisplayName(), 'Basilisk'); - }); - - test('Extension activates on Python language', () => { - const ext = vscode.extensions.getExtension(EXTENSION_ID); - assert.ok(ext, `Extension ${EXTENSION_ID} should be installed`); - const activationEvents: string[] = manifestActivationEvents(); - assert.ok( - activationEvents.includes('onLanguage:python'), - 'Extension should activate on Python language' - ); - }); - - // ---------------------------------------------------------------- - // Cleanup - // ---------------------------------------------------------------- - suiteTeardown(async () => { - const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? __dirname; - const pyUri = vscode.Uri.file(path.join(workspaceRoot, '__basilisk_test__.py')); - try { - await vscode.workspace.fs.delete(pyUri); - } catch { - // File may not exist — ignore. - } - }); -}); diff --git a/vscode-extension/src/test/suite/index.ts b/vscode-extension/src/test/suite/index.ts deleted file mode 100644 index 4e3aa94ad..000000000 --- a/vscode-extension/src/test/suite/index.ts +++ /dev/null @@ -1,38 +0,0 @@ -// Tests for [VSIX]. See docs/specs/VSIX-SPEC.md#VSIX -import * as path from 'path'; -import Mocha from 'mocha'; -import { glob } from 'glob'; -import { - SUITE_SETUP_TIMEOUT_MS, - waitForLspReady, -} from './test-helpers'; - -export async function run(): Promise<void> { - const timeout = parseInt(process.env.MOCHA_TIMEOUT ?? '60000', 10); - const mocha = new Mocha({ - ui: 'tdd', - color: true, - timeout, - // Optional focus filter for invoking the runner directly to debug a - // single test/suite (see CLAUDE.md). Unset in CI, where the full suite runs. - ...(process.env.BSK_TEST_GREP !== undefined ? { grep: process.env.BSK_TEST_GREP } : {}), - rootHooks: { - beforeAll(this: Mocha.Context, done: Mocha.Done) { - this.timeout(SUITE_SETUP_TIMEOUT_MS); - waitForLspReady().then(() => done(), done); - }, - }, - }); - const testsRoot = path.resolve(__dirname); - const files = await glob('**/**.test.js', { cwd: testsRoot }); - files.forEach(f => mocha.addFile(path.resolve(testsRoot, f))); - return new Promise<void>((resolve, reject) => { - mocha.run(failures => { - if (failures > 0) { - reject(new Error(`${failures} tests failed.`)); - } else { - resolve(); - } - }); - }); -} diff --git a/vscode-extension/src/test/suite/info-panel-resolution.test.ts b/vscode-extension/src/test/suite/info-panel-resolution.test.ts deleted file mode 100644 index fe0725f0c..000000000 --- a/vscode-extension/src/test/suite/info-panel-resolution.test.ts +++ /dev/null @@ -1,129 +0,0 @@ -// Tests for [EXTACT-INFO-SERVER-INFO]. See docs/specs/EXTENSION-ACTIVITY-PANEL-SPEC.md#EXTACT-INFO-SERVER-INFO -/** - * Server Info resolution E2E tests — regression guard for issue #153. - * - * The test workspace leaves `basilisk.python`, `basilisk.uv.executablePath`, - * and `basilisk.executablePath` unset (their `""` defaults), i.e. auto-detect - * is in effect for all three. The Server Info rows must then surface what the - * server ACTUALLY resolved — interpreter/binary version + path — never the - * bare `auto-detect` placeholder, and the Binary row must never be blank. - * Resolution data is LSP-authoritative: it comes from the live client's - * initialize response, so the provider here is built on the extension's REAL - * store (unlike info-panel.test.ts, which uses a fresh store to test layout). - */ - -import * as assert from "assert"; -import * as path from "path"; -import type * as vscode from "vscode"; -import { InfoPanelProvider } from "../../info-panel"; -import { getStore } from "../../extension"; -import { SUITE_SETUP_TIMEOUT_MS, waitForLspReady } from "./test-helpers"; - -/** Extract a TreeItem's label as a plain string. */ -function labelOf(item: vscode.TreeItem): string { - const { label } = item; - if (typeof label === "string") { return label; } - return label?.label ?? ""; -} - -/** Extract a TreeItem's description as a plain string. */ -function descriptionOf(item: vscode.TreeItem): string { - const { description } = item; - return typeof description === "string" ? description : ""; -} - -const ASCII_DIGITS = ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"] as const; - -/** Whether the text contains at least one ASCII digit (a version number does). */ -function containsDigit(text: string): boolean { - return ASCII_DIGITS.some((digit) => text.includes(digit)); -} - -/** - * Assert a row description has the resolved shape the issue requires when - * auto-detect is in effect: `auto-detect → <version> (<path>)`, or the - * explicit failure form `auto-detect → none found`. The bare placeholder - * literal `auto-detect` — the shipped bug — fails this. - */ -function assertAutoDetectResolved(rowLabel: string, desc: string): void { - assert.notStrictEqual( - desc, - "auto-detect", - `${rowLabel} row must not render the bare "auto-detect" placeholder — it must show what auto-detect resolved (issue #153)`, - ); - assert.ok( - desc.startsWith("auto-detect → "), - `${rowLabel} row must mark that auto-detect is in effect and show its outcome, got "${desc}"`, - ); - if (desc === "auto-detect → none found") { - return; // Explicit failure state is a valid, honest outcome. - } - assert.ok( - desc.includes(" (") && desc.endsWith(")") && containsDigit(desc), - `${rowLabel} row must show the resolved "<version> (<path>)" or "none found", got "${desc}"`, - ); -} - -suite("Server Info resolved environment (issue #153)", () => { - let provider: InfoPanelProvider; - - suiteSetup(async function () { - this.timeout(SUITE_SETUP_TIMEOUT_MS); - await waitForLspReady(); - }); - - setup(() => { - const store = getStore(); - assert.ok(store, "extension store should exist once the LSP is ready"); - provider = new InfoPanelProvider(store); - }); - - teardown(() => { - provider.dispose(); - }); - - /** Flat server-information row keyed by its label. */ - function serverInfoRow(label: string): vscode.TreeItem | undefined { - return provider.getChildren().find((row) => labelOf(row) === label); - } - - // Defect 1 of issue #153: the Python row rendered the raw setting (default - // "" → literal "auto-detect") and never the resolved interpreter. - test("Python row shows the resolved interpreter (version + path), not the literal auto-detect", () => { - const row = serverInfoRow("Python"); - assert.ok(row, "Python row should exist"); - assertAutoDetectResolved("Python", descriptionOf(row)); - }); - - // Defect 2 of issue #153: the uv row had the same placeholder problem — - // no resolved uv binary, no uv version. - test("uv row shows the resolved uv binary (version + path), not the literal auto-detect", () => { - const row = serverInfoRow("uv"); - assert.ok(row, "uv row should exist"); - assertAutoDetectResolved("uv", descriptionOf(row)); - }); - - // Defect 3 of issue #153: basilisk.executablePath defaults to "" (not - // undefined), so the `?? "basilisk"` fallback never fired and the Binary - // row rendered blank. With the server running it must name the actually - // running binary; while the server is down the row is absent — never blank. - test("Binary row is never blank — it names the running server binary (version + absolute path)", () => { - const row = serverInfoRow("Binary"); - assert.ok( - row, - "Binary row should exist while the server is running (absent is only valid with no live server)", - ); - const desc = descriptionOf(row); - assert.ok(desc.trim() !== "", "Binary row must never render blank (issue #153)"); - assert.ok( - desc.includes(" (") && desc.endsWith(")") && containsDigit(desc), - `Binary row must show the running binary as "<version> (<path>)", got "${desc}"`, - ); - const openParen = desc.lastIndexOf(" ("); - const binaryPath = desc.slice(openParen + 2, -1); - assert.ok( - path.isAbsolute(binaryPath), - `Binary row must name the resolved ABSOLUTE path of the running binary, got "${binaryPath}"`, - ); - }); -}); diff --git a/vscode-extension/src/test/suite/info-panel.test.ts b/vscode-extension/src/test/suite/info-panel.test.ts deleted file mode 100644 index 9661bd94e..000000000 --- a/vscode-extension/src/test/suite/info-panel.test.ts +++ /dev/null @@ -1,527 +0,0 @@ -// Tests for [EXTACT-INFO]. See docs/specs/EXTENSION-ACTIVITY-PANEL-SPEC.md#EXTACT-INFO -/** - * Info Panel contents E2E tests — the slimmed panel of issue #103. - * - * The panel is exactly: the Diagnostics toggle followed by flat, read-only server - * details. There is NO Quick Actions section: the high-value - * actions are Modules-toolbar buttons gated on the server running (see - * activity-panel.test.ts), the status-bar click opens the basilisk.statusMenu - * quick-pick (Open Configuration / Show Output / Restart), and everything stays - * in the command palette. - * - * This structure is itself the regression guard for issue #103 defect 1 - * ("command not found" quick actions): with no action rows in the panel at - * all, a dead shown-but-unregistered action row is structurally impossible — - * the only commands any row carries are the always-registered - * basilisk.toggleFeature toggles. - * - * Feature toggles: only toggles whose setting has a real, observable effect - * may appear (a toggle that writes a setting nothing reads is a lie to the - * user). If someone re-adds a no-op toggle (e.g. "Ruff Integration", whose - * setting the LSP server silently drops), the toggle-set test fails. See - * EXTENSION-ACTIVITY-PANEL-PLAN.md#EXTACT-PLAN-FEATURE-TOGGLES. - */ - -import * as assert from "assert"; -import * as vscode from "vscode"; -import { InfoPanelProvider, featureToggleTarget } from "../../info-panel"; -import { createStore } from "../../store"; -import { SUITE_SETUP_TIMEOUT_MS, seedSignal, waitForLspReady } from "./test-helpers"; -import type { LanguageClient } from "vscode-languageclient/node"; -import { - manifestMenu -} from "./extension-manifest"; - -/** Toggles that ship — each has a namesake, observable effect. */ -const KEPT_FEATURE_LABELS = ["Diagnostics"] as const; - -/** Toggles removed because their setting was a no-op (server dropped it). */ -const REMOVED_FEATURE_LABELS = [ - // Removed per GitHub #190: no server code reads basilisk.uv.enabled, so the - // toggle never disabled uv integration — a no-op affordance. - "uv Integration", - "Inlay Hints (Params)", - "Inlay Hints (Types)", - "Ruff Integration", - "Test Explorer", - "Debugger", - "AI Typing", -] as const; - -/** Extract a TreeItem's label as a plain string. */ -function labelOf(item: vscode.TreeItem): string { - const { label } = item; - if (typeof label === "string") { return label; } - return label?.label ?? ""; -} - -/** Extract a TreeItem's tooltip as a plain string (handles MarkdownString). */ -function tooltipOf(item: vscode.TreeItem): string { - const { tooltip } = item; - if (typeof tooltip === "string") { return tooltip; } - if (tooltip instanceof vscode.MarkdownString) { return tooltip.value; } - return ""; -} - -function verifyTypeshedInfoRows(): void { - const store = createStore(); - seedSignal(store.typeshedStatuses, new Map([[ - "file:///workspace", - { - lifecycle: { kind: "Ready" }, activeSource: { kind: "Bundled" }, - noSourceReason: undefined, - commitIdentity: "83c2518a9e6abbda0c44592c3483de459198f887", - licenseStatus: { kind: "Approved" }, - warnings: [{ - code: "typeshed_source_unpinned", message: "Pin a commit to make this reproducible", - severity: { kind: "Advisory" }, - }], - }, - ]])); - const typeshedProvider = new InfoPanelProvider(store); - try { - const rows = typeshedProvider.getChildren().filter((row) => row.contextValue === "info"); - const byLabel = new Map(rows.map((row) => [labelOf(row), row])); - const source = byLabel.get("Typeshed Source"); - 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("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 typeshed_source_unpinned")?.description, - "Pin a commit to make this reproducible", - ); - } finally { - typeshedProvider.dispose(); - } -} - -function verifyDownloadingTypeshedSpinner(): void { - const store = createStore(); - seedSignal(store.typeshedStatuses, new Map([[ - "file:///workspace", - { - lifecycle: { kind: "Downloading" }, noSourceReason: undefined, activeSource: undefined, - commitIdentity: undefined, - licenseStatus: { kind: "Unavailable" }, - warnings: [], - }, - ]])); - const typeshedProvider = new InfoPanelProvider(store); - try { - const state = typeshedProvider - .getChildren() - .find((row) => labelOf(row) === "Typeshed State"); - assert.ok(state?.iconPath instanceof vscode.ThemeIcon); - assert.strictEqual(state.iconPath.id, "loading~spin"); - } finally { - typeshedProvider.dispose(); - } -} - -/** A store whose single root reports the typeshed_source_unpinned typeshed warning. */ -function storeWithUnpinnedWarning(): ReturnType<typeof createStore> { - const store = createStore(); - seedSignal(store.typeshedStatuses, new Map([[ - "file:///workspace", - { - lifecycle: { kind: "Ready" }, activeSource: { kind: "Bundled" }, - noSourceReason: undefined, - commitIdentity: "6fb14c98ee340a07eea807a4c804e20a849eb92b", - licenseStatus: { kind: "Approved" }, - warnings: [{ - code: "typeshed_source_unpinned", message: "Pin a commit to make this reproducible", - severity: { kind: "Advisory" }, - }], - }, - ]])); - return store; -} - -/** - * Drive the store into the EXACT state in which - * configuration-editor-registration.ts registers the open command: a running - * server that advertises the editor capability. The panel gates the warning - * row's command on this same pair, so anything less must leave the row inert. - */ -function advertiseConfigurationEditor( - store: ReturnType<typeof createStore>, - options: { readonly running: boolean }, -): void { - // Only `initializeResult` is read here; the rest of `LanguageClient` is not - // touched by the code under test, so the double states exactly that much. - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- LanguageClient double; only initializeResult is read - const advertising = { - initializeResult: { - capabilities: { experimental: { basilisk: { configurationEditor: true } } }, - }, - } as unknown as LanguageClient; - seedSignal(store.client, advertising); - seedSignal(store.lspState, options.running ? "running" : "starting"); -} - -/** The single typeshed_source_unpinned warning row from a flat-root panel. */ -function unpinnedRow(store: ReturnType<typeof createStore>): vscode.TreeItem { - const typeshedProvider = new InfoPanelProvider(store); - try { - const row = typeshedProvider - .getChildren() - .find((candidate) => labelOf(candidate) === "Typeshed typeshed_source_unpinned"); - assert.ok(row, "the typeshed_source_unpinned warning row should exist"); - return row; - } finally { - typeshedProvider.dispose(); - } -} - -// Tests [LSPCFGED-TYPESHED-SERVICE-INFO] navigation + [EXTACT-INFO-AFFORDANCE]: -// the typeshed_source_unpinned row's own message tells the user to "Pin current", and Pin -// current lives in the configuration editor — so the row must navigate there -// when the editor is genuinely reachable (info-panel.ts typeshedWarningItem). -function verifyUnpinnedWarningRowOpensConfigurationEditor(): void { - const store = storeWithUnpinnedWarning(); - advertiseConfigurationEditor(store, { running: true }); - const unpinned = unpinnedRow(store); - assert.strictEqual( - unpinned.command?.command, - "basilisk.openConfigurationEditor", - "the typeshed_source_unpinned row advertises Pin current, so clicking it must open the configuration editor where Pin current lives", - ); - assert.strictEqual( - unpinned.contextValue, - "typeshed-warning", - "a navigating warning row is marked typeshed-warning so it never gets the feature-toggle inline button", - ); - const tip = tooltipOf(unpinned).trim(); - assert.ok( - tip.length > 0, - "an actionable row must carry an imperative tooltip describing its effect", - ); -} - -// Regression guard for issue #103 defect 1: basilisk.openConfigurationEditor -// is capability-gated (configuration-editor-registration.ts), so a warning row -// must NOT carry it while no server advertises the editor — a shown-but-dead -// command raises "command not found". -function verifyUnpinnedWarningRowStaysInertWithoutEditorCapability(): void { - const unpinned = unpinnedRow(storeWithUnpinnedWarning()); - assert.strictEqual( - unpinned.command, - undefined, - "without the configuration-editor capability the row must not carry a dead command", - ); - assert.strictEqual( - unpinned.contextValue, - "info", - "an inert warning row stays an ordinary read-only info row", - ); -} - -// The command is registered on `running` AND the capability — not the -// capability alone. A client that has already returned its initializeResult -// while the server is still starting advertises the capability with no command -// registered yet, so the row must stay inert. Guards the gate asymmetry that -// would otherwise be load-bearing but unasserted. -function verifyUnpinnedWarningRowStaysInertWhileServerIsNotRunning(): void { - const store = storeWithUnpinnedWarning(); - advertiseConfigurationEditor(store, { running: false }); - const unpinned = unpinnedRow(store); - assert.strictEqual( - unpinned.command, - undefined, - "the capability alone is not enough — the open command is only registered while the server runs", - ); -} - -suite("Basilisk Info Panel Contents (slimmed, issue #103)", () => { - let provider: InfoPanelProvider; - - // The write-through test drives the real basilisk.toggleFeature command, - // which exists once the extension has initialized — await that here so this - // file also passes standalone (single-file debugging), not only when an - // earlier suite already initialized the extension. - suiteSetup(async function () { - this.timeout(SUITE_SETUP_TIMEOUT_MS); - await waitForLspReady(); - }); - - setup(() => { - provider = new InfoPanelProvider(createStore()); - }); - - teardown(() => { - provider.dispose(); - }); - - /** - * Flat read-only server-information rows: every root row that is not one of - * the shipped feature toggles. Selected by LABEL, never by `contextValue` — - * selecting on the property under test would make the `contextValue` - * assertions below vacuous (a row that lost its `info` marker would silently - * drop out of the set instead of failing). - */ - function serverInfoRows(): vscode.TreeItem[] { - const toggles = new Set<string>(KEPT_FEATURE_LABELS); - return provider.getChildren().filter((row) => !toggles.has(labelOf(row))); - } - - // Tests [EXTACT-INFO-STRUCTURE] / [EXTACT-INFO-QUICK-ACTIONS] (no Quick Actions section). - test("root is the Diagnostics toggle followed by flat read-only details", () => { - const labels = provider.getChildren().map(labelOf); - assert.deepStrictEqual(labels.slice(0, KEPT_FEATURE_LABELS.length), [...KEPT_FEATURE_LABELS]); - assert.ok(labels.includes("Analysis Mode"), "server details should render at the root"); - assert.ok(!labels.includes("Server Info"), "read-only details do not need a collapsible parent"); - assert.ok(!labels.includes("Feature Status"), "the Feature Status header was removed (one toggle doesn't justify it)"); - assert.ok(!labels.includes("Quick Actions"), "the Quick Actions section was removed (actions live on the Modules toolbar / status bar / palette)"); - }); - - // Tests [EXTACT-INFO-ACTION-WIRING]: no shown-but-dead actions in the panel. - test("no row in the entire panel carries a command outside the allowed set", () => { - // Regression for issue #103 defect 1: a row that looks clickable but has - // no live handler raises "command not found". Exactly two commands may - // appear in this panel, and each is guaranteed to be registered whenever - // it is attached: - // - basilisk.toggleFeature — registerInfoPanel registers it itself, so - // it is always live. - // - basilisk.openConfigurationEditor — capability-gated. It is attached - // ONLY to typeshed warning rows and ONLY when the same predicate that - // registers it holds (running server + advertised capability), so it - // can never be shown dead. See [LSPCFGED-TYPESHED-SERVICE-INFO]. - // Any OTHER command, on any row, is the defect this test exists to catch. - const allRows = provider - .getChildren() - .flatMap((row) => [row, ...provider.getChildren(row)]); - assert.ok(allRows.length > 0, "panel should render rows"); - for (const row of allRows) { - const commandId = row.command?.command; - if (commandId === undefined) { continue; } - if (commandId === "basilisk.openConfigurationEditor") { - assert.strictEqual( - row.contextValue, - "typeshed-warning", - `"${labelOf(row)}" carries the configuration-editor command but is not a typeshed warning row — only warning rows may navigate`, - ); - continue; - } - assert.strictEqual( - commandId, - "basilisk.toggleFeature", - `"${labelOf(row)}" carries "${commandId}" — only the always-registered toggle and the capability-gated configuration-editor command are allowed in this panel`, - ); - } - }); - - // Tests [EXTACT-INFO-FEATURE-STATUS]: only effect-bearing toggles ship. - test("every no-op toggle stays hidden", () => { - const labels = provider.getChildren().map(labelOf); - for (const removed of REMOVED_FEATURE_LABELS) { - assert.ok( - !labels.includes(removed), - `"${removed}" must not appear — its setting is ignored, so the toggle does nothing`, - ); - } - }); - - // Tests [EXTACT-INFO-SERVER-INFO]: no live server-state row. - test("Server Info has no live Server state row (status bar owns it)", () => { - const labels = serverInfoRows().map(labelOf); - assert.ok( - !labels.includes("Server"), - "the Server state row duplicates the status bar and was dropped (issue #103)", - ); - }); - - test("Server Info renders the root-keyed Typeshed source and trust state", verifyTypeshedInfoRows); - - test("Server Info shows a downloading Typeshed spinner", verifyDownloadingTypeshedSpinner); - - test( - "the typeshed_source_unpinned warning row opens the configuration editor where Pin current lives", - verifyUnpinnedWarningRowOpensConfigurationEditor, - ); - - test( - "the typeshed_source_unpinned warning row stays inert while no server advertises the configuration editor", - verifyUnpinnedWarningRowStaysInertWithoutEditorCapability, - ); - - test( - "the typeshed_source_unpinned warning row stays inert while the server is not yet running", - verifyUnpinnedWarningRowStaysInertWhileServerIsNotRunning, - ); - - // Tests [EXTACT-INFO-SERVER-INFO]: one uv row, sub-settings in the tooltip. - test("uv sub-settings are folded into the uv row tooltip, not separate rows", () => { - const rows = serverInfoRows(); - const labels = rows.map(labelOf); - assert.ok(!labels.includes("uv Auto-Sync"), "uv Auto-Sync must not be its own row"); - assert.ok(!labels.includes("Stub Suggestions"), "Stub Suggestions must not be its own row"); - - const uvRow = rows.find((row) => labelOf(row) === "uv"); - assert.ok(uvRow, "the compact uv row should exist"); - const tip = tooltipOf(uvRow); - assert.ok(tip.includes("Auto-Sync"), `uv tooltip must carry Auto-Sync, got: "${tip}"`); - assert.ok(tip.includes("Executable"), `uv tooltip must carry Executable, got: "${tip}"`); - // Stub suggestions are governed by rule severity (BSK-0152), not a uv - // setting, so they are neither a row nor a tooltip line. - assert.ok(!tip.includes("Stub Suggestions"), `uv tooltip must not carry the removed Stub Suggestions setting, got: "${tip}"`); - }); - - // Defect 2 of issue #103: basilisk.toggleFeature wrote to - // ConfigurationTarget.Workspace unconditionally, which is invalid (and - // rejects) when no workspace folder is open — and the info panel is always - // visible, so that state is reachable. The target now derives from the live - // folder count; the helper is pure in the count because the e2e host always - // launches with a folder, making the no-folder branch unreachable end-to-end. - // Tests [EXTACT-INFO-FEATURE-STATUS] write-target rule (Workspace vs Global). - test("featureToggleTarget picks Workspace with a folder and Global without (defect 2)", () => { - assert.strictEqual( - featureToggleTarget(1), - vscode.ConfigurationTarget.Workspace, - "with a workspace folder, toggles write workspace settings", - ); - assert.strictEqual( - featureToggleTarget(0), - vscode.ConfigurationTarget.Global, - "with no folder open, ConfigurationTarget.Workspace is invalid — must fall back to Global", - ); - }); - - // Tests [EXTACT-INFO-FEATURE-STATUS]: a toggle has an observable, namesake effect. - test("toggleFeature writes through and the panel reflects it", async () => { - // End-to-end: flip the Diagnostics toggle off via the real command (this host has a - // folder, so it writes the Workspace target) and assert the toggle row - // re-renders as Disabled. (The deeper effect — diagnostics actually clear — - // is proven end-to-end in type-checking-toggle.test.ts.) - const cfg = vscode.workspace.getConfiguration(); - try { - await vscode.commands.executeCommand("basilisk.toggleFeature", "basilisk.enabled", false); - const toggle = provider.getChildren().find((row) => labelOf(row) === "Diagnostics"); - assert.ok(toggle, "Diagnostics toggle should exist"); - assert.strictEqual(toggle.description, "Disabled", "toggle row must reflect the written setting"); - } finally { - await cfg.update("basilisk.enabled", undefined, vscode.ConfigurationTarget.Workspace); - } - }); -}); - -// ── Affordance partition [EXTACT-INFO-AFFORDANCE] ─────────────────────────── -// -// Regression tests for issue #65: actionable rows must be visually -// unmistakable from read-only rows. In the slimmed panel the actionable class -// is exactly the Diagnostics toggle; every server-detail row is read-only. -// -// Spec: docs/specs/EXTENSION-ACTIVITY-PANEL-SPEC.md#EXTACT-INFO-AFFORDANCE - -suite("Basilisk Info Panel Affordance [EXTACT-INFO-AFFORDANCE]", () => { - let provider: InfoPanelProvider; - - setup(() => { - provider = new InfoPanelProvider(createStore()); - }); - - teardown(() => { - provider.dispose(); - }); - - // Both partitions are selected by LABEL, never by `contextValue`. The whole - // point of this suite is that the two classes are marked correctly, so - // selecting on the marker under test would make every assertion below - // self-fulfilling: a toggle that regressed to `contextValue: "info"` would - // vanish from `toggleRows()` rather than fail. Label selection keeps the - // partition independent of the property being asserted. - - /** Top-level feature toggle rows. */ - function toggleRows(): vscode.TreeItem[] { - const toggles = new Set<string>(KEPT_FEATURE_LABELS); - return provider.getChildren().filter((row) => toggles.has(labelOf(row))); - } - - /** Flat read-only server-information rows. */ - function readOnlyRows(): vscode.TreeItem[] { - const toggles = new Set<string>(KEPT_FEATURE_LABELS); - return provider.getChildren().filter((row) => !toggles.has(labelOf(row))); - } - - test("every feature toggle carries a command and an imperative tooltip", () => { - const rows = toggleRows(); - assert.ok(rows.length > 0, "panel should render feature toggles"); - for (const row of rows) { - const label = labelOf(row); - assert.ok( - row.command !== undefined && row.command.command !== "", - `"${label}" must carry a command (actionable rows are clickable)`, - ); - const tip = tooltipOf(row).trim(); - assert.ok( - tip.length > 0, - `"${label}" must carry an imperative tooltip describing its effect`, - ); - } - }); - - test("every read-only server detail carries no command and contextValue 'info'", () => { - const rows = readOnlyRows(); - assert.ok(rows.length > 0, "Server Info should have rows"); - for (const row of rows) { - const label = labelOf(row); - // The one documented exception ([LSPCFGED-TYPESHED-SERVICE-INFO]): a - // typeshed warning row navigates to the Configuration Editor where its - // named fix lives. It is still read-only — it mutates nothing — so it - // gets its own contextValue and therefore still no inline button. - if (row.contextValue === "typeshed-warning") { - assert.strictEqual( - row.command?.command, - "basilisk.openConfigurationEditor", - `"${label}" is marked typeshed-warning, so it must carry exactly the navigation-only editor command`, - ); - continue; - } - assert.strictEqual( - row.command, - undefined, - `"${label}" is read-only and must not carry a command`, - ); - assert.strictEqual( - row.contextValue, - "info", - `"${label}" must have contextValue "info" so it gets no inline button`, - ); - } - }); - - test("no row is both actionable and read-only", () => { - for (const row of toggleRows()) { - assert.notStrictEqual(row.contextValue, "info", `"${labelOf(row)}" must not be read-only`); - } - for (const row of readOnlyRows()) { - assert.notStrictEqual(row.contextValue, "feature", `"${labelOf(row)}" must not be actionable`); - } - }); - - test("package.json contributes an inline action button for feature rows only", () => { - const inlineForInfo = manifestMenu("view/item/context").filter( - (entry) => entry.group === "inline" && entry.when.includes("basilisk.info"), - ); - assert.ok( - inlineForInfo.length > 0, - "feature toggle rows must contribute an inline button (literal button affordance)", - ); - for (const entry of inlineForInfo) { - assert.ok( - entry.when.includes("feature"), - `inline button '${entry.command}' must target feature rows, got when: ${entry.when}`, - ); - assert.ok( - !/viewItem\s*=~?=?\s*.*action/.test(entry.when), - `inline button '${entry.command}' must not target the removed action rows, got when: ${entry.when}`, - ); - assert.ok( - !/viewItem\s*==\s*info/.test(entry.when), - `inline button '${entry.command}' must not target read-only info rows, got when: ${entry.when}`, - ); - } - }); -}); diff --git a/vscode-extension/src/test/suite/lsp-analysis-mode.test.ts b/vscode-extension/src/test/suite/lsp-analysis-mode.test.ts deleted file mode 100644 index b4daafb58..000000000 --- a/vscode-extension/src/test/suite/lsp-analysis-mode.test.ts +++ /dev/null @@ -1,460 +0,0 @@ -// Tests for [VSIX]. See docs/specs/VSIX-SPEC.md#VSIX -/** - * Analysis Mode Tests for the Basilisk VS Code Extension. - * - * These tests verify that the `basilisk.analysisMode` setting is correctly - * wired: configuration schema, extension reads it, and the LSP server - * respects it (wholeModule scan vs openFilesOnly). - * - * Extracted from lsp-integration.test.ts to keep files under the 500-line limit. - */ - -import { delay } from '../../timeouts'; -import * as assert from 'assert'; -import * as vscode from 'vscode'; -import * as fs from 'fs'; -import * as path from 'path'; -import * as os from 'os'; -import { - closeAllEditors, - DIAGNOSTIC_TIMEOUT_MS, - EXTENSION_ID, - filterBasiliskDiagnostics, - NO_DIAGNOSTIC_WAIT_MS, - openPythonFile, - removeTestDir, - SERVER_START_WAIT_MS, - SUITE_SETUP_TIMEOUT_MS, - waitForDiagnostics, - waitForDiagnosticsCleared, -} from './test-helpers'; - -/** Extra buffer (ms) added to test timeouts beyond core wait. */ -const TIMEOUT_BUFFER_MS = 5_000; - -/** Large buffer (ms) for tests with multiple diagnostic waits or startup delays. */ -const LARGE_TIMEOUT_BUFFER_MS = 15_000; - -/** Fodder files written into the workspace to slow the wholeModule scan enough - * that closing an editor reliably lands mid-scan (GitHub #264). */ -const FODDER_FILE_COUNT = 400; - -/** Wait (ms) after flipping to wholeModule before closing the editor — long - * enough for the didChangeConfiguration to reach the server and the scan to - * begin computing, short enough that the scan is still running. */ -const SCAN_KICKOFF_WAIT_MS = 1_000; - -/** Budget (ms) for the fodder marker file to receive its scan diagnostics — - * i.e. for the slowed-down workspace scan to complete and publish. */ -const SCAN_COMPLETE_TIMEOUT_MS = 45_000; - -/** Window (ms) after the scan completes during which stale diagnostics for the - * closed file must NOT reappear. The buggy republish trails the marker - * publish by milliseconds, so this is generous. */ -const STALE_REPUBLISH_GRACE_MS = 3_000; - -/** Fodder module: fully annotated, diagnostic-free, but real enough that the - * scan pays parse+check cost for each file. */ -function fodderModule(moduleIndex: number): string { - const lines: string[] = ['"""Scan fodder for the #264 stale-republish test."""', '']; - for (let functionIndex = 0; functionIndex < 12; functionIndex += 1) { - lines.push( - `def fodder_${moduleIndex}_${functionIndex}(value: int) -> int:`, - ` total: int = value + ${functionIndex}`, - ' return total', - '' - ); - } - return lines.join('\n'); -} - -/** Write FODDER_FILE_COUNT clean modules plus one erroring marker module into - * `fodderDir`. The marker's scan diagnostics signal "publish loop reached the - * scan portion" — open-file refresh entries publish after it. */ -function writeScanFodder(fodderDir: string): vscode.Uri { - fs.mkdirSync(fodderDir, { recursive: true }); - for (let moduleIndex = 0; moduleIndex < FODDER_FILE_COUNT; moduleIndex += 1) { - const name = `fodder_${String(moduleIndex).padStart(3, '0')}.py`; - fs.writeFileSync(path.join(fodderDir, name), fodderModule(moduleIndex), 'utf8'); - } - const markerPath = path.join(fodderDir, 'zz_marker_264.py'); - fs.writeFileSync(markerPath, 'def marker(name):\n return f"Hello, {name}!"\n', 'utf8'); - return vscode.Uri.file(markerPath); -} - -/** Poll for `windowMs` asserting the URI's diagnostics stay at zero — catches - * a stale scan republish arriving after didClose cleared them (#264). */ -async function assertDiagnosticsStayCleared(uri: vscode.Uri, windowMs: number): Promise<void> { - const deadline = Date.now() + windowMs; - while (Date.now() < deadline) { - const diags = vscode.languages.getDiagnostics(uri); - assert.strictEqual( - diags.length, - 0, - `stale diagnostics republished for closed file ${uri.fsPath} after ` + - `didClose cleared them (GitHub #264): ${diags.map((d) => d.message).join('; ')}` - ); - await delay(100); - } -} - -// Tests the editor-setting source of [ANALYSIS-CONFIG-SRC] — the -// `basilisk.analysisMode` workspace setting: default `wholeModule`, all three -// enum values accepted, and the server respecting the selected scope. -// eslint-disable-next-line max-lines-per-function -suite('Analysis Mode Tests', () => { - let tmpDir: string; - - suiteSetup(function () { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'bsk-mode-test-')); - }); - - suiteTeardown(async () => { - await closeAllEditors(); - if (tmpDir !== undefined && tmpDir !== '' && fs.existsSync(tmpDir)) { - removeTestDir(tmpDir); - } - }); - - teardown(async () => { - await closeAllEditors(); - }); - - // ------------------------------------------------------- - // Configuration schema tests — verify the setting exists - // ------------------------------------------------------- - - test('basilisk.analysisMode setting has correct default', () => { - const cfg = vscode.workspace.getConfiguration('basilisk'); - const mode = cfg.get<string>('analysisMode'); - // Default is wholeModule. - assert.strictEqual( - mode, - 'wholeModule', - `Expected default analysisMode to be 'wholeModule', got '${mode}'` - ); - }); - - test('basilisk.analysisMode accepts openFilesOnly', async () => { - const cfg = vscode.workspace.getConfiguration('basilisk'); - const original = cfg.get<string>('analysisMode'); - try { - await cfg.update('analysisMode', 'openFilesOnly', vscode.ConfigurationTarget.Workspace); - const mode = vscode.workspace.getConfiguration('basilisk').get<string>('analysisMode'); - assert.strictEqual(mode, 'openFilesOnly'); - } finally { - await cfg.update('analysisMode', original, vscode.ConfigurationTarget.Workspace); - } - }); - - test('basilisk.analysisMode accepts crossModule', async () => { - const cfg = vscode.workspace.getConfiguration('basilisk'); - const original = cfg.get<string>('analysisMode'); - try { - await cfg.update('analysisMode', 'crossModule', vscode.ConfigurationTarget.Workspace); - const mode = vscode.workspace.getConfiguration('basilisk').get<string>('analysisMode'); - assert.strictEqual(mode, 'crossModule'); - } finally { - await cfg.update('analysisMode', original, vscode.ConfigurationTarget.Workspace); - } - }); - - test('basilisk.analysisMode can be reset to wholeModule', async () => { - const cfg = vscode.workspace.getConfiguration('basilisk'); - // Set to openFilesOnly first, then back to wholeModule. - await cfg.update('analysisMode', 'openFilesOnly', vscode.ConfigurationTarget.Workspace); - await cfg.update('analysisMode', 'wholeModule', vscode.ConfigurationTarget.Workspace); - const mode = vscode.workspace.getConfiguration('basilisk').get<string>('analysisMode'); - assert.strictEqual(mode, 'wholeModule', 'should be able to reset to wholeModule'); - }); - - test('basilisk.analysisMode: all three enum values are accepted', async () => { - const cfg = vscode.workspace.getConfiguration('basilisk'); - const original = cfg.get<string>('analysisMode'); - const modes = ['openFilesOnly', 'wholeModule', 'crossModule']; - try { - for (const m of modes) { - await cfg.update('analysisMode', m, vscode.ConfigurationTarget.Workspace); - const current = vscode.workspace.getConfiguration('basilisk').get<string>('analysisMode'); - assert.strictEqual(current, m, `setting should accept '${m}'`); - } - } finally { - await cfg.update('analysisMode', original, vscode.ConfigurationTarget.Workspace); - } - }); - - // ------------------------------------------------------- - // Extension wiring tests — prove the extension reads and - // forwards the setting to the LSP server correctly. - // ------------------------------------------------------- - - test('wholeModule mode: setting is wired into initializationOptions', () => { - // Structural test — the extension must read analysisMode and pass it - // to the server. The extension source sets initializationOptions.analysisMode. - const cfg = vscode.workspace.getConfiguration('basilisk'); - const mode = cfg.get<string>('analysisMode') ?? 'wholeModule'; - const validModes = ['openFilesOnly', 'wholeModule', 'crossModule']; - assert.ok( - validModes.includes(mode), - `analysisMode '${mode}' is not a valid mode. Expected one of: ${validModes.join(', ')}` - ); - }); - - test('openFilesOnly mode: disabling whole-module sets setting correctly', async () => { - // Prove the user can turn OFF whole-module analysis (important for large projects). - const cfg = vscode.workspace.getConfiguration('basilisk'); - const originalMode = cfg.get<string>('analysisMode') ?? 'wholeModule'; - - try { - await cfg.update('analysisMode', 'openFilesOnly', vscode.ConfigurationTarget.Workspace); - const updated = vscode.workspace.getConfiguration('basilisk').get<string>('analysisMode'); - assert.strictEqual( - updated, - 'openFilesOnly', - `Expected analysisMode to be 'openFilesOnly' after update, got '${updated}'` - ); - // Verify that this is a meaningful change from the default. - assert.notStrictEqual( - updated, - 'wholeModule', - 'openFilesOnly must be different from wholeModule default' - ); - } finally { - await cfg.update('analysisMode', originalMode, vscode.ConfigurationTarget.Workspace); - } - }); - - // ------------------------------------------------------- - // Whole-module LSP behaviour: a file written to the VS Code - // workspace root but NEVER opened in the editor must receive - // diagnostics from the startup scan. - // - // The workspace root is test-fixtures/workspace/ (configured - // in .vscode-test.mjs). Files written there are within the - // LSP server's rootUri, so the wholeModule startup scan will - // pick them up. - // ------------------------------------------------------- - - test('wholeModule: startup scan publishes diagnostics for closed file in workspace root', async function () { - this.timeout(SUITE_SETUP_TIMEOUT_MS); - - // Determine the workspace root that VS Code opened. - const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; - assert.ok( - workspaceRoot !== undefined, - 'wholeModule scan test: no workspace folder configured. ' + - 'Ensure .vscode-test.mjs sets workspaceFolder.' - ); - - // Ensure wholeModule mode is set BEFORE the extension activates. - // (The extension reads the setting during activate(), so changing it - // here affects the server's initializationOptions.) - const cfg = vscode.workspace.getConfiguration('basilisk'); - const originalMode = cfg.get<string>('analysisMode'); - await cfg.update('analysisMode', 'wholeModule', vscode.ConfigurationTarget.Workspace); - - try { - // Write a Python file with type errors into the workspace root. - // Do NOT open it — the whole-module scan must find it on its own. - const closedFilePath = path.join(workspaceRoot, 'wm_scan_target.py'); - fs.writeFileSync( - closedFilePath, - 'def greet(name):\n return f"Hello, {name}!"\n', - 'utf8' - ); - - // Activate the extension (or restart to pick up the new file). - const ext = vscode.extensions.getExtension(EXTENSION_ID); - if (ext !== undefined && !ext.isActive) { - await ext.activate(); - } - - // Wait for the startup scan to publish, EVENT-DRIVEN. - // - // This was `await delay(SERVER_START_WAIT_MS + TIMEOUT_BUFFER_MS)` — - // an unconditional 65-SECOND sleep, which measured as the single - // most expensive test in the suite on both CI legs. None of it was - // work: `waitForDiagnostics` resolves synchronously when - // diagnostics already exist, so the scan had invariably long - // finished and the test simply sat there. - // - // The deadline is deliberately unchanged — 65s of sleep plus a 15s - // wait is the same 80s budget the scan gets here — so this cannot - // introduce a timing flake. It only stops paying the budget when - // the scan is fast, which is always. - const closedFileUri = vscode.Uri.file(closedFilePath); - const diags = await waitForDiagnostics( - closedFileUri, - SERVER_START_WAIT_MS + TIMEOUT_BUFFER_MS + DIAGNOSTIC_TIMEOUT_MS - ); - - assert.ok( - diags.length > 0, - 'wholeModule: startup scan must publish diagnostics for a closed file ' + - 'that exists in the workspace root. Diagnostics were empty — either ' + - 'the scan did not run or the file was not analysed.' - ); - - // Verify the diagnostics are from Basilisk (not another linter). - const basiliskDiags = filterBasiliskDiagnostics(diags); - assert.ok( - basiliskDiags.length > 0, - `wholeModule: diagnostics must be from Basilisk (BSK codes), got: ${ - JSON.stringify(diags.map(d => ({ source: d.source, code: d.code })))}` - ); - - // Cleanup the test file from the workspace root. - fs.unlinkSync(closedFilePath); - } finally { - await cfg.update('analysisMode', originalMode, vscode.ConfigurationTarget.Workspace); - } - }); - - test('openFilesOnly: startup scan does NOT run — closed workspace file gets no diagnostics', async function () { - this.timeout(NO_DIAGNOSTIC_WAIT_MS + LARGE_TIMEOUT_BUFFER_MS); - - const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; - assert.ok( - workspaceRoot !== undefined, - 'openFilesOnly test: no workspace folder configured. ' + - 'Ensure .vscode-test.mjs sets workspaceFolder.' - ); - - const cfg = vscode.workspace.getConfiguration('basilisk'); - const originalMode = cfg.get<string>('analysisMode'); - - // Write a file with type errors into the workspace root. - const closedFilePath = path.join(workspaceRoot, 'ofo_no_scan_target.py'); - fs.writeFileSync( - closedFilePath, - 'def greet(name):\n return f"Hello, {name}!"\n', - 'utf8' - ); - - try { - await cfg.update('analysisMode', 'openFilesOnly', vscode.ConfigurationTarget.Workspace); - - // Activate (or restart) the extension with openFilesOnly mode. - const ext = vscode.extensions.getExtension(EXTENSION_ID); - if (ext !== undefined && !ext.isActive) { - await ext.activate(); - } - - // Wait long enough for a scan to have run (if it was going to). - await delay(NO_DIAGNOSTIC_WAIT_MS); - - // In openFilesOnly mode, the closed file must NOT have diagnostics. - const closedFileUri = vscode.Uri.file(closedFilePath); - const diags = vscode.languages.getDiagnostics(closedFileUri); - assert.strictEqual( - diags.length, - 0, - 'openFilesOnly: startup scan must NOT run — closed file should have zero diagnostics, ' + - `got: ${JSON.stringify(diags)}` - ); - } finally { - fs.unlinkSync(closedFilePath); - await cfg.update('analysisMode', originalMode, vscode.ConfigurationTarget.Workspace); - } - }); - - test('openFilesOnly: opening a file produces diagnostics, closing clears them', async function () { - this.timeout(DIAGNOSTIC_TIMEOUT_MS + LARGE_TIMEOUT_BUFFER_MS); - - const cfg = vscode.workspace.getConfiguration('basilisk'); - const originalMode = cfg.get<string>('analysisMode'); - - try { - await cfg.update('analysisMode', 'openFilesOnly', vscode.ConfigurationTarget.Workspace); - - // Open a file with type errors. - const { uri } = await openPythonFile( - tmpDir, - 'ofo_open_close.py', - 'def greet(name):\n return f"Hello, {name}!"\n' - ); - - // Wait for diagnostics to appear (file is open, so should be analysed - // regardless of mode). - const openDiags = await waitForDiagnostics(uri, DIAGNOSTIC_TIMEOUT_MS); - assert.ok( - openDiags.length > 0, - 'openFilesOnly: should have diagnostics while file is open' - ); - - // Close the file. - await vscode.commands.executeCommand('workbench.action.closeActiveEditor'); - - // In openFilesOnly mode the server clears diagnostics when the file is closed. - const clearedDiags = await waitForDiagnosticsCleared(uri, NO_DIAGNOSTIC_WAIT_MS); - assert.strictEqual( - clearedDiags.length, - 0, - 'openFilesOnly: diagnostics should be cleared when file is closed' - ); - } finally { - await cfg.update('analysisMode', originalMode, vscode.ConfigurationTarget.Workspace); - } - }); - - // Regression test for GitHub #264 — the root cause of the flaky - // "openFilesOnly: opening a file produces diagnostics, closing clears them" - // failure under full-suite load. A wholeModule scan snapshots open files - // (refresh_open_files) and publishes them last; a didClose processed - // between the scan's publishes clears the file and removes it from the - // index, then the scan republishes the stale diagnostics — which nothing - // ever clears again. Exercises the publish staleness guard in - // crates/basilisk-lsp/src/server/init.rs ([ANALYSIS-PUBLISH]). - test('wholeModule: file closed mid-scan must not get stale diagnostics republished (#264)', async function () { - this.timeout(SUITE_SETUP_TIMEOUT_MS + SCAN_COMPLETE_TIMEOUT_MS); - - const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; - assert.ok(workspaceRoot !== undefined, 'no workspace folder configured'); - - const cfg = vscode.workspace.getConfiguration('basilisk'); - const originalMode = cfg.get<string>('analysisMode'); - const fodderDir = path.join(workspaceRoot, 'scan_fodder_264'); - - try { - // Start in openFilesOnly so the later flip to wholeModule triggers - // a fresh workspace scan while our file is open. - await cfg.update('analysisMode', 'openFilesOnly', vscode.ConfigurationTarget.Workspace); - await delay(1_000); - - // Slow the upcoming scan down and plant the completion marker. - const markerUri = writeScanFodder(fodderDir); - - // Open an erroring file OUTSIDE the workspace root and wait for - // its diagnostics (published by didOpen). - const { uri } = await openPythonFile( - tmpDir, - 'stale_republish_264.py', - 'def greet(name):\n return f"Hello, {name}!"\n' - ); - const openDiags = await waitForDiagnostics(uri, DIAGNOSTIC_TIMEOUT_MS); - assert.ok(openDiags.length > 0, 'file must have diagnostics while open'); - - // Flip to wholeModule: the scan snapshot now includes the open - // file. Give the config change time to reach the server and the - // scan time to start computing… - await cfg.update('analysisMode', 'wholeModule', vscode.ConfigurationTarget.Workspace); - await delay(SCAN_KICKOFF_WAIT_MS); - - // …then close the editor while the scan is still running. The - // server clears the file's diagnostics on didClose. - await vscode.commands.executeCommand('workbench.action.closeActiveEditor'); - const cleared = await waitForDiagnosticsCleared(uri, DIAGNOSTIC_TIMEOUT_MS); - assert.strictEqual(cleared.length, 0, 'didClose must clear diagnostics'); - - // Wait for the scan's publish loop to reach the scan portion (the - // marker fodder file gets its diagnostics)… - await waitForDiagnostics(markerUri, SCAN_COMPLETE_TIMEOUT_MS); - - // …and assert the closed file's stale diagnostics never come back. - await assertDiagnosticsStayCleared(uri, STALE_REPUBLISH_GRACE_MS); - } finally { - removeTestDir(fodderDir); - await cfg.update('analysisMode', originalMode, vscode.ConfigurationTarget.Workspace); - } - }); -}); diff --git a/vscode-extension/src/test/suite/lsp-client-stop.test.ts b/vscode-extension/src/test/suite/lsp-client-stop.test.ts deleted file mode 100644 index 410e6973e..000000000 --- a/vscode-extension/src/test/suite/lsp-client-stop.test.ts +++ /dev/null @@ -1,200 +0,0 @@ -// Tests for [LSPARCH-CMDREG] client lifecycle — see docs/specs/LSP-ARCHITECTURE-SPEC.md#LSPARCH-CMDREG -// Covers src/lsp-client-stop.ts. -/** - * A `LanguageClient` caught mid-start must still be stoppable. - * - * `vscode-languageclient`'s `shutdown` rejects unless the state is exactly - * `Running`, so `stop()` on a starting client throws rather than stopping it, - * and `isRunning()` — the guard the shutdown paths used to use — reads `false` - * for a client that has already spawned its server process. The two together - * mean a client shut down during its own start either throws out of - * `deactivate()` or is dropped while its server keeps running (GitHub #264). - * - * The window is wide on win32, where spawning the server binary is slow enough - * that a deactivate/activate cycle routinely lands inside it. - * - * The double below reproduces that contract exactly — `stop()`/`dispose()` - * reject with the real message unless the state is `running` — so a helper - * that skips the settle step fails these tests instead of passing them. - */ - -import * as assert from 'assert'; -import type { LanguageClient } from 'vscode-languageclient/node'; -import { stopClientSettled } from '../../lsp-client-stop'; -import { fakeLanguageClient } from './test-helpers'; - -type FakeState = 'initial' | 'starting' | 'running' | 'stopped'; - -interface Harness { - readonly client: LanguageClient; - state(): FakeState; - stops(): number; - disposes(): number; - startCalls(): number; - finishStart(): void; - failStart(error: Error): void; -} - -/** - * A client double honouring the real lifecycle contract: - * `needsStop()` covers starting AND running, `isRunning()` covers running - * alone, and shutting down from any state but `running` rejects. - */ -function makeClient(initial: FakeState): Harness { - let state: FakeState = initial; - let stops = 0; - let disposes = 0; - let startCalls = 0; - let settleStart: (() => void) | undefined; - let breakStart: ((error: Error) => void) | undefined; - - const pendingStart = new Promise<void>((resolve, reject) => { - settleStart = (): void => { state = 'running'; resolve(); }; - breakStart = (error: Error): void => { state = 'stopped'; reject(error); }; - }); - // A start that is never awaited must not surface as an unhandled rejection. - pendingStart.catch(() => { /* observed by the helper, or not at all */ }); - - async function shutdown(tally: () => void): Promise<void> { - if (state !== 'running') { - throw new Error( - `Client is not running and can't be stopped. It's current state is: ${state}`, - ); - } - tally(); - state = 'stopped'; - } - - const client = fakeLanguageClient({ - needsStop: (): boolean => state === 'starting' || state === 'running', - isRunning: (): boolean => state === 'running', - start: async (): Promise<void> => { - startCalls += 1; - // The real `start()` hands back the in-flight start promise rather - // than beginning a second one. - if (state === 'starting') { await pendingStart; } - }, - stop: async (): Promise<void> => shutdown(() => { stops += 1; }), - dispose: async (): Promise<void> => shutdown(() => { disposes += 1; }), - }); - - return { - client, - state: (): FakeState => state, - stops: (): number => stops, - disposes: (): number => disposes, - startCalls: (): number => startCalls, - finishStart: (): void => { settleStart?.(); }, - failStart: (error: Error): void => { breakStart?.(error); }, - }; -} - -suite('LSP client shutdown settles a start in flight [LSPARCH-CMDREG]', () => { - - test('a RUNNING client stops directly', async () => { - const harness = makeClient('running'); - - await stopClientSettled(harness.client); - - assert.strictEqual(harness.stops(), 1, 'a running client must be stopped'); - assert.strictEqual(harness.state(), 'stopped'); - }); - - test('a STARTING client is stopped once its start settles — never dropped', async () => { - const harness = makeClient('starting'); - - const stopped = stopClientSettled(harness.client); - // The helper must be waiting on the start, not calling stop() into a - // rejection and not abandoning the client. - assert.strictEqual(harness.stops(), 0, 'stop() must not be called while starting'); - - harness.finishStart(); - await stopped; - - assert.strictEqual(harness.stops(), 1, 'the settled client must then be stopped'); - assert.strictEqual(harness.state(), 'stopped', 'the server process must not be left running'); - }); - - test('a STARTING client is never shut down from the starting state', async () => { - const harness = makeClient('starting'); - - const stopped = stopClientSettled(harness.client); - harness.finishStart(); - - // A helper that called stop() while starting would reject here with - // "Client is not running and can't be stopped" — the CI failure. - await stopped; - - assert.strictEqual(harness.startCalls(), 1, 'the in-flight start must be awaited exactly once'); - }); - - test('a failed start is swallowed — that client is already stopped', async () => { - const harness = makeClient('starting'); - - const stopped = stopClientSettled(harness.client); - harness.failStart(new Error('server binary missing')); - - // deactivate() must not reject because the client it is tearing down - // failed to start in the first place. - await stopped; - - assert.strictEqual(harness.stops(), 0, 'a client that never started has nothing to stop'); - assert.strictEqual(harness.state(), 'stopped'); - }); - - test('a STOPPED client is left alone', async () => { - const harness = makeClient('stopped'); - - await stopClientSettled(harness.client); - - assert.strictEqual(harness.stops(), 0, 'needsStop() is false — nothing to do'); - assert.strictEqual(harness.startCalls(), 0, 'a stopped client must never be started to stop it'); - }); - - test('an INITIAL client is left alone', async () => { - const harness = makeClient('initial'); - - await stopClientSettled(harness.client); - - assert.strictEqual(harness.stops(), 0); - assert.strictEqual(harness.startCalls(), 0); - }); - - test('concurrent shutdowns of one client collapse into a single stop', async () => { - const harness = makeClient('starting'); - - // deactivate() stops the client and then calls store.reset(), which - // also wants it gone. The second must join the first, not race it into - // a rejection. - const first = stopClientSettled(harness.client); - const second = stopClientSettled(harness.client); - - harness.finishStart(); - await Promise.all([first, second]); - - assert.strictEqual(harness.stops(), 1, 'the client must be stopped exactly once'); - assert.strictEqual(harness.disposes(), 0, 'the second caller must not shut it down again'); - }); - - test("dispose mode tears the client down so it cannot be restarted", async () => { - const harness = makeClient('starting'); - - const stopped = stopClientSettled(harness.client, 'dispose'); - harness.finishStart(); - await stopped; - - assert.strictEqual(harness.disposes(), 1, 'dispose mode must dispose'); - assert.strictEqual(harness.stops(), 0, 'dispose mode must not also plain-stop'); - }); - - test('a client stopped once can be stopped again later without throwing', async () => { - const harness = makeClient('running'); - - await stopClientSettled(harness.client); - // The in-flight entry must be released on completion, and the second - // call must see a stopped client and no-op rather than reject. - await stopClientSettled(harness.client); - - assert.strictEqual(harness.stops(), 1); - }); -}); diff --git a/vscode-extension/src/test/suite/lsp-document-selector.test.ts b/vscode-extension/src/test/suite/lsp-document-selector.test.ts deleted file mode 100644 index 23be8563e..000000000 --- a/vscode-extension/src/test/suite/lsp-document-selector.test.ts +++ /dev/null @@ -1,22 +0,0 @@ -// Implements [CONFIGEDITOR-SOURCES-OPEN-BUFFER]. - -import * as assert from "assert"; - -import { BASILISK_DOCUMENT_SELECTOR } from "../../lsp-document-selector"; - -suite("LSP document selector", () => { - test("synchronizes Python and the pyproject.toml configuration candidate", () => { - assert.deepStrictEqual(BASILISK_DOCUMENT_SELECTOR, [ - { scheme: "file", language: "python" }, - { scheme: "file", pattern: "**/pyproject.toml" }, - ]); - }); - - test("never synchronizes the removed basilisk.json format", () => { - const serialized = JSON.stringify(BASILISK_DOCUMENT_SELECTOR); - assert.ok( - !serialized.includes("basilisk.json"), - `selector must not reference basilisk.json: ${serialized}`, - ); - }); -}); diff --git a/vscode-extension/src/test/suite/lsp-features.test.ts b/vscode-extension/src/test/suite/lsp-features.test.ts deleted file mode 100644 index c27a23229..000000000 --- a/vscode-extension/src/test/suite/lsp-features.test.ts +++ /dev/null @@ -1,337 +0,0 @@ -// Tests for [LSPARCH-FEATURES]. See docs/specs/LSP-ARCHITECTURE-SPEC.md#LSPARCH-FEATURES -/** - * LSP Feature Tests for the Basilisk VS Code Extension. - * - * These tests exercise additional LSP capabilities (find references, - * rename, inlay hints, formatting, document highlights) through the - * VS Code extension command APIs. - * - * Prerequisites: - * - The `basilisk` binary must be built: `cargo build -p basilisk-cli` - * - The binary must be on PATH or the test will fail hard - */ - -import * as assert from 'assert'; -import * as vscode from 'vscode'; -import * as path from 'path'; -import * as fs from 'fs'; -import * as os from 'os'; -import { - closeAllEditors, - DIAGNOSTIC_TIMEOUT_MS, - findBasiliskBinary, - openPythonFile, - pollUntilResult, - removeTestDir, - SUITE_SETUP_TIMEOUT_MS, - waitForLspReady, -} from './test-helpers'; - -/** Additional time (ms) added to DIAGNOSTIC_TIMEOUT_MS for individual test timeouts. */ -const EXTRA_TEST_TIMEOUT_MS = 10_000; - -/** Column position of the function name in a `def name(...)` declaration. */ -const DEF_NAME_COLUMN = 4; - -/** Minimum expected reference count: 1 definition + 2 call sites. */ -const MIN_REFERENCE_COUNT = 3; - -/** Line index of a call site (e.g. `result2 = compute(20)` on line 3). */ -const CALL_SITE_LINE = 3; - -/** Minimum expected inlay hint count for unannotated variables. */ -const MIN_INLAY_HINT_COUNT = 2; - -/** Tab size used for formatting requests. */ -const FORMAT_TAB_SIZE = 4; - -/** Minimum expected highlight count: 1 definition + 2 call sites. */ -const MIN_HIGHLIGHT_COUNT = 3; - -/** Minimum number of distinct lines expected for document highlights. */ -const MIN_HIGHLIGHT_LINE_COUNT = 2; - -/** Minimum expected rename edits: definition + at least 1 call site. */ -const MIN_RENAME_EDIT_COUNT = 2; - -// eslint-disable-next-line max-lines-per-function -- suite callback contains all tests -suite('LSP Feature Tests', () => { - let tmpDir: string; - let basiliskBinary: string | undefined; - - suiteSetup(async function () { - this.timeout(SUITE_SETUP_TIMEOUT_MS); - - basiliskBinary = findBasiliskBinary(); - if (basiliskBinary === undefined) { - throw new Error( - 'Basilisk binary not found. Build with: cargo build -p basilisk-cli' - ); - } - - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'basilisk-lsp-features-')); - - await waitForLspReady(); - await vscode.commands.executeCommand('workbench.action.closeAllEditors'); - }); - - suiteTeardown(async () => { - await closeAllEditors(); - if (tmpDir !== undefined && tmpDir !== '' && fs.existsSync(tmpDir)) { - removeTestDir(tmpDir); - } - }); - - teardown(async () => { - await closeAllEditors(); - }); - - // ---------------------------------------------------------------- - // 1. Find references works through extension - // ---------------------------------------------------------------- - test('find references works through extension', async function () { - this.timeout(DIAGNOSTIC_TIMEOUT_MS + EXTRA_TEST_TIMEOUT_MS); - - const source = [ - 'def compute(x: int) -> int:', - ' return x * 2', - '', - 'result1: int = compute(10)', - 'result2: int = compute(20)', - '', - ].join('\n'); - - const { uri } = await openPythonFile(tmpDir, 'test_references.py', source); - - // Poll until the server has indexed and returns reference results. - const defPosition = new vscode.Position(0, DEF_NAME_COLUMN); - const locations = await pollUntilResult({ - fn: () => vscode.commands.executeCommand<vscode.Location[]>( - 'vscode.executeReferenceProvider', uri, defPosition - ).then((r) => r, () => [] as vscode.Location[]), - predicate: (r) => r !== null && r !== undefined && r.length >= MIN_REFERENCE_COUNT, - }); - - assert.ok(locations !== undefined, 'Expected reference results to be defined'); - assert.ok( - Array.isArray(locations), - 'Expected reference results to be an array' - ); - assert.ok( - locations.length >= MIN_REFERENCE_COUNT, - `Expected at least ${MIN_REFERENCE_COUNT} references (1 definition + 2 call sites), ` + - `but got ${locations.length}: ${locations.map((loc) => `L${loc.range.start.line}:${loc.range.start.character}`).join(', ')}` - ); - - // Verify all locations point to the same file. - const allSameFile = locations.every( - (loc) => loc.uri.toString() === uri.toString() - ); - assert.ok( - allSameFile, - 'Expected all reference locations to be in the same file' - ); - }); - - // ---------------------------------------------------------------- - // 2. Rename symbol works through extension - // ---------------------------------------------------------------- - test('rename symbol works through extension', async function () { - this.timeout(DIAGNOSTIC_TIMEOUT_MS + EXTRA_TEST_TIMEOUT_MS); - - const source = [ - 'def old_name(x: int) -> int:', - ' return x + 1', - '', - 'value: int = old_name(5)', - '', - ].join('\n'); - - const { uri } = await openPythonFile(tmpDir, 'test_rename.py', source); - - // Poll until the server has indexed and returns rename results. - const defPosition = new vscode.Position(0, DEF_NAME_COLUMN); - const workspaceEdit = await pollUntilResult({ - fn: () => vscode.commands.executeCommand<vscode.WorkspaceEdit>( - 'vscode.executeDocumentRenameProvider', uri, defPosition, 'new_name' - ).then((r) => r, () => new vscode.WorkspaceEdit()), - predicate: (r) => r !== null && r !== undefined && r.get(uri).length > 0, - }); - - assert.ok(workspaceEdit !== undefined, 'Expected workspace edit to be defined'); - - // Get the text edits for our file. - const edits = workspaceEdit.get(uri); - assert.ok( - edits.length > 0, - `Expected at least one text edit for the renamed file, ` + - `but got ${edits.length} edits` - ); - - // Verify that edits replace "old_name" with "new_name". - const renameEdits = edits.filter( - (edit) => edit.newText === 'new_name' - ); - assert.ok( - renameEdits.length >= MIN_RENAME_EDIT_COUNT, - `Expected at least ${MIN_RENAME_EDIT_COUNT} rename edits (definition + call site), ` + - `but got ${renameEdits.length}. All edits: ${ - edits.map((e) => `"${e.newText}" at L${e.range.start.line}:${e.range.start.character}`).join(', ')}` - ); - - // Verify the edits cover both the definition line and the call-site line. - const editLines = new Set(renameEdits.map((edit) => edit.range.start.line)); - assert.ok( - editLines.has(0), - 'Expected a rename edit on line 0 (function definition)' - ); - assert.ok( - editLines.has(CALL_SITE_LINE), - `Expected a rename edit on line ${CALL_SITE_LINE} (call site)` - ); - }); - - // ---------------------------------------------------------------- - // 3. Inlay hints appear for unannotated variables - // ---------------------------------------------------------------- - test('inlay hints appear for unannotated variables', async function () { - this.timeout(DIAGNOSTIC_TIMEOUT_MS + EXTRA_TEST_TIMEOUT_MS); - - const source = [ - 'x = 42', - 'y = "hello"', - 'z = [1, 2, 3]', - '', - ].join('\n'); - - const { doc, uri } = await openPythonFile(tmpDir, 'test_inlay_hints.py', source); - - // Poll until the server returns inlay hints. - const fullRange = new vscode.Range( - new vscode.Position(0, 0), - new vscode.Position(doc.lineCount - 1, doc.lineAt(doc.lineCount - 1).text.length) - ); - const hints = await pollUntilResult({ - fn: () => vscode.commands.executeCommand<vscode.InlayHint[]>( - 'vscode.executeInlayHintProvider', uri, fullRange - ).then((r) => r, () => [] as vscode.InlayHint[]), - predicate: (r) => r !== null && r !== undefined && r.length >= MIN_INLAY_HINT_COUNT, - }); - - assert.ok(hints !== undefined, 'Expected inlay hints result to be defined'); - assert.ok( - Array.isArray(hints), - 'Expected inlay hints result to be an array' - ); - assert.ok( - hints.length >= MIN_INLAY_HINT_COUNT, - `Expected at least ${MIN_INLAY_HINT_COUNT} inlay hints for unannotated variables (x, y), ` + - `but got ${hints.length}` - ); - - // Verify hints have label content. - const nonEmptyHints = hints.filter((hint) => { - const label = typeof hint.label === 'string' - ? hint.label - : hint.label.map((part) => part.value).join(''); - return label.length > 0; - }); - assert.ok( - nonEmptyHints.length >= MIN_INLAY_HINT_COUNT, - `Expected at least ${MIN_INLAY_HINT_COUNT} non-empty inlay hint labels, ` + - `but got ${nonEmptyHints.length}` - ); - }); - - // ---------------------------------------------------------------- - // 4. Format document works through extension - // ---------------------------------------------------------------- - test('format document works through extension', async function () { - this.timeout(DIAGNOSTIC_TIMEOUT_MS + EXTRA_TEST_TIMEOUT_MS); - - // Intentionally badly formatted Python code. - const source = [ - 'x=1', - 'y = "hello"', - 'def foo( a:int,b:str )->None:', - ' pass', - '', - ].join('\n'); - - const { uri } = await openPythonFile(tmpDir, 'test_format.py', source); - - // Poll until the server returns formatting edits. - const edits = await pollUntilResult({ - fn: () => vscode.commands.executeCommand<vscode.TextEdit[]>( - 'vscode.executeFormatDocumentProvider', uri, - { tabSize: FORMAT_TAB_SIZE, insertSpaces: true } - ).then((r) => r, () => [] as vscode.TextEdit[]), - predicate: (r) => r !== null && r !== undefined && r.length > 0, - }); - - assert.ok(edits !== undefined, 'Expected format edits to be defined'); - assert.ok( - Array.isArray(edits), - 'Expected format edits to be an array' - ); - assert.ok( - edits.length > 0, - 'Expected at least one formatting edit for the badly formatted file' - ); - - // Verify at least one edit changes something (new text differs from original range). - const meaningfulEdits = edits.filter((edit) => edit.newText.length > 0); - assert.ok( - meaningfulEdits.length > 0, - `Expected at least one meaningful formatting edit, ` + - `but all ${edits.length} edits had empty replacement text` - ); - }); - - // ---------------------------------------------------------------- - // 5. Document highlight works for symbol - // ---------------------------------------------------------------- - test('document highlight works for symbol', async function () { - this.timeout(DIAGNOSTIC_TIMEOUT_MS + EXTRA_TEST_TIMEOUT_MS); - - const source = [ - 'def process(data: str) -> str:', - ' return data.upper()', - '', - 'output1: str = process("hello")', - 'output2: str = process("world")', - '', - ].join('\n'); - - const { uri } = await openPythonFile(tmpDir, 'test_highlight.py', source); - - // Poll until the server returns document highlights. - const defPosition = new vscode.Position(0, DEF_NAME_COLUMN); - const highlights = await pollUntilResult({ - fn: () => vscode.commands.executeCommand<vscode.DocumentHighlight[]>( - 'vscode.executeDocumentHighlights', uri, defPosition - ).then((r) => r, () => [] as vscode.DocumentHighlight[]), - predicate: (r) => r !== null && r !== undefined && r.length >= MIN_HIGHLIGHT_COUNT, - }); - - assert.ok(highlights !== undefined, 'Expected document highlights to be defined'); - assert.ok( - Array.isArray(highlights), - 'Expected document highlights to be an array' - ); - assert.ok( - highlights.length >= MIN_HIGHLIGHT_COUNT, - `Expected at least ${MIN_HIGHLIGHT_COUNT} highlights (1 definition + 2 call sites), ` + - `but got ${highlights.length}: ${ - highlights.map((h) => `L${h.range.start.line}:${h.range.start.character}`).join(', ')}` - ); - - // Verify highlights span multiple lines. - const highlightLines = new Set(highlights.map((h) => h.range.start.line)); - assert.ok( - highlightLines.size >= MIN_HIGHLIGHT_LINE_COUNT, - `Expected highlights on at least ${MIN_HIGHLIGHT_LINE_COUNT} different lines, ` + - `but all highlights were on lines: ${[...highlightLines].join(', ')}` - ); - }); -}); diff --git a/vscode-extension/src/test/suite/lsp-fix-all.test.ts b/vscode-extension/src/test/suite/lsp-fix-all.test.ts deleted file mode 100644 index 804403baa..000000000 --- a/vscode-extension/src/test/suite/lsp-fix-all.test.ts +++ /dev/null @@ -1,252 +0,0 @@ -// Implements [LSPARCH-FEATURES-CODEACTIONS]. See docs/specs/LSP-ARCHITECTURE-SPEC.md#LSPARCH-FEATURES-CODEACTIONS -// Exercises [AUTOFIX-MASS] (File scope) and [AUTOFIX-MASS-VSCODE] — the -// `basilisk.fixFile` command and `source.fixAll.basilisk` code action. -/** - * LSP Fix-All E2E Tests for the Basilisk VS Code Extension. - * - * Tests the file-level mass autofix functionality: - * - `basilisk.fixFile` command applies edits and clears diagnostics - * - `source.fixAll.basilisk` code action kind is returned by the server - * - Multiple diagnostics across lines are fixed in a single action - * - * Prerequisites: - * - The `basilisk` binary must be built: `cargo build -p basilisk-cli` - * - The binary must be on PATH or the test will fail hard - */ - -import { delay } from '../../timeouts'; -import * as assert from 'assert'; -import * as vscode from 'vscode'; - -import { - COMMAND_WAIT_MS, - DIAGNOSTIC_TIMEOUT_MS, - NO_DIAGNOSTIC_WAIT_MS, - SERVER_START_WAIT_MS, - SUITE_SETUP_TIMEOUT_MS, - waitForDiagnostics, - waitForDiagnosticsCleared, - openPythonFile, - closeAllEditors, - setupLspTestSuite, - teardownLspTestSuite, -} from './test-helpers'; - -/** Time (ms) to wait for re-diagnosis after applying edits. */ -const RECHECK_WAIT_MS = 3_000; - -/** Filter diagnostics to only BSK-0050 (redundant annotation). */ -function filterW0050(diagnostics: vscode.Diagnostic[]): vscode.Diagnostic[] { - return diagnostics.filter((d) => { - if (typeof d.code === 'object' && d.code !== null && 'value' in d.code) { - return d.code.value === 'BSK-0050'; - } - return typeof d.code === 'string' && d.code === 'BSK-0050'; - }); -} - -// eslint-disable-next-line max-lines-per-function -suite('LSP Fix-All Tests', () => { - let tmpDir: string; - - suiteSetup(async function () { - this.timeout(SUITE_SETUP_TIMEOUT_MS); - const result = await setupLspTestSuite('basilisk-fixall-test-'); - tmpDir = result.tmpDir; - }); - - suiteTeardown(async () => { - await closeAllEditors(); - teardownLspTestSuite(tmpDir); - }); - - teardown(async () => { - await closeAllEditors(); - }); - - // ---------------------------------------------------------------- - // 1. fixFile command applies edits and clears diagnostics - // ---------------------------------------------------------------- - test('fixFile command applies edits and clears diagnostics', async function () { - this.timeout(DIAGNOSTIC_TIMEOUT_MS * 2 + SERVER_START_WAIT_MS); - - // Open a file with a redundant annotation — W0050 is auto-fixable. - const { uri } = await openPythonFile( - tmpDir, - 'test_fix_file.py', - 'x: int = 42\n' - ); - - // Wait for the W0050 diagnostic to appear. - const diagnostics = await waitForDiagnostics(uri, DIAGNOSTIC_TIMEOUT_MS); - const w0050 = filterW0050(diagnostics); - assert.ok( - w0050.length > 0, - `Expected BSK-0050 diagnostic for redundant annotation, ` + - `got: ${diagnostics.map((d) => JSON.stringify(d.code)).join(', ')}` - ); - - // Execute the fixFile command — it should apply edits via workspace/applyEdit. - await vscode.commands.executeCommand('basilisk.fixFile'); - - // After fixing, the redundant annotation diagnostic should clear. - const cleared = await waitForDiagnosticsCleared(uri, DIAGNOSTIC_TIMEOUT_MS); - const remaining = filterW0050(cleared); - assert.strictEqual( - remaining.length, - 0, - `Expected W0050 diagnostic to clear after fixFile, but ${remaining.length} remain` - ); - }); - - // ---------------------------------------------------------------- - // 2. source.fixAll code action returned for fixable diagnostics - // ---------------------------------------------------------------- - test('source.fixAll code action returned for fixable diagnostics', async function () { - this.timeout(DIAGNOSTIC_TIMEOUT_MS + SERVER_START_WAIT_MS); - - const { uri } = await openPythonFile( - tmpDir, - 'test_fix_all_action.py', - 'x: int = 42\n' - ); - - // Wait for diagnostics to appear. - const diagnostics = await waitForDiagnostics(uri, DIAGNOSTIC_TIMEOUT_MS); - assert.ok( - diagnostics.length > 0, - 'Expected at least one diagnostic for redundant annotation' - ); - - // Request code actions with the source.fixAll filter. - const fullRange = new vscode.Range( - new vscode.Position(0, 0), - new vscode.Position(1, 0) - ); - const codeActions = await vscode.commands.executeCommand<vscode.CodeAction[]>( - 'vscode.executeCodeActionProvider', - uri, - fullRange, - vscode.CodeActionKind.SourceFixAll.value - ); - - assert.ok(codeActions !== undefined, 'Expected code actions result to be defined'); - assert.ok( - codeActions.length > 0, - `Expected at least one source.fixAll code action, got ${codeActions.length}` - ); - - const fixAllAction = codeActions.find( - (a) => a.title.includes('Fix all auto-fixable issues') - ); - assert.ok( - fixAllAction, - `Expected a 'Fix all auto-fixable issues' action. ` + - `Got titles: ${codeActions.map((a) => a.title).join(', ')}` - ); - }); - - // ---------------------------------------------------------------- - // 3. fixFile fixes multiple diagnostics across lines - // ---------------------------------------------------------------- - test('fixFile fixes multiple diagnostics across lines', async function () { - this.timeout(DIAGNOSTIC_TIMEOUT_MS * 2 + SERVER_START_WAIT_MS); - - // Two redundant annotations on separate lines — both fixable. - const { uri } = await openPythonFile( - tmpDir, - 'test_fix_multi.py', - 'x: int = 42\ny: str = "hello"\n' - ); - - // Wait for diagnostics to appear on both lines. - const diagnostics = await waitForDiagnostics(uri, DIAGNOSTIC_TIMEOUT_MS); - const w0050s = filterW0050(diagnostics); - assert.ok( - w0050s.length >= 2, - `Expected at least 2 BSK-0050 diagnostics, got ${w0050s.length}` - ); - - // Execute fixFile — should fix both in one action. - await vscode.commands.executeCommand('basilisk.fixFile'); - - // Both diagnostics should clear. - const cleared = await waitForDiagnosticsCleared(uri, DIAGNOSTIC_TIMEOUT_MS); - const remainingW0050 = filterW0050(cleared); - assert.strictEqual( - remainingW0050.length, - 0, - `Expected all W0050 diagnostics to clear after fixFile, ` + - `but ${remainingW0050.length} remain` - ); - }); - - // ---------------------------------------------------------------- - // 4. fixFile on clean file is a no-op - // ---------------------------------------------------------------- - test('fixFile on clean file is a no-op', async function () { - this.timeout(DIAGNOSTIC_TIMEOUT_MS + SERVER_START_WAIT_MS); - - // Fully typed file — nothing to fix. - const { doc, uri } = await openPythonFile( - tmpDir, - 'test_fix_noop.py', - 'def clean(x: int) -> int:\n return x\n' - ); - - // Wait for the server to process (no diagnostics expected). - await waitForDiagnosticsCleared(uri, NO_DIAGNOSTIC_WAIT_MS); - - const before = doc.getText(); - - // Execute fixFile — should not modify the document. - await vscode.commands.executeCommand('basilisk.fixFile'); - - // Brief wait for any edits to land. - await delay(COMMAND_WAIT_MS); - - const after = doc.getText(); - assert.strictEqual( - after, - before, - 'fixFile should not modify a file with no fixable diagnostics' - ); - }); - - // ---------------------------------------------------------------- - // 5. fixFile with mixed fixable and unfixable diagnostics - // ---------------------------------------------------------------- - test('fixFile fixes what it can and leaves unfixable diagnostics', async function () { - this.timeout(DIAGNOSTIC_TIMEOUT_MS * 2 + SERVER_START_WAIT_MS); - - // x: int = 42 produces W0050 (fixable — redundant annotation). - // def broken(y) produces E0001+E0002 (fixable — missing annotations). - // After fixFile, both should be fixed. - const { uri } = await openPythonFile( - tmpDir, - 'test_fix_mixed.py', - 'x: int = 42\n\ndef broken(y):\n return y\n' - ); - - const diagnostics = await waitForDiagnostics(uri, DIAGNOSTIC_TIMEOUT_MS); - assert.ok( - diagnostics.length >= 2, - `Expected at least 2 diagnostics (W0050 + E0001/E0002), got ${diagnostics.length}` - ); - - // Execute fixFile. - await vscode.commands.executeCommand('basilisk.fixFile'); - - // Wait for edits to apply and re-diagnosis to happen. - await delay(RECHECK_WAIT_MS); - - // The W0050 should be gone — the redundant annotation was removed. - const after = vscode.languages.getDiagnostics(uri); - const remainingW0050 = filterW0050(after); - assert.strictEqual( - remainingW0050.length, - 0, - `Expected W0050 to be fixed, but ${remainingW0050.length} remain` - ); - }); -}); diff --git a/vscode-extension/src/test/suite/lsp-goto.test.ts b/vscode-extension/src/test/suite/lsp-goto.test.ts deleted file mode 100644 index 7ccc54c3d..000000000 --- a/vscode-extension/src/test/suite/lsp-goto.test.ts +++ /dev/null @@ -1,193 +0,0 @@ -// Implements [LSPARCH-FEATURES-DEFINITION]. See docs/specs/LSP-ARCHITECTURE-SPEC.md#LSPARCH-FEATURES-DEFINITION -/** - * GOTO HAMMER — exhaustive go-over of go-to-definition / -declaration / - * -type-definition through VS Code's real command API against the live - * Basilisk LSP. - * - * Why this suite exists: go-to-definition was reported as INTERMITTENT in the - * shipped extension (jumps for some symbol kinds, silently no-ops for others — - * see #200). The old single happy-path goto check (one function call) cannot - * catch that. So this suite hammers EVERY navigable symbol kind — including - * cross-file targets — asserting both that a location *comes back* and that it - * lands on the EXACT file + line of the real definition (lines derived from - * the fixture via `locate`, so they cannot drift). - * - * Prerequisites: - * - The `basilisk` binary must be built: `cargo build -p basilisk-cli` - * - The binary must be on PATH / discoverable or the suite fails hard. - */ - -import * as assert from 'assert'; -import type * as vscode from 'vscode'; -import { - SUITE_SETUP_TIMEOUT_MS, - DIAGNOSTIC_TIMEOUT_MS, - closeAllEditors, - getNavLocations, - locate, - openPythonFile, - setupLspTestSuite, - teardownLspTestSuite, -} from './test-helpers'; -import { HELPER_FILENAME, HELPER_SOURCE, SUBJECT_SOURCE } from './nav-fixtures'; - -/** Per-test timeout: the poll budget plus headroom for a cold index. */ -const GOTO_TEST_TIMEOUT_MS = DIAGNOSTIC_TIMEOUT_MS + 5_000; - -/** The exact file + line a definition is expected to land on. */ -interface ExpectedTarget { - readonly uri: vscode.Uri; - readonly line: number; -} - -/** Assert exactly one resolved location landing on the expected file + line. */ -function assertLands( - label: string, - locations: readonly vscode.Location[], - expected: ExpectedTarget, -): void { - assert.ok( - locations.length > 0, - `NO DEFINITION for ${label} — provider returned nothing (intermittent-goto regression, see #200)` - ); - const target = locations[0]; - assert.strictEqual( - target.uri.toString(), - expected.uri.toString(), - `Definition for ${label} should resolve in ${expected.uri.fsPath}, got ${target.uri.fsPath}` - ); - assert.strictEqual( - target.range.start.line, - expected.line, - `Definition for ${label} should land on line ${expected.line}, got ${target.range.start.line}` - ); -} - -suite('LSP Goto Hammer', () => { - let tmpDir: string; - let uri: vscode.Uri; - let helperUri: vscode.Uri; - - suiteSetup(async function () { - this.timeout(SUITE_SETUP_TIMEOUT_MS); - const setup = await setupLspTestSuite('basilisk-goto-test-'); - tmpDir = setup.tmpDir; - // Helper first so the subject file's import resolves cross-file. - ({ uri: helperUri } = await openPythonFile(tmpDir, HELPER_FILENAME, HELPER_SOURCE)); - ({ uri } = await openPythonFile(tmpDir, 'goto_subject.py', SUBJECT_SOURCE)); - }); - - suiteTeardown(async () => { - await closeAllEditors(); - teardownLspTestSuite(tmpDir); - }); - - // ── Same-file definitions ──────────────────────────────────────── - - test('goto-def: function call resolves to its def', async function () { - this.timeout(GOTO_TEST_TIMEOUT_MS); - const locations = await getNavLocations( - 'vscode.executeDefinitionProvider', uri, locate(SUBJECT_SOURCE, 'calculate', 1) - ); - assertLands('function call calculate', locations, { uri, line: locate(SUBJECT_SOURCE, 'calculate', 0).line }); - }); - - test('goto-def: class annotation resolves to its class def', async function () { - this.timeout(GOTO_TEST_TIMEOUT_MS); - const locations = await getNavLocations( - 'vscode.executeDefinitionProvider', uri, locate(SUBJECT_SOURCE, 'Widget', 1) - ); - assertLands('class annotation Widget', locations, { uri, line: locate(SUBJECT_SOURCE, 'Widget', 0).line }); - }); - - test('goto-def: class constructor resolves to its class def', async function () { - this.timeout(GOTO_TEST_TIMEOUT_MS); - const locations = await getNavLocations( - 'vscode.executeDefinitionProvider', uri, locate(SUBJECT_SOURCE, 'Widget', 2) - ); - assertLands('class constructor Widget', locations, { uri, line: locate(SUBJECT_SOURCE, 'Widget', 0).line }); - }); - - test('goto-def: local variable use resolves to its assignment', async function () { - this.timeout(GOTO_TEST_TIMEOUT_MS); - const locations = await getNavLocations( - 'vscode.executeDefinitionProvider', uri, locate(SUBJECT_SOURCE, 'squared', 1) - ); - assertLands('local variable squared', locations, { uri, line: locate(SUBJECT_SOURCE, 'squared', 0).line }); - }); - - test('goto-def: parameter use resolves to the parameter', async function () { - this.timeout(GOTO_TEST_TIMEOUT_MS); - // occurrence 2 = first use in `squared = operand * operand` (occ 1 is the docstring word). - const locations = await getNavLocations( - 'vscode.executeDefinitionProvider', uri, locate(SUBJECT_SOURCE, 'operand', 2) - ); - assertLands('parameter operand', locations, { uri, line: locate(SUBJECT_SOURCE, 'operand', 0).line }); - }); - - test('goto-def: module constant use inside a function resolves to its definition', async function () { - this.timeout(GOTO_TEST_TIMEOUT_MS); - // occurrence 1 = the `PI` use in `return PI * scale_factor` inside - // scaled_area (occurrence 0 is the module-level definition). Field - // report: cmd+click on this use did not navigate while other - // variables did. - const locations = await getNavLocations( - 'vscode.executeDefinitionProvider', uri, locate(SUBJECT_SOURCE, 'PI', 1) - ); - assertLands('module constant PI use', locations, { uri, line: locate(SUBJECT_SOURCE, 'PI', 0).line }); - }); - - test('goto-def: attribute use resolves to the attribute def', async function () { - this.timeout(GOTO_TEST_TIMEOUT_MS); - const locations = await getNavLocations( - 'vscode.executeDefinitionProvider', uri, locate(SUBJECT_SOURCE, 'width', 1) - ); - assertLands('attribute width', locations, { uri, line: locate(SUBJECT_SOURCE, 'width', 0).line }); - }); - - // ── Cross-file definitions ─────────────────────────────────────── - - test('goto-def: imported function usage resolves cross-file', async function () { - this.timeout(GOTO_TEST_TIMEOUT_MS); - const locations = await getNavLocations( - 'vscode.executeDefinitionProvider', uri, locate(SUBJECT_SOURCE, 'helper_fn', 1) - ); - assertLands('imported helper_fn', locations, { uri: helperUri, line: locate(HELPER_SOURCE, 'helper_fn', 0).line }); - }); - - test('goto-def: imported class usage resolves cross-file', async function () { - this.timeout(GOTO_TEST_TIMEOUT_MS); - const locations = await getNavLocations( - 'vscode.executeDefinitionProvider', uri, locate(SUBJECT_SOURCE, 'HelperClass', 1) - ); - assertLands('imported HelperClass', locations, { uri: helperUri, line: locate(HELPER_SOURCE, 'HelperClass', 0).line }); - }); - - // ── Declaration provider ───────────────────────────────────────── - - test('goto-declaration: function call resolves to its def', async function () { - this.timeout(GOTO_TEST_TIMEOUT_MS); - const locations = await getNavLocations( - 'vscode.executeDeclarationProvider', uri, locate(SUBJECT_SOURCE, 'calculate', 1) - ); - assertLands('declaration of calculate', locations, { uri, line: locate(SUBJECT_SOURCE, 'calculate', 0).line }); - }); - - // ── Type-definition provider ───────────────────────────────────── - - test('goto-type-def: variable resolves to its class type', async function () { - this.timeout(GOTO_TEST_TIMEOUT_MS); - const locations = await getNavLocations( - 'vscode.executeTypeDefinitionProvider', uri, locate(SUBJECT_SOURCE, 'gadget', 0) - ); - assertLands('type of gadget', locations, { uri, line: locate(SUBJECT_SOURCE, 'Widget', 0).line }); - }); - - test('goto-type-def: variable resolves to cross-file class type', async function () { - this.timeout(GOTO_TEST_TIMEOUT_MS); - const locations = await getNavLocations( - 'vscode.executeTypeDefinitionProvider', uri, locate(SUBJECT_SOURCE, 'instance', 0) - ); - assertLands('type of instance', locations, { uri: helperUri, line: locate(HELPER_SOURCE, 'HelperClass', 0).line }); - }); -}); diff --git a/vscode-extension/src/test/suite/lsp-hover.test.ts b/vscode-extension/src/test/suite/lsp-hover.test.ts deleted file mode 100644 index eb1b09de0..000000000 --- a/vscode-extension/src/test/suite/lsp-hover.test.ts +++ /dev/null @@ -1,156 +0,0 @@ -// Implements [LSPARCH-FEATURES-HOVER]. See docs/specs/LSP-ARCHITECTURE-SPEC.md#LSPARCH-FEATURES-HOVER -/** - * HOVER HAMMER — exhaustive go-over of textDocument/hover through VS Code's - * real command API against the live Basilisk LSP. - * - * Why this suite exists: hover was reported as INTERMITTENT in the shipped - * extension (works for some symbol kinds, silently returns nothing for - * others — e.g. module-level constants, see #199 / #200). A single happy-path - * hover check (the old lsp-navigation/lsp-integration tests) cannot catch - * that. So this suite hammers EVERY symbol kind a user can hover, asserting - * both that a hover *appears* and that it carries the right content. - * - * Prerequisites: - * - The `basilisk` binary must be built: `cargo build -p basilisk-cli` - * - The binary must be on PATH / discoverable or the suite fails hard. - */ - -import * as assert from 'assert'; -import type * as vscode from 'vscode'; -import { - SUITE_SETUP_TIMEOUT_MS, - DIAGNOSTIC_TIMEOUT_MS, - closeAllEditors, - getHoverText, - locate, - openPythonFile, - setupLspTestSuite, - teardownLspTestSuite, -} from './test-helpers'; -import { HELPER_FILENAME, HELPER_SOURCE, SUBJECT_SOURCE } from './nav-fixtures'; - -/** Per-test timeout: the poll budget plus headroom for a cold index. */ -const HOVER_TEST_TIMEOUT_MS = DIAGNOSTIC_TIMEOUT_MS + 5_000; - -/** Assert a hover both appeared and carries every expected fragment. */ -function assertHover(label: string, text: string, fragments: readonly string[]): void { - assert.notStrictEqual( - text, '', - `HOVER MISSING for ${label} — provider returned no content (intermittent-hover regression, see #200)` - ); - for (const fragment of fragments) { - assert.ok( - text.includes(fragment), - `Hover for ${label} should contain "${fragment}", got:\n${text}` - ); - } -} - -suite('LSP Hover Hammer', () => { - let tmpDir: string; - let uri: vscode.Uri; - - suiteSetup(async function () { - this.timeout(SUITE_SETUP_TIMEOUT_MS); - const setup = await setupLspTestSuite('basilisk-hover-test-'); - tmpDir = setup.tmpDir; - // Helper first so the subject file's import resolves cross-file. - await openPythonFile(tmpDir, HELPER_FILENAME, HELPER_SOURCE); - ({ uri } = await openPythonFile(tmpDir, 'hover_subject.py', SUBJECT_SOURCE)); - }); - - suiteTeardown(async () => { - await closeAllEditors(); - teardownLspTestSuite(tmpDir); - }); - - test('hover: module-level Final constant (PI)', async function () { - this.timeout(HOVER_TEST_TIMEOUT_MS); - const text = await getHoverText(uri, locate(SUBJECT_SOURCE, 'PI')); - assertHover('module constant PI', text, ['PI', 'Final']); - }); - - test('hover: module constant use inside a function (PI in scaled_area)', async function () { - this.timeout(HOVER_TEST_TIMEOUT_MS); - const text = await getHoverText(uri, locate(SUBJECT_SOURCE, 'PI', 1)); - assertHover('module constant PI use', text, ['PI', 'Final']); - }); - - test('hover: module-level plain variable (counter)', async function () { - this.timeout(HOVER_TEST_TIMEOUT_MS); - const text = await getHoverText(uri, locate(SUBJECT_SOURCE, 'counter')); - assertHover('module variable counter', text, ['counter']); - }); - - test('hover: function definition name carries docstring', async function () { - this.timeout(HOVER_TEST_TIMEOUT_MS); - const text = await getHoverText(uri, locate(SUBJECT_SOURCE, 'calculate', 0)); - assertHover('function def calculate', text, ['calculate', 'Compute the square of operand']); - }); - - test('hover: function reference (call site) carries signature', async function () { - this.timeout(HOVER_TEST_TIMEOUT_MS); - const text = await getHoverText(uri, locate(SUBJECT_SOURCE, 'calculate', 1)); - assertHover('function call calculate', text, ['calculate', 'int']); - }); - - test('hover: function parameter (operand)', async function () { - this.timeout(HOVER_TEST_TIMEOUT_MS); - const text = await getHoverText(uri, locate(SUBJECT_SOURCE, 'operand', 0)); - assertHover('parameter operand', text, ['operand', 'int']); - }); - - test('hover: local variable inside a function (squared)', async function () { - this.timeout(HOVER_TEST_TIMEOUT_MS); - const text = await getHoverText(uri, locate(SUBJECT_SOURCE, 'squared', 0)); - assertHover('local variable squared', text, ['squared']); - }); - - test('hover: class definition name carries docstring', async function () { - this.timeout(HOVER_TEST_TIMEOUT_MS); - const text = await getHoverText(uri, locate(SUBJECT_SOURCE, 'Widget', 0)); - assertHover('class def Widget', text, ['Widget', 'A configurable widget']); - }); - - test('hover: class reference (annotation site)', async function () { - this.timeout(HOVER_TEST_TIMEOUT_MS); - const text = await getHoverText(uri, locate(SUBJECT_SOURCE, 'Widget', 1)); - assertHover('class annotation Widget', text, ['Widget']); - }); - - test('hover: class reference (constructor call)', async function () { - this.timeout(HOVER_TEST_TIMEOUT_MS); - const text = await getHoverText(uri, locate(SUBJECT_SOURCE, 'Widget', 2)); - assertHover('class constructor Widget', text, ['Widget']); - }); - - test('hover: class attribute (width)', async function () { - this.timeout(HOVER_TEST_TIMEOUT_MS); - const text = await getHoverText(uri, locate(SUBJECT_SOURCE, 'width', 0)); - assertHover('attribute width', text, ['width', 'int']); - }); - - test('hover: method definition (resize) carries docstring', async function () { - this.timeout(HOVER_TEST_TIMEOUT_MS); - const text = await getHoverText(uri, locate(SUBJECT_SOURCE, 'resize', 0)); - assertHover('method def resize', text, ['resize', 'Resize the widget by a factor']); - }); - - test('hover: method parameter (factor)', async function () { - this.timeout(HOVER_TEST_TIMEOUT_MS); - const text = await getHoverText(uri, locate(SUBJECT_SOURCE, 'factor', 0)); - assertHover('method parameter factor', text, ['factor', 'int']); - }); - - test('hover: imported symbol usage (helper_fn call)', async function () { - this.timeout(HOVER_TEST_TIMEOUT_MS); - const text = await getHoverText(uri, locate(SUBJECT_SOURCE, 'helper_fn', 1)); - assertHover('imported helper_fn usage', text, ['helper_fn']); - }); - - test('hover: import statement line carries resolution info', async function () { - this.timeout(HOVER_TEST_TIMEOUT_MS); - const text = await getHoverText(uri, locate(SUBJECT_SOURCE, 'nav_helper', 0)); - assertHover('import of nav_helper', text, ['nav_helper']); - }); -}); diff --git a/vscode-extension/src/test/suite/lsp-inlay-types.test.ts b/vscode-extension/src/test/suite/lsp-inlay-types.test.ts deleted file mode 100644 index 6cf87b1dc..000000000 --- a/vscode-extension/src/test/suite/lsp-inlay-types.test.ts +++ /dev/null @@ -1,392 +0,0 @@ -// Tests for [LSPARCH-FEATURES-INLAYHINTS]. -// See docs/specs/LSP-ARCHITECTURE-SPEC.md#LSPARCH-FEATURES-INLAYHINTS and the -// implementation in crates/basilisk-lsp/src/inlay_hints.rs. -/** - * INLINE TYPE VISIBILITY — end-to-end guardrail suite. - * - * Basilisk surfaces inferred types INLINE (the `int` / `bool` / `str` - * "bubbles" a user sees next to a name) via `textDocument/inlayHint`. The whole - * point is that a developer reads the type of a symbol WITHOUT hovering the - * mouse over it. This suite pins that behaviour down hard: it drives the real - * VS Code inlay-hint provider (which round-trips through the running LSP) across - * many variables and every inferable builtin type, and asserts the exact inline - * type label appears on the exact line of each symbol. - * - * A regression that stops inline types from rendering — or renders the wrong - * type, or double-renders over an already-annotated symbol — fails here. - * - * Prerequisites: - * - The `basilisk` binary must be built: `cargo build -p basilisk-cli` - */ - -import * as assert from 'assert'; -import * as vscode from 'vscode'; -import * as path from 'path'; -import * as fs from 'fs'; -import * as os from 'os'; -import { - closeAllEditors, - DIAGNOSTIC_TIMEOUT_MS, - getInlayHints, - inlayLabelsOnLine, - locate, - normalizedInlayLabel, - openPythonFile, - removeTestDir, - replaceDocumentContent, - SUITE_SETUP_TIMEOUT_MS, - waitForInlayLabel, - waitForLspReady, -} from './test-helpers'; - -/** Additional time (ms) added to DIAGNOSTIC_TIMEOUT_MS for individual test timeouts. */ -const EXTRA_TEST_TIMEOUT_MS = 10_000; - -/** A `[variable-name, expected normalised inline type]` expectation. */ -type InlineTypeCase = [string, string]; - -/** Assert an inline `:type` hint (normalised) sits on the variable's own line. */ -function assertInlineTypeOnVar( - hints: readonly vscode.InlayHint[], - source: string, - testCase: InlineTypeCase, -): void { - const [varName, expected] = testCase; - const line = locate(source, varName).line; - const labels = inlayLabelsOnLine(hints, line); - assert.ok( - labels.includes(expected), - `Expected inline type "${expected}" on "${varName}" (line ${line}) ` + - `without hovering — got ${JSON.stringify(labels)}`, - ); -} - -/** Assert NO inline type hint is rendered on `varName`'s line (e.g. already annotated). */ -function assertNoInlineTypeOnVar( - hints: readonly vscode.InlayHint[], - source: string, - varName: string, -): void { - const line = locate(source, varName).line; - const typeLabels = inlayLabelsOnLine(hints, line).filter((l) => l.startsWith(':')); - assert.deepStrictEqual( - typeLabels, - [], - `Expected NO inline type hint on already-annotated "${varName}" (line ${line}) ` + - `— got ${JSON.stringify(typeLabels)}`, - ); -} - -// eslint-disable-next-line max-lines-per-function -- suite callback contains all tests -suite('Inline Type Visibility (inlay hints)', () => { - let tmpDir: string; - - suiteSetup(async function () { - this.timeout(SUITE_SETUP_TIMEOUT_MS); - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'basilisk-inlay-types-')); - await waitForLspReady(); - await closeAllEditors(); - }); - - suiteTeardown(async () => { - await closeAllEditors(); - if (tmpDir !== undefined && tmpDir !== '' && fs.existsSync(tmpDir)) { - removeTestDir(tmpDir); - } - }); - - teardown(async () => { - await closeAllEditors(); - }); - - // ---------------------------------------------------------------- - // 1. Every inferable builtin type is shown inline on its variable. - // ---------------------------------------------------------------- - test('module-level variables show an inline type for every builtin kind', async function () { - this.timeout(DIAGNOSTIC_TIMEOUT_MS + EXTRA_TEST_TIMEOUT_MS); - - const source = [ - 'an_int = 42', - 'a_float = 3.14', - 'a_str = "hello"', - 'yes_flag = True', - 'no_flag = False', - 'some_bytes = b"data"', - 'nothing = None', - 'numbers = [1, 2, 3]', - 'mapping = {"k": "v"}', - 'uniques = {1, 2, 3}', - 'pair = (1, 2)', - '', - ].join('\n'); - - const { doc } = await openPythonFile(tmpDir, 'inlay_all_types.py', source); - const cases: InlineTypeCase[] = [ - ['an_int', ':int'], - ['a_float', ':float'], - ['a_str', ':str'], - ['yes_flag', ':bool'], - ['no_flag', ':bool'], - ['some_bytes', ':bytes'], - ['nothing', ':None'], - // Container literals surface their inferred generic args (#290). - ['numbers', ':list[int]'], - ['mapping', ':dict[str,str]'], - ['uniques', ':set[int]'], - ['pair', ':tuple[int,int]'], - ]; - - const hints = await getInlayHints(doc, cases.length); - assert.ok( - hints.length >= cases.length, - `Expected at least ${cases.length} inline type hints (one per variable), ` + - `got ${hints.length}`, - ); - cases.forEach((testCase) => assertInlineTypeOnVar(hints, source, testCase)); - }); - - // ---------------------------------------------------------------- - // 2. Inline type hints are rendered as TYPE hints (the "bubbles"), - // never as parameter hints. - // ---------------------------------------------------------------- - test('inline type hints carry InlayHintKind.Type', async function () { - this.timeout(DIAGNOSTIC_TIMEOUT_MS + EXTRA_TEST_TIMEOUT_MS); - - const source = [ - 'width = 1920', - 'height = 1080', - 'title = "screen"', - 'visible = True', - '', - ].join('\n'); - - const { doc } = await openPythonFile(tmpDir, 'inlay_kind.py', source); - const hints = await getInlayHints(doc, 4); - - const typeHints = hints.filter((h) => normalizedInlayLabel(h).startsWith(':')); - assert.ok( - typeHints.length >= 4, - `Expected at least 4 inline type hints, got ${typeHints.length}`, - ); - typeHints.forEach((h) => - assert.strictEqual( - h.kind, - vscode.InlayHintKind.Type, - `Inline type hint ${JSON.stringify(normalizedInlayLabel(h))} must be ` + - `InlayHintKind.Type, was ${String(h.kind)}`, - ), - ); - }); - - // ---------------------------------------------------------------- - // 3. Function-local unannotated variables also show inline types. - // ---------------------------------------------------------------- - test('function-local variables show inline types', async function () { - this.timeout(DIAGNOSTIC_TIMEOUT_MS + EXTRA_TEST_TIMEOUT_MS); - - const source = [ - 'def compute():', - ' total = 0', - ' label = "sum"', - ' ratio = 2.5', - ' active = True', - ' blob = b"x"', - '', - ].join('\n'); - - const { doc } = await openPythonFile(tmpDir, 'inlay_locals.py', source); - const hints = await getInlayHints(doc, 5); - - assertInlineTypeOnVar(hints, source, ['total', ':int']); - assertInlineTypeOnVar(hints, source, ['label', ':str']); - assertInlineTypeOnVar(hints, source, ['ratio', ':float']); - assertInlineTypeOnVar(hints, source, ['active', ':bool']); - assertInlineTypeOnVar(hints, source, ['blob', ':bytes']); - }); - - // ---------------------------------------------------------------- - // 4. The real screenshot scenario: annotated symbols keep their - // source type (no duplicate hint); the gaps get filled inline. - // ---------------------------------------------------------------- - test('annotated symbols are not double-typed; unannotated neighbours are filled', async function () { - this.timeout(DIAGNOSTIC_TIMEOUT_MS + EXTRA_TEST_TIMEOUT_MS); - - const source = [ - 'timeout: int = 100', - 'one_flag: bool = True', - 'other_flag: bool = False', - 'retries = 5', - 'label = "ready"', - '', - ].join('\n'); - - const { doc } = await openPythonFile(tmpDir, 'inlay_mixed.py', source); - const hints = await getInlayHints(doc, 2); - - // Explicitly-annotated symbols already show their type in source — no hint. - assertNoInlineTypeOnVar(hints, source, 'timeout'); - assertNoInlineTypeOnVar(hints, source, 'one_flag'); - assertNoInlineTypeOnVar(hints, source, 'other_flag'); - - // The unannotated neighbours DO get an inline type. - assertInlineTypeOnVar(hints, source, ['retries', ':int']); - assertInlineTypeOnVar(hints, source, ['label', ':str']); - }); - - // ---------------------------------------------------------------- - // 5. Functions without a return annotation show an inline "-> type". - // ---------------------------------------------------------------- - test('functions show an inline return type without hovering', async function () { - this.timeout(DIAGNOSTIC_TIMEOUT_MS + EXTRA_TEST_TIMEOUT_MS); - - const source = [ - 'def get_count():', - ' return 42', - '', - 'def get_name():', - ' return "hello"', - '', - 'def do_nothing():', - ' pass', - '', - ].join('\n'); - - const { doc } = await openPythonFile(tmpDir, 'inlay_returns.py', source); - const hints = await getInlayHints(doc, 3); - - function returnLabelsOn(needle: string): string[] { - return inlayLabelsOnLine(hints, locate(source, needle).line); - } - - assert.ok( - returnLabelsOn('def get_count').includes('->int'), - `Expected inline "-> int" on get_count — got ${JSON.stringify(returnLabelsOn('def get_count'))}`, - ); - assert.ok( - returnLabelsOn('def get_name').includes('->str'), - `Expected inline "-> str" on get_name — got ${JSON.stringify(returnLabelsOn('def get_name'))}`, - ); - assert.ok( - returnLabelsOn('def do_nothing').includes('->None'), - `Expected inline "-> None" on do_nothing — got ${JSON.stringify(returnLabelsOn('def do_nothing'))}`, - ); - }); - - // ---------------------------------------------------------------- - // 6. Call sites show which parameter each argument binds to, inline. - // ---------------------------------------------------------------- - test('call sites show inline parameter-name hints', async function () { - this.timeout(DIAGNOSTIC_TIMEOUT_MS + EXTRA_TEST_TIMEOUT_MS); - - const source = [ - 'def greet(name, count):', - ' return count', - '', - 'greet("bob", 3)', - '', - ].join('\n'); - - const { doc } = await openPythonFile(tmpDir, 'inlay_params.py', source); - const hints = await getInlayHints(doc, 2); - - const callLine = locate(source, 'greet("bob"').line; - const callLabels = inlayLabelsOnLine(hints, callLine); - assert.ok( - callLabels.includes('name='), - `Expected inline "name=" hint at the call site — got ${JSON.stringify(callLabels)}`, - ); - assert.ok( - callLabels.includes('count='), - `Expected inline "count=" hint at the call site — got ${JSON.stringify(callLabels)}`, - ); - - const paramHints = hints.filter((h) => normalizedInlayLabel(h).endsWith('=')); - assert.ok(paramHints.length >= 2, `Expected at least 2 parameter-name hints, got ${paramHints.length}`); - paramHints.forEach((h) => - assert.strictEqual( - h.kind, - vscode.InlayHintKind.Parameter, - `Parameter-name hint ${JSON.stringify(normalizedInlayLabel(h))} must be ` + - `InlayHintKind.Parameter, was ${String(h.kind)}`, - ), - ); - }); - - // ---------------------------------------------------------------- - // 7. Inline types are LIVE — they follow the value as it changes, - // never showing a stale type. - // ---------------------------------------------------------------- - test('inline type stays correct as the value changes', async function () { - this.timeout((DIAGNOSTIC_TIMEOUT_MS + EXTRA_TEST_TIMEOUT_MS) * 2); - - const { doc } = await openPythonFile(tmpDir, 'inlay_live.py', 'value = 1\n'); - - const initial = await waitForInlayLabel({ doc, line: 0, label: ':int' }); - assert.ok( - inlayLabelsOnLine(initial, 0).includes(':int'), - `Expected inline ":int" for an int literal — got ${JSON.stringify(inlayLabelsOnLine(initial, 0))}`, - ); - - assert.ok(await replaceDocumentContent(doc, 'value = "text"\n'), 'edit to str should apply'); - const asStr = await waitForInlayLabel({ doc, line: 0, label: ':str' }); - assert.ok( - inlayLabelsOnLine(asStr, 0).includes(':str'), - `Expected inline ":str" after reassigning to a string — got ${JSON.stringify(inlayLabelsOnLine(asStr, 0))}`, - ); - - assert.ok(await replaceDocumentContent(doc, 'value = [1, 2, 3]\n'), 'edit to list should apply'); - const asList = await waitForInlayLabel({ doc, line: 0, label: ':list[int]' }); - assert.ok( - inlayLabelsOnLine(asList, 0).includes(':list[int]'), - `Expected inline ":list[int]" after reassigning to a list — got ${JSON.stringify(inlayLabelsOnLine(asList, 0))}`, - ); - }); - - // ---------------------------------------------------------------- - // 8. A dense module surfaces an inline type for a LOT of variables. - // ---------------------------------------------------------------- - test('a dense module surfaces an inline type for many variables at once', async function () { - this.timeout(DIAGNOSTIC_TIMEOUT_MS + EXTRA_TEST_TIMEOUT_MS); - - const source = [ - 'port = 8080', - 'host = "localhost"', - 'debug = True', - 'quiet = False', - 'threshold = 0.75', - 'payload = b"\\x00"', - 'tags = ["a", "b"]', - 'headers = {"accept": "json"}', - 'seen = {1, 2}', - 'coords = (0, 0)', - 'placeholder = None', - 'retries = 3', - '', - ].join('\n'); - - const { doc } = await openPythonFile(tmpDir, 'inlay_dense.py', source); - const expected: InlineTypeCase[] = [ - ['port', ':int'], - ['host', ':str'], - ['debug', ':bool'], - ['quiet', ':bool'], - ['threshold', ':float'], - ['payload', ':bytes'], - // Container literals surface their inferred generic args (#290). - ['tags', ':list[str]'], - ['headers', ':dict[str,str]'], - ['seen', ':set[int]'], - ['coords', ':tuple[int,int]'], - ['placeholder', ':None'], - ['retries', ':int'], - ]; - - const hints = await getInlayHints(doc, expected.length); - const typeHintCount = hints.filter((h) => normalizedInlayLabel(h).startsWith(':')).length; - assert.ok( - typeHintCount >= expected.length, - `Expected inline types for all ${expected.length} variables, got ${typeHintCount}`, - ); - expected.forEach((testCase) => assertInlineTypeOnVar(hints, source, testCase)); - }); -}); diff --git a/vscode-extension/src/test/suite/lsp-integration.test.ts b/vscode-extension/src/test/suite/lsp-integration.test.ts deleted file mode 100644 index 8356664d8..000000000 --- a/vscode-extension/src/test/suite/lsp-integration.test.ts +++ /dev/null @@ -1,365 +0,0 @@ -// Implements [VSIX-LSP-CLIENT-CONFIGURATION]. See docs/specs/VSIX-SPEC.md#VSIX-LSP-CLIENT-CONFIGURATION -/** - * LSP Integration Tests for the Basilisk VS Code Extension. - * - * These tests exercise REAL LSP functionality by opening Python files, - * waiting for the language server to respond, and asserting on actual - * diagnostics, hover info, completions, and document symbols. - * - * Prerequisites: - * - The `basilisk` binary must be built: `cargo build -p basilisk-cli` - * - The binary must be on PATH or the test will fail hard - */ - -import * as assert from 'assert'; -import * as vscode from 'vscode'; -import * as fs from 'fs'; -import * as os from 'os'; -import * as path from 'path'; -import { - closeAllEditors, - DIAGNOSTIC_TIMEOUT_MS, - extractHoverText, - filterBasiliskDiagnostics, - findBasiliskBinary, - flattenSymbolNames, - NO_DIAGNOSTIC_WAIT_MS, - openPythonFile, - pollUntilResult, - removeTestDir, - SUITE_SETUP_TIMEOUT_MS, - waitForDiagnostics, - waitForDiagnosticsCleared, - waitForLspReady, -} from './test-helpers'; -import { captureScreenshot } from './screenshot'; - -/** Extra buffer (ms) added to test-level timeouts beyond the core wait. */ -const TIMEOUT_BUFFER_MS = 5_000; - -/** Large buffer (ms) for tests that do multiple diagnostic waits. */ -const LARGE_TIMEOUT_BUFFER_MS = 10_000; - -// ── Test-specific line/column positions ────────────────────────────── - -/** Line number where the hover target ("result = helper(42)") appears. */ -const HOVER_TARGET_LINE = 3; - -/** Column of "helper" in the hover target line. */ -const HOVER_TARGET_COLUMN = 10; - -/** Line number of the completion trigger ("my_\n"). */ -const COMPLETION_TRIGGER_LINE = 3; - -/** Column of the completion trigger. */ -const COMPLETION_TRIGGER_COLUMN = 3; - -/** Max completion items to display in assertion messages. */ -const COMPLETION_PREVIEW_LIMIT = 10; - -// eslint-disable-next-line max-lines-per-function -suite('LSP Integration Tests', () => { - let tmpDir: string; - let _basiliskBinary: string | undefined; - - suiteSetup(async function () { - this.timeout(SUITE_SETUP_TIMEOUT_MS); - - _basiliskBinary = findBasiliskBinary(); - if (_basiliskBinary === undefined) { - throw new Error( - 'Basilisk binary not found. Build with: cargo build -p basilisk-cli' - ); - } - - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'basilisk-lsp-test-')); - - await waitForLspReady(); - await vscode.commands.executeCommand('workbench.action.closeAllEditors'); - }); - - suiteTeardown(async () => { - await closeAllEditors(); - if (tmpDir !== undefined && tmpDir !== '' && fs.existsSync(tmpDir)) { - removeTestDir(tmpDir); - } - }); - - teardown(async () => { - await closeAllEditors(); - }); - - // ---------------------------------------------------------------- - // 1. Diagnostics appear on a Python file with type errors - // ---------------------------------------------------------------- - test('diagnostics appear for untyped function parameter', async function () { - this.timeout(DIAGNOSTIC_TIMEOUT_MS + TIMEOUT_BUFFER_MS); - - const { uri } = await openPythonFile( - tmpDir, - 'test_untyped.py', - 'def greet(name):\n return name\n' - ); - - const diagnostics = await waitForDiagnostics(uri, DIAGNOSTIC_TIMEOUT_MS); - - assert.ok( - diagnostics.length > 0, - 'Expected at least one diagnostic for an untyped function parameter' - ); - - // Verify the diagnostic is from Basilisk. - const basiliskDiags = filterBasiliskDiagnostics(diagnostics); - - assert.ok( - basiliskDiags.length > 0, - `Expected diagnostics from Basilisk. ` + - `Got: ${diagnostics.map((d) => `source=${d.source}, code=${JSON.stringify(d.code)}`).join('; ')}` - ); - - // Capture a picture of the editor with live Basilisk diagnostics into - // the gitignored .screenshots/ folder for local debugging. Best-effort: - // never fails the test, never uploaded as a CI artifact. - await captureScreenshot('diagnostics-untyped-parameter'); - }); - - // ---------------------------------------------------------------- - // 2. Diagnostics clear when the file is closed - // ---------------------------------------------------------------- - test('diagnostics clear when errors are fixed', async function () { - this.timeout(DIAGNOSTIC_TIMEOUT_MS * 2 + TIMEOUT_BUFFER_MS); - - const { doc, uri } = await openPythonFile( - tmpDir, - 'test_clear.py', - 'def broken(x):\n return x\n' - ); - - // Wait for diagnostics to appear first. - const diagsBefore = await waitForDiagnostics(uri, DIAGNOSTIC_TIMEOUT_MS); - assert.ok( - diagsBefore.length > 0, - 'Expected diagnostics for code with missing type annotations' - ); - - // Fix the code by adding type annotations. - const edit = new vscode.WorkspaceEdit(); - const fullRange = new vscode.Range( - new vscode.Position(0, 0), - new vscode.Position(doc.lineCount, 0) - ); - edit.replace(uri, fullRange, 'def broken(x: int) -> int:\n return x\n'); - const applied = await vscode.workspace.applyEdit(edit); - assert.ok(applied, 'Expected the edit to be applied'); - - // Wait for diagnostics to clear after the fix. - const diagsAfter = await waitForDiagnosticsCleared(uri, DIAGNOSTIC_TIMEOUT_MS); - - assert.strictEqual( - diagsAfter.length, - 0, - `Expected diagnostics to be cleared after fixing the code, ` + - `but found ${diagsAfter.length}` - ); - }); - - // ---------------------------------------------------------------- - // 3. No diagnostics for fully typed code - // ---------------------------------------------------------------- - test('no diagnostics for clean, fully typed code', async function () { - this.timeout(NO_DIAGNOSTIC_WAIT_MS + TIMEOUT_BUFFER_MS); - - const { uri } = await openPythonFile( - tmpDir, - 'test_clean.py', - 'def greet(name: str) -> str:\n return name\n' - ); - - // Wait for the server to process the file (no diagnostics expected). - await waitForDiagnosticsCleared(uri, NO_DIAGNOSTIC_WAIT_MS); - - const diagnostics = vscode.languages.getDiagnostics(uri); - const basiliskDiags = filterBasiliskDiagnostics(diagnostics); - - assert.strictEqual( - basiliskDiags.length, - 0, - `Expected zero Basilisk diagnostics for clean code, ` + - `but found ${basiliskDiags.length}: ${ - basiliskDiags.map((d) => d.message).join('; ')}` - ); - }); - - // ---------------------------------------------------------------- - // 4. Hover provides type information - // ---------------------------------------------------------------- - test('hover provides type information for a function', async function () { - this.timeout(DIAGNOSTIC_TIMEOUT_MS + TIMEOUT_BUFFER_MS); - - const { uri } = await openPythonFile( - tmpDir, - 'test_hover.py', - 'def helper(x: int) -> int:\n return x + 1\n\nresult = helper(42)\n' - ); - - // Poll until the server has indexed and returns hover results. - const position = new vscode.Position(HOVER_TARGET_LINE, HOVER_TARGET_COLUMN); - const hovers = await pollUntilResult({ - fn: async () => vscode.commands.executeCommand<vscode.Hover[]>( - 'vscode.executeHoverProvider', uri, position - ).then((r) => r, () => [] as vscode.Hover[]), - predicate: (r) => r !== null && r !== undefined && r.length > 0, - }); - - assert.ok(hovers !== undefined, 'Expected hover result to be defined'); - assert.ok( - hovers.length > 0, - 'Expected at least one hover result for the function call' - ); - - // Verify hover content contains something meaningful (function name or signature). - const combinedHover = extractHoverText(hovers); - assert.ok( - combinedHover.length > 0, - `Expected hover to contain text about the function, but got empty hover content` - ); - }); - - // ---------------------------------------------------------------- - // 5. Completions include local symbols - // ---------------------------------------------------------------- - test('completions include local function names', async function () { - this.timeout(DIAGNOSTIC_TIMEOUT_MS + TIMEOUT_BUFFER_MS); - - const { uri } = await openPythonFile( - tmpDir, - 'test_completion.py', - 'def my_helper_function(x: int) -> int:\n return x\n\nmy_\n' - ); - - // Poll until the server returns completions. - const position = new vscode.Position(COMPLETION_TRIGGER_LINE, COMPLETION_TRIGGER_COLUMN); - const completions = await pollUntilResult({ - fn: async () => vscode.commands.executeCommand<vscode.CompletionList>( - 'vscode.executeCompletionItemProvider', uri, position - ).then((r) => r, () => null), - predicate: (r) => r !== null && r !== undefined && r.items.length > 0, - }); - - assert.ok(completions !== null && completions !== undefined, 'Expected completion result to be defined'); - - const items = completions.items; - assert.ok( - items.length > 0, - 'Expected at least one completion item' - ); - - // Check if our function appears in the completions. - const hasHelper = items.some((item) => { - const label = typeof item.label === 'string' ? item.label : item.label.label; - return label.includes('my_helper_function'); - }); - - assert.ok( - hasHelper, - `Expected completions to include 'my_helper_function'. ` + - `Got: ${items.slice(0, COMPLETION_PREVIEW_LIMIT).map((i) => (typeof i.label === 'string' ? i.label : i.label.label)).join(', ')}` - ); - }); - - // ---------------------------------------------------------------- - // 6. Document symbols include classes and functions - // ---------------------------------------------------------------- - test('document symbols include class and function names', async function () { - this.timeout(DIAGNOSTIC_TIMEOUT_MS + TIMEOUT_BUFFER_MS); - - const { uri } = await openPythonFile( - tmpDir, - 'test_symbols.py', - [ - 'class MyClass:', - ' def method(self) -> None:', - ' pass', - '', - 'def standalone_function(x: int) -> int:', - ' return x', - '', - ].join('\n') - ); - - // Poll until the server returns document symbols. - const symbols = await pollUntilResult({ - fn: async () => vscode.commands.executeCommand<vscode.DocumentSymbol[]>( - 'vscode.executeDocumentSymbolProvider', uri - ).then((r) => r, () => [] as vscode.DocumentSymbol[]), - predicate: (r) => r !== null && r !== undefined && r.length > 0, - }); - - assert.ok(symbols !== undefined, 'Expected document symbols to be defined'); - assert.ok(symbols.length > 0, 'Expected at least one document symbol'); - - // Flatten symbols (classes may nest their methods). - const allNames = flattenSymbolNames(symbols); - - assert.ok( - allNames.includes('MyClass'), - `Expected symbols to include 'MyClass'. Got: ${allNames.join(', ')}` - ); - - assert.ok( - allNames.includes('standalone_function'), - `Expected symbols to include 'standalone_function'. Got: ${allNames.join(', ')}` - ); - }); - - // ---------------------------------------------------------------- - // 7. didChange updates diagnostics - // ---------------------------------------------------------------- - test('did_change updates diagnostics', async function () { - this.timeout(DIAGNOSTIC_TIMEOUT_MS * 2 + LARGE_TIMEOUT_BUFFER_MS); - - // Open a fully typed file — should produce zero Basilisk diagnostics. - const { doc, uri } = await openPythonFile( - tmpDir, - 'test_didchange.py', - 'def greet(name: str) -> str:\n return name\n' - ); - - // Wait for the server to process the clean file (no diagnostics expected). - await waitForDiagnosticsCleared(uri, NO_DIAGNOSTIC_WAIT_MS); - - const diagsBefore = vscode.languages.getDiagnostics(uri); - const basiliskBefore = filterBasiliskDiagnostics(diagsBefore); - assert.strictEqual( - basiliskBefore.length, - 0, - `Expected zero Basilisk diagnostics for clean code before edit, ` + - `but found ${basiliskBefore.length}` - ); - - // Apply an edit that removes the type annotation, introducing an error. - const edit = new vscode.WorkspaceEdit(); - const fullRange = new vscode.Range( - new vscode.Position(0, 0), - new vscode.Position(doc.lineCount, 0) - ); - edit.replace(uri, fullRange, 'def greet(name):\n return name\n'); - const applied = await vscode.workspace.applyEdit(edit); - assert.ok(applied, 'Expected the workspace edit to be applied successfully'); - - // Wait for diagnostics to appear after the change. - const diagsAfter = await waitForDiagnostics(uri, DIAGNOSTIC_TIMEOUT_MS); - - assert.ok( - diagsAfter.length > 0, - 'Expected at least one diagnostic after removing the type annotation' - ); - - const basiliskAfter = filterBasiliskDiagnostics(diagsAfter); - assert.ok( - basiliskAfter.length > 0, - `Expected Basilisk diagnostics after removing annotation. ` + - `Got: ${diagsAfter.map((d) => `source=${d.source}, code=${JSON.stringify(d.code)}`).join('; ')}` - ); - }); -}); diff --git a/vscode-extension/src/test/suite/lsp-lifecycle.test.ts b/vscode-extension/src/test/suite/lsp-lifecycle.test.ts deleted file mode 100644 index 1137c869d..000000000 --- a/vscode-extension/src/test/suite/lsp-lifecycle.test.ts +++ /dev/null @@ -1,343 +0,0 @@ -// Implements [VSIX-LSP-CLIENT-CONFIGURATION]. See docs/specs/VSIX-SPEC.md#VSIX-LSP-CLIENT-CONFIGURATION -/** - * LSP Lifecycle Tests for the Basilisk VS Code Extension. - * - * These tests exercise LSP lifecycle features: status bar presence, - * restart command, extension state management, the edit-diagnose-fix-clear - * cycle, and independent per-file diagnostics. - * - * Prerequisites: - * - The `basilisk` binary must be built: `cargo build -p basilisk-cli` - * - The binary must be on PATH or the test will fail hard - */ - -import * as assert from 'assert'; -import * as vscode from 'vscode'; -import { getStore } from '../../extension'; - -import { - EXTENSION_ID, - DIAGNOSTIC_TIMEOUT_MS, - NO_DIAGNOSTIC_WAIT_MS, - SERVER_START_WAIT_MS, - SUITE_SETUP_TIMEOUT_MS, - waitForDiagnostics, - waitForDiagnosticsCleared, - waitForLspReady, - openPythonFile, - closeAllEditors, - replaceDocumentContent, - setupLspTestSuite, - teardownLspTestSuite, -} from './test-helpers'; -import { - manifestActivationEvents -} from "./extension-manifest"; - -/** Extra buffer (ms) added to restart-test timeout to cover server restart. */ -const RESTART_EXTRA_TIMEOUT_MS = 20_000; - -/** Multiplier applied to DIAGNOSTIC_TIMEOUT_MS for multi-phase tests. */ -const DIAGNOSTIC_TIMEOUT_MULTIPLIER = 3; - -// eslint-disable-next-line max-lines-per-function -suite('LSP Lifecycle Tests', () => { - let tmpDir: string; - - suiteSetup(async function () { - this.timeout(SUITE_SETUP_TIMEOUT_MS); - const result = await setupLspTestSuite('basilisk-lifecycle-test-'); - tmpDir = result.tmpDir; - }); - - suiteTeardown(async () => { - await closeAllEditors(); - teardownLspTestSuite(tmpDir); - }); - - teardown(async () => { - await closeAllEditors(); - }); - - // ---------------------------------------------------------------- - // 1. restartServer command works [VSIX-ERROR-RECOVERY] (manual recovery), - // [VSIX-COMMANDS] - // ---------------------------------------------------------------- - test('restartServer command works and server remains functional', async function () { - this.timeout(DIAGNOSTIC_TIMEOUT_MS + RESTART_EXTRA_TIMEOUT_MS); - - // Execute the restart command -- it should not throw. - let didThrow = false; - try { - await vscode.commands.executeCommand('basilisk.restartServer'); - } catch { - didThrow = true; - } - assert.strictEqual(didThrow, false, 'basilisk.restartServer should not throw'); - - // Deterministically wait for the restarted server to re-advertise its - // commands before probing it. lspClient.stop() clears store.serverCommands - // and start() re-populates it asynchronously after re-initialize; waitForLspReady - // polls that signal, so we never race a half-restarted server (replaces the - // previous fixed 500ms sleep, which was the timing flake). - await waitForLspReady(); - - // Verify the extension is still active after restart. - const ext = vscode.extensions.getExtension(EXTENSION_ID); - assert.ok(ext, `Extension ${EXTENSION_ID} should be installed`); - assert.strictEqual(ext.isActive, true, 'Extension should remain active after server restart'); - - // Open a Python file with an error to prove the restarted server works. - const { uri } = await openPythonFile( - tmpDir, - 'test_restart_verify.py', - 'def after_restart(x):\n return x\n' - ); - - const diagnostics = await waitForDiagnostics(uri, DIAGNOSTIC_TIMEOUT_MS); - assert.ok( - diagnostics.length > 0, - 'Expected diagnostics after server restart, proving the server restarted and is working' - ); - }); - - // ---------------------------------------------------------------- - // 2. showOutput command works [VSIX-OUTPUT-CHANNELS], [VSIX-COMMANDS] - // ---------------------------------------------------------------- - test('showOutput command works without error', async function () { - this.timeout(SERVER_START_WAIT_MS); - - // Ensure the extension is active. - const ext = vscode.extensions.getExtension(EXTENSION_ID); - assert.ok(ext, `Extension ${EXTENSION_ID} should be installed`); - assert.strictEqual(ext.isActive, true, 'Extension should be active'); - - // Execute the showOutput command -- it should not throw. - let didThrow = false; - try { - await vscode.commands.executeCommand('basilisk.showOutput'); - } catch { - didThrow = true; - } - assert.strictEqual(didThrow, false, 'basilisk.showOutput should not throw'); - }); - - // ---------------------------------------------------------------- - // 3. Status bar exists after activation [VSIX-STATUS-BAR] - // ---------------------------------------------------------------- - test('status bar exists after activation', async function () { - this.timeout(SERVER_START_WAIT_MS); - - const ext = vscode.extensions.getExtension(EXTENSION_ID); - assert.ok(ext, `Extension ${EXTENSION_ID} should be installed`); - assert.strictEqual(ext.isActive, true, 'Extension must be active for status bar to exist'); - - // Verify the showOutput command is available via internal VSIX state. - const store = getStore(); - assert.ok(store, 'Store should be available after activation'); - assert.ok( - store.isClientCommandRegistered('basilisk.showOutput'), - 'basilisk.showOutput should be tracked in internal VSIX state' - ); - - // Execute the status bar command to confirm the output channel is alive. - // If the status bar or output channel was not created, this would throw. - let didThrow = false; - try { - await vscode.commands.executeCommand('basilisk.showOutput'); - } catch { - didThrow = true; - } - assert.strictEqual( - didThrow, - false, - 'Executing the status bar command (showOutput) should not throw' - ); - }); - - // ---------------------------------------------------------------- - // 4. Extension activates on Python file open - // ---------------------------------------------------------------- - test('extension activates on Python file open', async function () { - this.timeout(DIAGNOSTIC_TIMEOUT_MS); - - // The extension should already be active from suiteSetup, but we - // verify the activation mechanism by checking the extension state. - const ext = vscode.extensions.getExtension(EXTENSION_ID); - assert.ok(ext, `Extension ${EXTENSION_ID} should be installed`); - - // Verify activation events include Python language. - const activationEvents: string[] = manifestActivationEvents(); - assert.ok( - activationEvents.includes('onLanguage:python'), - 'Extension should declare onLanguage:python activation event' - ); - - // Open a .py file and confirm the extension is active. - const { doc } = await openPythonFile( - tmpDir, - 'test_activate.py', - 'x: int = 42\n' - ); - - assert.strictEqual(doc.languageId, 'python', 'Opened document should be identified as Python'); - - // After opening a Python file the extension must be active. - assert.strictEqual( - ext.isActive, - true, - 'Extension should be active after opening a Python file' - ); - }); - - // ---------------------------------------------------------------- - // 5. Diagnostics update on file edit (full edit-diagnose-fix-clear cycle) - // ---------------------------------------------------------------- - test('diagnostics update on file edit -- full edit-diagnose-fix-clear cycle', async function () { - this.timeout(DIAGNOSTIC_TIMEOUT_MS * DIAGNOSTIC_TIMEOUT_MULTIPLIER + SERVER_START_WAIT_MS); - - // Step 1: Open a clean, fully typed file -- expect zero diagnostics. - const { doc, uri } = await openPythonFile( - tmpDir, - 'test_edit_cycle.py', - 'def good(x: int) -> int:\n return x\n' - ); - - // Wait for the server to have processed the file (diagnostics cleared or stable). - await waitForDiagnosticsCleared(uri, NO_DIAGNOSTIC_WAIT_MS); - - const initialDiags = vscode.languages.getDiagnostics(uri); - const initialBasiliskDiags = initialDiags.filter( - (d) => - d.source === 'basilisk' || - (typeof d.code === 'object' && - d.code !== null && - 'value' in d.code && - typeof d.code.value === 'string' && - d.code.value.startsWith('BSK-')) - ); - assert.strictEqual( - initialBasiliskDiags.length, - 0, - `Expected zero Basilisk diagnostics for clean code, got ${initialBasiliskDiags.length}` - ); - - // Step 2: Edit the file to introduce a type error (missing param type). - const editApplied = await replaceDocumentContent( - doc, - 'def good(x: int) -> int:\n return x\n\ndef bad(x):\n return x\n' - ); - assert.strictEqual(editApplied, true, 'Edit to introduce error should succeed'); - - const errorDiags = await waitForDiagnostics(uri, DIAGNOSTIC_TIMEOUT_MS); - assert.ok( - errorDiags.length > 0, - 'Expected diagnostics after introducing untyped parameter' - ); - - // Step 3: Fix the error by adding the type annotation. - const fixApplied = await replaceDocumentContent( - doc, - 'def good(x: int) -> int:\n return x\n\ndef bad(x: int) -> int:\n return x\n' - ); - assert.strictEqual(fixApplied, true, 'Edit to fix error should succeed'); - - const clearedDiags = await waitForDiagnosticsCleared(uri, DIAGNOSTIC_TIMEOUT_MS); - const remainingBasilisk = clearedDiags.filter( - (d) => - d.source === 'basilisk' || - (typeof d.code === 'object' && - d.code !== null && - 'value' in d.code && - typeof d.code.value === 'string' && - d.code.value.startsWith('BSK-')) - ); - assert.strictEqual( - remainingBasilisk.length, - 0, - `Expected diagnostics to clear after fixing the type error, but ${remainingBasilisk.length} remain` - ); - }); - - // ---------------------------------------------------------------- - // 6. Multiple files get independent diagnostics - // ---------------------------------------------------------------- - test('multiple files get independent diagnostics', async function () { - this.timeout(DIAGNOSTIC_TIMEOUT_MS * 2 + SERVER_START_WAIT_MS); - - // Open file A with an error. - const { uri: uriA } = await openPythonFile( - tmpDir, - 'test_multi_a.py', - 'def broken(x):\n return x\n' - ); - - // Wait for diagnostics on file A. - const diagsA = await waitForDiagnostics(uriA, DIAGNOSTIC_TIMEOUT_MS); - assert.ok( - diagsA.length > 0, - 'File A (with errors) should have diagnostics' - ); - - // Open file B with clean code. - const { uri: uriB } = await openPythonFile( - tmpDir, - 'test_multi_b.py', - 'def clean(x: int) -> int:\n return x\n' - ); - - // Wait for the server to process file B (no diagnostics expected). - await waitForDiagnosticsCleared(uriB, NO_DIAGNOSTIC_WAIT_MS); - - const diagsB = vscode.languages.getDiagnostics(uriB); - const basiliskDiagsB = diagsB.filter( - (d) => - d.source === 'basilisk' || - (typeof d.code === 'object' && - d.code !== null && - 'value' in d.code && - typeof d.code.value === 'string' && - d.code.value.startsWith('BSK-')) - ); - assert.strictEqual( - basiliskDiagsB.length, - 0, - `File B (clean code) should have zero Basilisk diagnostics, got ${basiliskDiagsB.length}` - ); - - // Verify file A still has its diagnostics while file B is open. - const diagsAStill = vscode.languages.getDiagnostics(uriA); - assert.ok( - diagsAStill.length > 0, - 'File A should still have diagnostics while file B is open' - ); - - // Close file A. - await closeAllEditors(); - - // Re-open file B only. - const { uri: uriBReopened } = await openPythonFile( - tmpDir, - 'test_multi_b.py', - 'def clean(x: int) -> int:\n return x\n' - ); - - await waitForDiagnosticsCleared(uriBReopened, NO_DIAGNOSTIC_WAIT_MS); - - const diagsBAfterClose = vscode.languages.getDiagnostics(uriBReopened); - const basiliskDiagsBAfter = diagsBAfterClose.filter( - (d) => - d.source === 'basilisk' || - (typeof d.code === 'object' && - d.code !== null && - 'value' in d.code && - typeof d.code.value === 'string' && - d.code.value.startsWith('BSK-')) - ); - assert.strictEqual( - basiliskDiagsBAfter.length, - 0, - 'File B should still have zero Basilisk diagnostics after closing file A' - ); - }); -}); diff --git a/vscode-extension/src/test/suite/lsp-navigation.test.ts b/vscode-extension/src/test/suite/lsp-navigation.test.ts deleted file mode 100644 index 645bd6d39..000000000 --- a/vscode-extension/src/test/suite/lsp-navigation.test.ts +++ /dev/null @@ -1,155 +0,0 @@ -// Implements [LSPARCH-FEATURES-SIGNATURE-HELP]. See docs/specs/LSP-ARCHITECTURE-SPEC.md#LSPARCH-FEATURES-SIGNATURE-HELP -/** - * LSP Signature-Help & Code-Action Tests for the Basilisk VS Code Extension. - * - * Tests signature help and code actions through VS Code's command APIs against - * the real LSP server. - * - * Hover and go-to-definition/-declaration/-type-definition are hammered - * exhaustively in their own dedicated suites (lsp-hover.test.ts, - * lsp-goto.test.ts) — they are NOT duplicated here. - * - * Prerequisites: - * - The `basilisk` binary must be built: `cargo build -p basilisk-cli` - * - The binary must be on PATH or the test will fail hard - */ - -import * as assert from 'assert'; -import * as vscode from 'vscode'; -import { - DIAGNOSTIC_TIMEOUT_MS, - SUITE_SETUP_TIMEOUT_MS, - closeAllEditors, - openPythonFile, - pollUntilResult, - setupLspTestSuite, - teardownLspTestSuite, - waitForDiagnostics, -} from './test-helpers'; - -/** Extra buffer (ms) added to test-level timeouts beyond the core wait. */ -const TIMEOUT_BUFFER_MS = 5_000; - -/** Large buffer (ms) for tests that involve multiple operations. */ -const LARGE_TIMEOUT_BUFFER_MS = 10_000; - -/** Line for signature help trigger ("greet()"). */ -const SIG_HELP_LINE = 3; - -/** Column inside the parentheses for signature help. */ -const SIG_HELP_COLUMN = 6; - -suite('LSP Signature Help & Code Action Tests', () => { - let tmpDir: string; - - suiteSetup(async function () { - this.timeout(SUITE_SETUP_TIMEOUT_MS); - const setup = await setupLspTestSuite('basilisk-nav-test-'); - tmpDir = setup.tmpDir; - }); - - suiteTeardown(async () => { - await closeAllEditors(); - teardownLspTestSuite(tmpDir); - }); - - teardown(async () => { - await closeAllEditors(); - }); - - // ---------------------------------------------------------------- - // Signature help works through extension - // ---------------------------------------------------------------- - test('signature help works through extension', async function () { - this.timeout(DIAGNOSTIC_TIMEOUT_MS + TIMEOUT_BUFFER_MS); - - const { uri } = await openPythonFile( - tmpDir, - 'test_sig_help.py', - [ - 'def greet(name: str, age: int) -> str:', - ' return f"{name} is {age}"', - '', - 'greet()', - '', - ].join('\n') - ); - - // Poll until signature help returns results. - const position = new vscode.Position(SIG_HELP_LINE, SIG_HELP_COLUMN); - const signatureHelp = await pollUntilResult({ - fn: async () => vscode.commands.executeCommand<vscode.SignatureHelp>( - 'vscode.executeSignatureHelpProvider', uri, position, '(' - ).then((r) => r, () => null), - predicate: (r) => r !== null && r !== undefined && r.signatures.length > 0, - }); - - assert.ok(signatureHelp !== null && signatureHelp !== undefined, 'Expected signature help result to be defined'); - assert.ok( - signatureHelp.signatures.length > 0, - 'Expected at least one signature in signature help' - ); - - // Verify the signature contains both parameter names. - const sig = signatureHelp.signatures[0]; - const paramLabels = sig.parameters.map((p) => - typeof p.label === 'string' ? p.label : sig.label.slice(p.label[0], p.label[1]) - ); - const allParamText = paramLabels.join(' '); - - assert.ok( - allParamText.includes('name'), - `Expected signature parameters to include 'name'. Got: ${paramLabels.join(', ')}` - ); - assert.ok( - allParamText.includes('age'), - `Expected signature parameters to include 'age'. Got: ${paramLabels.join(', ')}` - ); - }); - - // ---------------------------------------------------------------- - // Code actions provided for diagnostics - // ---------------------------------------------------------------- - test('code actions provided for diagnostics', async function () { - this.timeout(DIAGNOSTIC_TIMEOUT_MS + LARGE_TIMEOUT_BUFFER_MS); - - // A core type-mismatch (always on) — independent of any opt-in house - // rule, so this suite needs no config. Every coded diagnostic offers at - // least the suppress/disable quick fixes, which is what we assert below. - const { uri } = await openPythonFile( - tmpDir, - 'test_code_actions.py', - 'x: int = "hello"\n' - ); - - // Wait for diagnostics to appear (the assignment type mismatch). - const diagnostics = await waitForDiagnostics(uri, DIAGNOSTIC_TIMEOUT_MS); - - assert.ok( - diagnostics.length > 0, - 'Expected at least one diagnostic for the type mismatch' - ); - - // Use the range of the first diagnostic to request code actions. - const diagRange = diagnostics[0].range; - const codeActions = await vscode.commands.executeCommand<vscode.CodeAction[]>( - 'vscode.executeCodeActionProvider', - uri, - diagRange - ); - - assert.ok(codeActions !== undefined, 'Expected code actions result to be defined'); - assert.ok( - codeActions.length > 0, - `Expected at least one code action for the diagnostic. ` + - `Diagnostic: ${diagnostics[0].message}` - ); - - // Verify the code action has a title (i.e. is well-formed). - const firstAction = codeActions[0]; - assert.ok( - firstAction.title.length > 0, - `Expected code action to have a non-empty title, got: "${firstAction.title}"` - ); - }); -}); diff --git a/vscode-extension/src/test/suite/lsp-refactoring-actions.test.ts b/vscode-extension/src/test/suite/lsp-refactoring-actions.test.ts deleted file mode 100644 index bace5828d..000000000 --- a/vscode-extension/src/test/suite/lsp-refactoring-actions.test.ts +++ /dev/null @@ -1,380 +0,0 @@ -// Implements [LSPARCH-FEATURES-CODEACTIONS]. See docs/specs/LSP-ARCHITECTURE-SPEC.md#LSPARCH-FEATURES-CODEACTIONS -/** - * LSP Refactoring Code Action Tests for the Basilisk VS Code Extension. - * - * Tests that all refactoring code actions (extract, inline, convert, - * move, change signature) are offered through the real LSP server. - * - * Prerequisites: - * - The `basilisk` binary must be built: `cargo build -p basilisk-cli` - */ - -import * as assert from 'assert'; -import * as vscode from 'vscode'; -import { - setupLspTestSuite, - teardownLspTestSuite, - openPythonFile, - closeAllEditors, - pollUntilResult, -} from "./test-helpers"; - -// Exercises [REFACTOR-KINDS] (client side) — verifies the refactoring code-action -// kinds (extract var/const/func, inline var/func, convert, move, change signature) -// are offered to the editor through the real LSP. -// eslint-disable-next-line max-lines-per-function -suite('LSP Refactoring Code Action Tests', () => { - let tmpDir: string; - - suiteSetup(async function () { - const setup = await setupLspTestSuite('basilisk-refactor-actions-'); - tmpDir = setup.tmpDir; - }); - - suiteTeardown(async () => { - await closeAllEditors(); - teardownLspTestSuite(tmpDir); - }); - - teardown(async () => { - await closeAllEditors(); - }); - - // ── Extract Variable ──────────────────────────────────────────────── - - test('extract variable code action is offered for expression selection', async function () { - - const source = 'result: int = some_func(42) + other_func(7)\n'; - const { uri } = await openPythonFile(tmpDir, 'refactor_extract_var.py', source); - - const range = new vscode.Range( - new vscode.Position(0, 14), - new vscode.Position(0, 27) - ); - - const actions = await pollUntilResult({ - fn: () => vscode.commands.executeCommand<vscode.CodeAction[]>( - 'vscode.executeCodeActionProvider', uri, range - ).then(r => r ?? [], () => []), - predicate: (r) => r.length > 0, - }); - - const titles = actions.map(a => a.title); - assert.ok( - titles.some(t => t.includes('Extract variable')), - `Expected action containing 'Extract variable', got: ${titles.join(', ')}` - ); - }); - - // ── Extract Constant ──────────────────────────────────────────────── - - test('extract constant code action is offered inside function', async function () { - - const source = 'import os\n\ndef f() -> int:\n return 42\n'; - const { uri } = await openPythonFile(tmpDir, 'refactor_extract_const.py', source); - - const range = new vscode.Range( - new vscode.Position(3, 11), - new vscode.Position(3, 13) - ); - - const actions = await pollUntilResult({ - fn: () => vscode.commands.executeCommand<vscode.CodeAction[]>( - 'vscode.executeCodeActionProvider', uri, range - ).then(r => r ?? [], () => []), - predicate: (r) => r.length > 0, - }); - - const titles = actions.map(a => a.title); - assert.ok( - titles.some(t => t.includes('Extract constant')), - `Expected action containing 'Extract constant', got: ${titles.join(', ')}` - ); - }); - - // ── Extract Function ──────────────────────────────────────────────── - - test('extract function code action is offered for statement selection', async function () { - - const source = 'def main() -> None:\n x: int = 1\n y: int = x + 1\n print(y)\n'; - const { uri } = await openPythonFile(tmpDir, 'refactor_extract_func.py', source); - - const range = new vscode.Range( - new vscode.Position(1, 0), - new vscode.Position(3, 0) - ); - - const actions = await pollUntilResult({ - fn: () => vscode.commands.executeCommand<vscode.CodeAction[]>( - 'vscode.executeCodeActionProvider', uri, range - ).then(r => r ?? [], () => []), - predicate: (r) => r.length > 0, - }); - - const titles = actions.map(a => a.title); - assert.ok( - titles.some(t => t.includes('Extract function')), - `Expected action containing 'Extract function', got: ${titles.join(', ')}` - ); - }); - - // ── Inline Variable ───────────────────────────────────────────────── - - test('inline variable code action is offered', async function () { - - const source = 'def f() -> None:\n temp = calculate()\n result = temp + 1\n'; - const { uri } = await openPythonFile(tmpDir, 'refactor_inline_var.py', source); - - const range = new vscode.Range( - new vscode.Position(1, 4), - new vscode.Position(1, 4) - ); - - const actions = await pollUntilResult({ - fn: () => vscode.commands.executeCommand<vscode.CodeAction[]>( - 'vscode.executeCodeActionProvider', uri, range - ).then(r => r ?? [], () => []), - predicate: (r) => r.length > 0, - }); - - const titles = actions.map(a => a.title); - assert.ok( - titles.some(t => t.includes('Inline variable')), - `Expected action containing 'Inline variable', got: ${titles.join(', ')}` - ); - }); - - // ── Inline Function ───────────────────────────────────────────────── - - test('inline function code action is offered', async function () { - - const source = 'def double(x: int) -> int:\n return x * 2\n\nresult: int = double(5)\n'; - const { uri } = await openPythonFile(tmpDir, 'refactor_inline_func.py', source); - - const range = new vscode.Range( - new vscode.Position(3, 14), - new vscode.Position(3, 14) - ); - - const actions = await pollUntilResult({ - fn: () => vscode.commands.executeCommand<vscode.CodeAction[]>( - 'vscode.executeCodeActionProvider', uri, range - ).then(r => r ?? [], () => []), - predicate: (r) => r.length > 0, - }); - - const titles = actions.map(a => a.title); - assert.ok( - titles.some(t => t.includes('Inline function')), - `Expected action containing 'Inline function', got: ${titles.join(', ')}` - ); - }); - - // ── Union Conversion ──────────────────────────────────────────────── - - test('Union conversion code action is offered', async function () { - - const source = 'from typing import Union\nx: Union[int, str] = 1\n'; - const { uri } = await openPythonFile(tmpDir, 'refactor_union.py', source); - - const range = new vscode.Range( - new vscode.Position(1, 3), - new vscode.Position(1, 3) - ); - - const actions = await pollUntilResult({ - fn: () => vscode.commands.executeCommand<vscode.CodeAction[]>( - 'vscode.executeCodeActionProvider', uri, range - ).then(r => r ?? [], () => []), - predicate: (r) => r.length > 0, - }); - - const titles = actions.map(a => a.title); - assert.ok( - titles.some(t => t.includes('Union')), - `Expected action containing 'Union', got: ${titles.join(', ')}` - ); - }); - - // ── Optional Conversion ───────────────────────────────────────────── - - test('Optional conversion code action is offered', async function () { - - const source = 'from typing import Optional\nx: Optional[int] = None\n'; - const { uri } = await openPythonFile(tmpDir, 'refactor_optional.py', source); - - const range = new vscode.Range( - new vscode.Position(1, 3), - new vscode.Position(1, 3) - ); - - const actions = await pollUntilResult({ - fn: () => vscode.commands.executeCommand<vscode.CodeAction[]>( - 'vscode.executeCodeActionProvider', uri, range - ).then(r => r ?? [], () => []), - predicate: (r) => r.length > 0, - }); - - const titles = actions.map(a => a.title); - assert.ok( - titles.some(t => t.includes('Optional')), - `Expected action containing 'Optional', got: ${titles.join(', ')}` - ); - }); - - // ── f-string Conversion ───────────────────────────────────────────── - - test('f-string conversion code action is offered', async function () { - - const source = 'name: str = "world"\nx: str = f"hello {name}"\n'; - const { uri } = await openPythonFile(tmpDir, 'refactor_fstring.py', source); - - const range = new vscode.Range( - new vscode.Position(1, 9), - new vscode.Position(1, 9) - ); - - const actions = await pollUntilResult({ - fn: () => vscode.commands.executeCommand<vscode.CodeAction[]>( - 'vscode.executeCodeActionProvider', uri, range - ).then(r => r ?? [], () => []), - predicate: (r) => r.length > 0, - }); - - const titles = actions.map(a => a.title); - assert.ok( - titles.some(t => t.includes('.format()')), - `Expected action containing '.format()', got: ${titles.join(', ')}` - ); - }); - - // ── dict Literal Conversion ───────────────────────────────────────── - - test('dict literal conversion code action is offered', async function () { - - const source = 'x: dict[str, int] = dict(a=1, b=2)\n'; - const { uri } = await openPythonFile(tmpDir, 'refactor_dict.py', source); - - const range = new vscode.Range( - new vscode.Position(0, 20), - new vscode.Position(0, 20) - ); - - const actions = await pollUntilResult({ - fn: () => vscode.commands.executeCommand<vscode.CodeAction[]>( - 'vscode.executeCodeActionProvider', uri, range - ).then(r => r ?? [], () => []), - predicate: (r) => r.length > 0, - }); - - const titles = actions.map(a => a.title); - assert.ok( - titles.some(t => t.includes('dict')), - `Expected action containing 'dict', got: ${titles.join(', ')}` - ); - }); - - // ── list Literal Conversion ───────────────────────────────────────── - - test('list literal conversion code action is offered', async function () { - - const source = 'x: list[int] = list()\n'; - const { uri } = await openPythonFile(tmpDir, 'refactor_list.py', source); - - const range = new vscode.Range( - new vscode.Position(0, 15), - new vscode.Position(0, 15) - ); - - const actions = await pollUntilResult({ - fn: () => vscode.commands.executeCommand<vscode.CodeAction[]>( - 'vscode.executeCodeActionProvider', uri, range - ).then(r => r ?? [], () => []), - predicate: (r) => r.length > 0, - }); - - const titles = actions.map(a => a.title); - assert.ok( - titles.some(t => t.includes('list')), - `Expected action containing 'list', got: ${titles.join(', ')}` - ); - }); - - // ── Ternary Conversion ────────────────────────────────────────────── - - test('ternary conversion code action is offered', async function () { - - const source = 'def f(cond: bool) -> int:\n x: int = 1 if cond else 0\n return x\n'; - const { uri } = await openPythonFile(tmpDir, 'refactor_ternary.py', source); - - const range = new vscode.Range( - new vscode.Position(1, 4), - new vscode.Position(1, 4) - ); - - const actions = await pollUntilResult({ - fn: () => vscode.commands.executeCommand<vscode.CodeAction[]>( - 'vscode.executeCodeActionProvider', uri, range - ).then(r => r ?? [], () => []), - predicate: (r) => r.length > 0, - }); - - const titles = actions.map(a => a.title); - assert.ok( - titles.some(t => t.includes('if/else')), - `Expected action containing 'if/else', got: ${titles.join(', ')}` - ); - }); - - // ── Move Symbol ───────────────────────────────────────────────────── - - test('move symbol code action is offered for class', async function () { - - const source = 'import os\n\nclass MyWidget:\n pass\n'; - const { uri } = await openPythonFile(tmpDir, 'refactor_move.py', source); - - const range = new vscode.Range( - new vscode.Position(2, 0), - new vscode.Position(2, 0) - ); - - const actions = await pollUntilResult({ - fn: () => vscode.commands.executeCommand<vscode.CodeAction[]>( - 'vscode.executeCodeActionProvider', uri, range - ).then(r => r ?? [], () => []), - predicate: (r) => r.length > 0, - }); - - const titles = actions.map(a => a.title); - assert.ok( - titles.some(t => t.includes('Move') && t.includes('new file')), - `Expected action containing 'Move' and 'new file', got: ${titles.join(', ')}` - ); - }); - - // ── Change Signature ──────────────────────────────────────────────── - - test('change signature remove parameter is offered', async function () { - - const source = 'def greet(name: str, greeting: str) -> str:\n return f"{greeting}, {name}"\n\nresult: str = greet("world", "Hello")\n'; - const { uri } = await openPythonFile(tmpDir, 'refactor_change_sig.py', source); - - const range = new vscode.Range( - new vscode.Position(0, 21), - new vscode.Position(0, 21) - ); - - const actions = await pollUntilResult({ - fn: () => vscode.commands.executeCommand<vscode.CodeAction[]>( - 'vscode.executeCodeActionProvider', uri, range - ).then(r => r ?? [], () => []), - predicate: (r) => r.length > 0, - }); - - const titles = actions.map(a => a.title); - assert.ok( - titles.some(t => t.includes('Remove parameter')), - `Expected action containing 'Remove parameter', got: ${titles.join(', ')}` - ); - }); -}); diff --git a/vscode-extension/src/test/suite/lsp-refactoring.test.ts b/vscode-extension/src/test/suite/lsp-refactoring.test.ts deleted file mode 100644 index 11eb310ca..000000000 --- a/vscode-extension/src/test/suite/lsp-refactoring.test.ts +++ /dev/null @@ -1,316 +0,0 @@ -// Implements [LSPARCH-FEATURES-RENAME]. See docs/specs/LSP-ARCHITECTURE-SPEC.md#LSPARCH-FEATURES-RENAME -/** - * LSP Refactoring Tests for the Basilisk VS Code Extension. - * - * Tests scope-aware rename, keyword rejection, and nested scope handling - * through the REAL LSP server. No mocking. - * - * Prerequisites: - * - The `basilisk` binary must be built: `cargo build -p basilisk-cli` - */ - -import * as assert from 'assert'; -import * as vscode from 'vscode'; -import { - setupLspTestSuite, - teardownLspTestSuite, - openPythonFile, - closeAllEditors, - pollUntilResult, - DIAGNOSTIC_TIMEOUT_MS, - SERVER_START_WAIT_MS, - SUITE_SETUP_TIMEOUT_MS, -} from './test-helpers'; -const RENAME_POLL_TIMEOUT_MS = 3_000; -const LOCAL_VAR_START_LINE = 3; -const LOCAL_VAR_END_LINE = 4; -const MODULE_USAGE_LINE = 6; -const PARAM_START_LINE = 2; -const PARAM_END_LINE = 3; -const PARAM_CHAR_OFFSET = 10; -const OUTER_DEF_LINE = 1; -const OUTER_USAGE_LINE = 5; -const HELPER_DEF_CHAR_OFFSET = 4; -const MIN_MULTI_RENAME_EDITS = 4; - -// Exercises [REFACTOR-RENAME-SCOPE] (scope-aware rename, shadowing) and -// [REFACTOR-RENAME-VALIDATE] (keyword / invalid-identifier rejection) through the -// real LSP rename round-trip. -// eslint-disable-next-line max-lines-per-function -suite('LSP Refactoring Tests', () => { - let tmpDir: string; - - suiteSetup(async function () { - this.timeout(SUITE_SETUP_TIMEOUT_MS); - const setup = await setupLspTestSuite('basilisk-refactoring-'); - tmpDir = setup.tmpDir; - }); - - suiteTeardown(async () => { - await closeAllEditors(); - teardownLspTestSuite(tmpDir); - }); - - teardown(async () => { - await closeAllEditors(); - }); - - // ---------------------------------------------------------------- - // 1. Scope-aware rename: local var does NOT rename module-level var - // ---------------------------------------------------------------- - test('rename local variable does not affect module-level', async function () { - this.timeout(DIAGNOSTIC_TIMEOUT_MS + SERVER_START_WAIT_MS); - - const source = [ - 'x: int = 1', - '', - 'def foo() -> int:', - ' x: int = 2', - ' return x', - '', - 'y: int = x', - '', - ].join('\n'); - - const { uri } = await openPythonFile(tmpDir, 'scope_rename_local.py', source); - - // Rename `x` inside the function (line 3, char 4). - const localPos = new vscode.Position(LOCAL_VAR_START_LINE, LOCAL_VAR_END_LINE); - const edit = await pollUntilResult({ - fn: () => vscode.commands.executeCommand<vscode.WorkspaceEdit>( - 'vscode.executeDocumentRenameProvider', uri, localPos, 'local_x' - ).then((r) => r, () => new vscode.WorkspaceEdit()), - predicate: (r) => r !== null && r !== undefined && r.get(uri).length > 0, - }); - - const edits = edit.get(uri); - assert.ok(edits.length > 0, 'Expected rename edits'); - - // All edits must be within the function body (lines 3-4), NOT on line 0 or 6. - for (const e of edits) { - const line = e.range.start.line; - assert.ok( - line >= LOCAL_VAR_START_LINE && line <= LOCAL_VAR_END_LINE, - `Rename of local x should only touch lines 3-4, but found edit on line ${line}` - ); - assert.strictEqual(e.newText, 'local_x'); - } - }); - - // ---------------------------------------------------------------- - // 2. Scope-aware rename: module-level var skips shadowed local - // ---------------------------------------------------------------- - test('rename module variable skips shadowed local', async function () { - this.timeout(DIAGNOSTIC_TIMEOUT_MS + SERVER_START_WAIT_MS); - - const source = [ - 'x: int = 1', - '', - 'def foo() -> int:', - ' x: int = 2', - ' return x', - '', - 'y: int = x', - '', - ].join('\n'); - - const { uri } = await openPythonFile(tmpDir, 'scope_rename_module.py', source); - - // Rename `x` at module level (line 0, char 0). - const modulePos = new vscode.Position(0, 0); - const edit = await pollUntilResult({ - fn: () => vscode.commands.executeCommand<vscode.WorkspaceEdit>( - 'vscode.executeDocumentRenameProvider', uri, modulePos, 'global_x' - ).then((r) => r, () => new vscode.WorkspaceEdit()), - predicate: (r) => r !== null && r !== undefined && r.get(uri).length > 0, - }); - - const edits = edit.get(uri); - assert.ok(edits.length > 0, 'Expected rename edits'); - - // Edits should only be on line 0 (definition) and line 6 (usage), NOT lines 3-4. - for (const e of edits) { - const line = e.range.start.line; - assert.ok( - line === 0 || line === MODULE_USAGE_LINE, - `Rename of module x should only touch lines 0 and 6, but found edit on line ${line}` - ); - assert.strictEqual(e.newText, 'global_x'); - } - }); - - // ---------------------------------------------------------------- - // 3. Rename parameter stays within function scope - // ---------------------------------------------------------------- - test('rename parameter stays within function', async function () { - this.timeout(DIAGNOSTIC_TIMEOUT_MS + SERVER_START_WAIT_MS); - - const source = [ - 'name: str = "global"', - '', - 'def greet(name: str) -> str:', - ' return f"Hello, {name}!"', - '', - 'result: str = name', - '', - ].join('\n'); - - const { uri } = await openPythonFile(tmpDir, 'scope_rename_param.py', source); - - // Rename `name` parameter (line 2, char 10 = the `n` in `greet(name: str)`). - const paramPos = new vscode.Position(PARAM_START_LINE, PARAM_CHAR_OFFSET); - const edit = await pollUntilResult({ - fn: () => vscode.commands.executeCommand<vscode.WorkspaceEdit>( - 'vscode.executeDocumentRenameProvider', uri, paramPos, 'person' - ).then((r) => r, () => new vscode.WorkspaceEdit()), - predicate: (r) => r !== null && r !== undefined && r.get(uri).length > 0, - }); - - const edits = edit.get(uri); - assert.ok(edits.length > 0, 'Expected rename edits'); - - // Should only rename within the function (lines 2-3), not at module level. - for (const e of edits) { - const line = e.range.start.line; - assert.ok( - line >= PARAM_START_LINE && line <= PARAM_END_LINE, - `Rename of parameter should only touch lines 2-3, but found edit on line ${line}` - ); - } - }); - - // ---------------------------------------------------------------- - // 4. Rename rejects Python keywords - // ---------------------------------------------------------------- - test('rename to keyword is rejected', async function () { - this.timeout(DIAGNOSTIC_TIMEOUT_MS + SERVER_START_WAIT_MS); - - const source = 'x: int = 1\n'; - const { uri } = await openPythonFile(tmpDir, 'scope_rename_keyword.py', source); - - const pos = new vscode.Position(0, 0); - let rejected = false; - try { - await pollUntilResult({ - fn: () => vscode.commands.executeCommand<vscode.WorkspaceEdit>( - 'vscode.executeDocumentRenameProvider', uri, pos, 'class' - ).then((r) => r, () => null), - predicate: (r) => r !== null && r !== undefined && r.get(uri).length > 0, - timeoutMs: RENAME_POLL_TIMEOUT_MS, - }); - } catch { - rejected = true; - } - // If not thrown, the edit should be null/empty. - if (!rejected) { - // VS Code may return an empty edit or throw; either is acceptable. - assert.ok(true, 'Rename to keyword was handled (either rejected or returned empty)'); - } - }); - - // ---------------------------------------------------------------- - // 5. Rename rejects invalid identifiers - // ---------------------------------------------------------------- - test('rename to invalid identifier is rejected', async function () { - this.timeout(DIAGNOSTIC_TIMEOUT_MS + SERVER_START_WAIT_MS); - - const source = 'x: int = 1\n'; - const { uri } = await openPythonFile(tmpDir, 'scope_rename_invalid.py', source); - - const pos = new vscode.Position(0, 0); - let rejected = false; - try { - await pollUntilResult({ - fn: () => vscode.commands.executeCommand<vscode.WorkspaceEdit>( - 'vscode.executeDocumentRenameProvider', uri, pos, '123abc' - ).then((r) => r, () => null), - predicate: (r) => r !== null && r !== undefined && r.get(uri).length > 0, - timeoutMs: RENAME_POLL_TIMEOUT_MS, - }); - } catch { - rejected = true; - } - if (!rejected) { - assert.ok(true, 'Rename to invalid identifier was handled'); - } - }); - - // ---------------------------------------------------------------- - // 6. Nested function scoping: outer rename does not touch inner shadow - // ---------------------------------------------------------------- - test('rename in outer function skips inner shadowed variable', async function () { - this.timeout(DIAGNOSTIC_TIMEOUT_MS + SERVER_START_WAIT_MS); - - const source = [ - 'def outer() -> int:', - ' x: int = 1', - ' def inner() -> int:', - ' x: int = 2', - ' return x', - ' return x', - '', - ].join('\n'); - - const { uri } = await openPythonFile(tmpDir, 'scope_rename_nested.py', source); - - // Rename `x` in outer (line 1, char 4). - const outerPos = new vscode.Position(OUTER_DEF_LINE, HELPER_DEF_CHAR_OFFSET); - const edit = await pollUntilResult({ - fn: () => vscode.commands.executeCommand<vscode.WorkspaceEdit>( - 'vscode.executeDocumentRenameProvider', uri, outerPos, 'outer_x' - ).then((r) => r, () => new vscode.WorkspaceEdit()), - predicate: (r) => r !== null && r !== undefined && r.get(uri).length > 0, - }); - - const edits = edit.get(uri); - assert.ok(edits.length > 0, 'Expected rename edits'); - - // Should rename `x` on lines 1 and 5 (outer scope), NOT lines 3-4 (inner). - for (const e of edits) { - const line = e.range.start.line; - assert.ok( - line === OUTER_DEF_LINE || line === OUTER_USAGE_LINE, - `Rename of outer x should only touch lines 1 and 5, but found edit on line ${line}` - ); - assert.strictEqual(e.newText, 'outer_x'); - } - }); - - // ---------------------------------------------------------------- - // 7. Multi-occurrence rename at module level - // ---------------------------------------------------------------- - test('rename function with multiple call sites', async function () { - this.timeout(DIAGNOSTIC_TIMEOUT_MS + SERVER_START_WAIT_MS); - - const source = [ - 'def helper(x: int) -> int:', - ' return x + 1', - '', - 'a: int = helper(1)', - 'b: int = helper(2)', - 'c: int = helper(3)', - '', - ].join('\n'); - - const { uri } = await openPythonFile(tmpDir, 'scope_rename_multi.py', source); - - // Rename `helper` at definition (line 0, char 4). - const defPos = new vscode.Position(0, HELPER_DEF_CHAR_OFFSET); - const edit = await pollUntilResult({ - fn: () => vscode.commands.executeCommand<vscode.WorkspaceEdit>( - 'vscode.executeDocumentRenameProvider', uri, defPos, 'assist' - ).then((r) => r, () => new vscode.WorkspaceEdit()), - predicate: (r) => r !== null && r !== undefined && r.get(uri).length >= MIN_MULTI_RENAME_EDITS, - }); - - const edits = edit.get(uri); - assert.ok( - edits.length >= MIN_MULTI_RENAME_EDITS, - `Expected at least 4 rename edits (1 def + 3 calls), got ${edits.length}` - ); - - for (const e of edits) { - assert.strictEqual(e.newText, 'assist'); - } - }); -}); diff --git a/vscode-extension/src/test/suite/lsp-trace.test.ts b/vscode-extension/src/test/suite/lsp-trace.test.ts deleted file mode 100644 index 66b67225c..000000000 --- a/vscode-extension/src/test/suite/lsp-trace.test.ts +++ /dev/null @@ -1,107 +0,0 @@ -// Implements [VSIX-OUTPUT-CHANNELS]. See docs/specs/VSIX-SPEC.md#VSIX-OUTPUT-CHANNELS -/** - * LSP trace channel tests (GitHub #201). - * - * The "Basilisk LSP Trace" output channel is the only field observability for - * LSP request/response traffic. #201 reported it entirely blank: setting the - * documented `basilisk.trace.server` switch produced zero output, leaving - * failures undiagnosable. These tests drive the real LSP pipeline and assert - * what users actually see through the `lspTraceLines()` seam - * (src/lsp-trace.ts — VS Code offers no API to read a channel back). - */ - -import { delay } from '../../timeouts'; -import * as assert from 'assert'; -import * as vscode from 'vscode'; -import { lspTraceLines } from '../../lsp-trace'; - -import { - DIAGNOSTIC_TIMEOUT_MS, - SUITE_SETUP_TIMEOUT_MS, - openPythonFile, - closeAllEditors, - setupLspTestSuite, - teardownLspTestSuite, -} from './test-helpers'; - -/** How long trace lines get to land after tracing is switched on. */ -const TRACE_WAIT_MS = 15_000; - -/** Poll interval while waiting for trace lines. */ -const TRACE_POLL_MS = 100; - -/** A trace line naming an LSP document method — proof of per-request tracing. */ -const LSP_METHOD_RE = /textDocument\//; - -/** Wait until a recorded trace line matches `pattern`, or time out. */ -async function waitForTraceLine( - pattern: RegExp, - timeoutMs: number -): Promise<string | undefined> { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - const line = lspTraceLines().find((candidate) => pattern.test(candidate)); - if (line !== undefined) { - return line; - } - await delay(TRACE_POLL_MS); - } - return undefined; -} - -suite('LSP Trace Channel Tests', () => { - let tmpDir: string; - - suiteSetup(async function () { - this.timeout(SUITE_SETUP_TIMEOUT_MS); - const result = await setupLspTestSuite('basilisk-trace-test-'); - tmpDir = result.tmpDir; - }); - - suiteTeardown(async () => { - await vscode.workspace - .getConfiguration('basilisk') - .update('trace.server', undefined, vscode.ConfigurationTarget.Global); - await closeAllEditors(); - teardownLspTestSuite(tmpDir); - }); - - // ---------------------------------------------------------------- - // GitHub #201: with basilisk.trace.server enabled, real LSP traffic - // (didOpen, hover) must surface in the trace channel. [VSIX-OUTPUT-CHANNELS] - // ---------------------------------------------------------------- - test('enabling basilisk.trace.server surfaces LSP requests in the trace channel', async function () { - this.timeout(DIAGNOSTIC_TIMEOUT_MS + TRACE_WAIT_MS * 2); - - await vscode.workspace - .getConfiguration('basilisk') - .update('trace.server', 'verbose', vscode.ConfigurationTarget.Global); - - // Drive genuine LSP traffic through the live server: didOpen from the - // editor, then an explicit hover request. - const { doc, uri } = await openPythonFile( - tmpDir, - 'trace_probe.py', - 'def greet(name: str) -> str:\n' + - ' return name\n' + - '\n' + - '\n' + - 'result = greet("world")\n' - ); - assert.strictEqual(doc.languageId, 'python', 'probe file must open as python'); - await vscode.commands.executeCommand<vscode.Hover[]>( - 'vscode.executeHoverProvider', - uri, - new vscode.Position(4, 10) - ); - - const line = await waitForTraceLine(LSP_METHOD_RE, TRACE_WAIT_MS); - assert.notStrictEqual( - line, - undefined, - 'basilisk.trace.server=verbose must surface textDocument/* traffic in the ' + - `"Basilisk LSP Trace" channel; the channel stayed blank (#201) — ` + - `${lspTraceLines().length} line(s) recorded` - ); - }); -}); diff --git a/vscode-extension/src/test/suite/memory-autopilot-e2e.test.ts b/vscode-extension/src/test/suite/memory-autopilot-e2e.test.ts deleted file mode 100644 index 1801e02b3..000000000 --- a/vscode-extension/src/test/suite/memory-autopilot-e2e.test.ts +++ /dev/null @@ -1,331 +0,0 @@ -// Tests for [PROFILE-MEMORY-AUTOPILOT] + [PROFILE-MEMORY-LEAK-ACTIONS] + [PROFILE-MEMORY-REFGRAPH-PICKER]. -// See docs/specs/LSP-PROFILING-SPEC.md#PROFILE-MEMORY-AUTOPILOT -// -// REAL end-to-end coverage of the memory autopilot — the whole point is that the -// TEST never calls snapshot/diff itself. A real basilisk-debug session pauses a -// real leaking Python program; the autopilot's per-pause snapshot+diff round-trip -// (and its interval timer) fire automatically, and the assertions read what the -// user sees: leak confidence escalating LOW→MEDIUM→HIGH, the purple + leak -// decorations painted, and exactly one proactive leak-action offer. No mocks. - -import { delay } from "../../timeouts"; -import * as assert from "assert"; -import * as vscode from "vscode"; -import * as fs from "fs"; -import * as path from "path"; -import { - SESSION_WAIT_MS, - POLL_MS, - setBreakpoints, - waitForPause, - resume, - waitForSessionEnd, -} from "./debug-e2e-helpers"; -import { - pollUntilResult, - setupLspTestSuite, - teardownLspTestSuite, - closeAllEditors, - sameFile, -} from "./test-helpers"; -import { buildProfileLaunchConfig } from "../../process-launch"; -import { activeMemorySession } from "../../memory-profiler"; -import { recordedAutopilotCaptures, recordedLeakOffers } from "../../memory-autopilot"; -import { gatherReferenceTypeCandidates } from "../../memory-ref-picker"; -import { appliedMemoryDecorations, clearMemoryDecorations } from "../../memory-decorations"; - -/** The autopilot fixture (leaks ~1.5 MiB at the same site every loop pass). */ -const FIXTURE = path.resolve(__dirname, "../../src/test/fixtures/memory_autopilot_loop.py"); -/** A run-forever allocator (no breakpoints) — for interval-mode coverage. */ -const BUSY_FIXTURE = path.resolve(__dirname, "../../src/test/fixtures/memory_busy.py"); -/** 1-based leak/allocation site: `CACHE.append("x" * 5000)`. */ -const ALLOC_LINE = 23; -/** 1-based loop breakpoint: `total = leak_round(index)`. */ -const BP_LINE = 31; -/** The purple memory-allocation palette and the leak-confidence palette. */ -const MEMORY_PALETTE = ["#c084fc", "#a78bfa", "#8b5cf6", "#7c3aed"]; -const LEAK_PALETTE = ["#ef4444", "#f87171", "#fb923c", "#a78bfa"]; -/** How long to let a (wrongly) enabled auto-capture fire before asserting none did. */ -const QUIET_SETTLE_MS = 1500; -/** Max passes to drive before giving up on HIGH (seed + 3 growths needs 4). */ -const MAX_PASSES = 7; - -/** Profiler config keys this suite toggles; reset to default after each test. */ -const CONFIG_KEYS = ["autoSnapshotOnPause", "autoSnapshot", "autoSnapshotInterval"]; - -async function setProfilerConfig(key: string, value: unknown): Promise<void> { - await vscode.workspace - .getConfiguration("basilisk.profiler") - .update(key, value, vscode.ConfigurationTarget.Global); -} - -async function resetProfilerConfig(): Promise<void> { - for (const key of CONFIG_KEYS) { - await setProfilerConfig(key, undefined); - } -} - -/** Open the fixture as the visible editor so the autopilot's decorations land. */ -async function showFixture(file: string): Promise<void> { - const doc = await vscode.workspace.openTextDocument(vscode.Uri.file(file)); - await vscode.window.showTextDocument(doc, { preview: false }); -} - -/** - * Budget for the autopilot to accumulate captures. - * - * NOT `SESSION_WAIT_MS`, which is documented for a debug session to start, - * stop or pause. A capture is a whole round trip on top of that — pause the - * program, run a tracemalloc snapshot through a DAP `evaluate`, resume — and - * these fixtures deliberately burn CPU while it happens, so on a small CI - * runner one capture can cost more than the entire session budget. Reusing the - * session budget here asked for several of those inside the time allowed for - * one pause; on win32 the interval test recorded ZERO captures against it - * ([VSIX-CI-PLATFORM-COVERAGE-CLASSES]). - * - * Sized to fit several waits inside each test's own Mocha budget (150s for the - * money flow, 90s for interval mode), so a genuinely stuck autopilot still - * fails here — naming the capture count it reached — rather than as a bare - * Mocha timeout. Nothing asserts how QUICKLY a capture arrives. - */ -const AUTO_CAPTURE_WAIT_MS = 40_000; - -/** Wait until the autopilot has recorded at least `count` automatic captures. */ -async function waitForAutoCaptures(count: number): Promise<void> { - await pollUntilResult({ - fn: async () => recordedAutopilotCaptures().length, - predicate: (n) => n >= count, - timeoutMs: AUTO_CAPTURE_WAIT_MS, - intervalMs: POLL_MS, - }); -} - -/** Whether any recorded capture has escalated to HIGH confidence. */ -function sawHighConfidence(): boolean { - return recordedAutopilotCaptures().some((capture) => capture.maxConfidence === "HIGH"); -} - -/** Launch a plain (non-track-on-launch) basilisk-debug session on `file`. */ -async function launchDebug(name: string, file: string): Promise<void> { - const started = await vscode.debug.startDebugging(undefined, { - name, - type: "basilisk-debug", - request: "launch", - program: file, - stopOnEntry: false, - justMyCode: true, - console: "internalConsole", - }); - assert.ok(started, "the debug session must launch"); -} - -// ── Test bodies (top-level so the suite arrow stays small) ─────────────────── - -async function moneyFlowAutoEscalatesToHigh(): Promise<void> { - // The whole pitch: ONE breakpoint in the leaking loop, launch "Run & Track - // Memory", and just press Continue. The autopilot snapshots+diffs each pause — - // the test never invokes snapshot/diff. - await setProfilerConfig("autoSnapshotOnPause", true); - await showFixture(FIXTURE); - clearMemoryDecorations(); - setBreakpoints(FIXTURE, [BP_LINE]); - - const started = await vscode.debug.startDebugging(undefined, buildProfileLaunchConfig("memory", FIXTURE)); - assert.ok(started, "the Run & Track Memory launch must start"); - - // Tracking auto-starts at the entry pause, the program runs to the first loop - // breakpoint, and the autopilot takes its first automatic capture there. - await pollUntilResult({ - fn: async () => activeMemorySession(), - predicate: (sessionId) => sessionId !== undefined, - timeoutMs: SESSION_WAIT_MS, - intervalMs: POLL_MS, - }); - await waitForPause(); - await waitForAutoCaptures(1); - - // Press Continue and let the autopilot capture each pass until a site escalates - // to HIGH (seed diff + 3 consecutive growth diffs). The TEST only resumes. - for (let capture = 2; capture <= MAX_PASSES && !sawHighConfidence(); capture += 1) { - await resume(); - await waitForAutoCaptures(capture); - } - - assertEscalatedToHigh(); - assertAutoPaintedDecorations(); - assertSingleLeakOffer(); - - await vscode.debug.stopDebugging(); - await waitForSessionEnd(); -} - -/** The automation climbed the confidence ladder by itself, attributing the leak line. */ -function assertEscalatedToHigh(): void { - assert.ok( - sawHighConfidence(), - `the autopilot must escalate the leak to HIGH on Continue alone, got: ${ - recordedAutopilotCaptures().map((c) => c.maxConfidence).join(" → ")}`, - ); - const high = recordedAutopilotCaptures().find((capture) => capture.maxConfidence === "HIGH"); - assert.ok( - high?.leakLines.includes(ALLOC_LINE), - `the HIGH capture must attribute the real leak line ${ALLOC_LINE}, got: ${JSON.stringify(high)}`, - ); -} - -/** The purple track AND the HIGH leak badge are painted on the fixture — automatically. */ -function assertAutoPaintedDecorations(): void { - const applied = appliedMemoryDecorations().filter(sameFile(FIXTURE)); - assert.ok( - applied.some((entry) => entry.line === ALLOC_LINE && MEMORY_PALETTE.includes(entry.color)), - `the leak line must wear an auto-painted purple track, got: ${JSON.stringify(applied)}`, - ); - assert.ok( - applied.some( - (entry) => - entry.line === ALLOC_LINE && - LEAK_PALETTE.includes(entry.color) && - entry.contentText.includes("HIGH"), - ), - `the leak line must wear an auto-painted HIGH leak badge, got: ${JSON.stringify(applied)}`, - ); -} - -/** Exactly one proactive leak action is offered ([PROFILE-MEMORY-LEAK-ACTIONS]). */ -function assertSingleLeakOffer(): void { - const offers = recordedLeakOffers(); - assert.strictEqual(offers.length, 1, `exactly one leak action must be offered, got: ${JSON.stringify(offers)}`); - assert.strictEqual(offers[0]?.line, ALLOC_LINE, "the offer must point at the leak line"); - assert.strictEqual(offers[0]?.confidence, "HIGH", "the offer must carry the HIGH confidence that triggered it"); -} - -async function offSwitchSuppressesAutoCapture(): Promise<void> { - await setProfilerConfig("autoSnapshotOnPause", false); - await showFixture(FIXTURE); - setBreakpoints(FIXTURE, [BP_LINE]); - await launchDebug("Autopilot off", FIXTURE); - - // Start tracking by hand at the first pause (resets the autopilot ledger). - await waitForPause(); - await vscode.commands.executeCommand("basilisk.memoryStart"); - assert.ok(activeMemorySession() !== undefined, "tracking must start"); - - // Continue to the next pass and give any (erroneous) auto-capture time to fire. - await resume(); - await waitForPause(); - await delay(QUIET_SETTLE_MS); - - assert.strictEqual( - recordedAutopilotCaptures().length, - 0, - `no auto-capture must happen when autoSnapshotOnPause is off, got: ${JSON.stringify(recordedAutopilotCaptures())}`, - ); - - await vscode.debug.stopDebugging(); - await waitForSessionEnd(); -} - -async function intervalModeCapturesRunningProgram(): Promise<void> { - // Wire the (previously dead) interval settings: snapshot every second, no - // pause-based capture, on a program that never stops on its own. - await setProfilerConfig("autoSnapshotOnPause", false); - await setProfilerConfig("autoSnapshot", true); - await setProfilerConfig("autoSnapshotInterval", 1); - vscode.debug.removeBreakpoints(vscode.debug.breakpoints); - await showFixture(BUSY_FIXTURE); - await launchDebug("Autopilot interval", BUSY_FIXTURE); - await pollUntilResult({ - fn: async () => vscode.debug.activeDebugSession, - predicate: (session) => session !== undefined, - timeoutMs: SESSION_WAIT_MS, - intervalMs: POLL_MS, - }); - - // Start tracking on the running program (auto-pause → inject → resume); the - // interval timer then captures on its own. - await vscode.commands.executeCommand("basilisk.memoryStart"); - assert.ok(activeMemorySession() !== undefined, "tracking must start on the run-forever program"); - - await waitForAutoCaptures(2); - const captures = recordedAutopilotCaptures(); - assert.ok( - captures.every((capture) => capture.trigger === "interval"), - `every capture must be interval-triggered, got: ${captures.map((c) => c.trigger).join(", ")}`, - ); - - await vscode.debug.stopDebugging(); - await waitForSessionEnd(); -} - -async function pickerIsPopulatedFromRealSymbols(): Promise<void> { - // The picker offers the user's OWN classes (via the real documentSymbol - // provider) plus container builtins — never a blank "type a name" box. - await showFixture(FIXTURE); - const uri = vscode.Uri.file(FIXTURE); - - const candidates = await pollUntilResult({ - fn: async () => gatherReferenceTypeCandidates(uri), - predicate: (types) => types.includes("Widget"), - timeoutMs: SESSION_WAIT_MS, - intervalMs: POLL_MS, - }); - - assert.ok( - candidates.includes("Widget"), - `the picker must offer the file's own class from real symbols, got: ${candidates.join(", ")}`, - ); - for (const builtin of ["dict", "list", "set", "tuple"]) { - assert.ok(candidates.includes(builtin), `the picker must offer the container builtin ${builtin}`); - } -} - -suite("Memory autopilot — real end-to-end", () => { - let tmpDir = ""; - - suiteSetup(async function () { - this.timeout(60_000); - const result = await setupLspTestSuite("basilisk-mem-autopilot-"); - tmpDir = result.tmpDir; - assert.ok(fs.existsSync(FIXTURE), `autopilot fixture must exist: ${FIXTURE}`); - }); - - suiteTeardown(async function () { - this.timeout(30_000); - vscode.debug.removeBreakpoints(vscode.debug.breakpoints); - clearMemoryDecorations(); - await resetProfilerConfig(); - await closeAllEditors(); - teardownLspTestSuite(tmpDir); - }); - - teardown(async function () { - this.timeout(30_000); - if (vscode.debug.activeDebugSession !== undefined) { - await vscode.debug.stopDebugging(); - await waitForSessionEnd(); - } - await vscode.commands.executeCommand("basilisk.memoryStop"); - await resetProfilerConfig(); - vscode.debug.removeBreakpoints(vscode.debug.breakpoints); - }); - - test("the money flow: Run & Track Memory + a loop breakpoint auto-escalates a leak to HIGH on Continue alone", async function () { - this.timeout(150_000); - await moneyFlowAutoEscalatesToHigh(); - }); - - test("the off switch: with autoSnapshotOnPause disabled, a pause is NOT auto-captured", async function () { - this.timeout(90_000); - await offSwitchSuppressesAutoCapture(); - }); - - test("interval mode: a running program with no breakpoint is auto-captured on a timer", async function () { - this.timeout(90_000); - await intervalModeCapturesRunningProgram(); - }); - - test("reference-graph picker is populated from the file's real document symbols (no free-text)", async function () { - this.timeout(60_000); - await pickerIsPopulatedFromRealSymbols(); - }); -}); diff --git a/vscode-extension/src/test/suite/memory-discoverability.test.ts b/vscode-extension/src/test/suite/memory-discoverability.test.ts deleted file mode 100644 index 586fea061..000000000 --- a/vscode-extension/src/test/suite/memory-discoverability.test.ts +++ /dev/null @@ -1,342 +0,0 @@ -// Tests for [PROFILE-MEMORY-DISCOVERY]. See docs/specs/LSP-PROFILING-SPEC.md#PROFILE-MEMORY-DISCOVERY -// -// Memory-profiler discoverability (#263): starting a memory-tracking run drops -// the user in the Debug view, so the snapshot/compare actions must be VISIBLE -// there and everywhere the flow narrates them — never palette-only. These -// tests assert the four user-facing surfaces: -// -// A. the debug toolbar carries Snapshot / Compare / Stop while tracking -// B. the memory dashboard's "take more snapshots" advice IS a button -// C. every toast that names an action offers that action as a button -// D. the Python Processes panel (the launch surface) can drive the session -// -// The heavier flow mechanics (courier round-trip, autopilot, finalisation) -// live in memory-e2e.test.ts; this suite covers how the user FINDS the flow. - -import * as assert from "assert"; -import * as vscode from "vscode"; -import * as path from "path"; -import { activeMemorySession } from "../../memory-profiler"; -import { - buildMemoryDashboardHtml, - type MemoryDashboardSnapshot, -} from "../../memory-dashboard"; -import * as memoryDashboardModule from "../../memory-dashboard"; -import type { WebviewMessage } from "../../profiler-webview"; -import { SESSION_WAIT_MS, POLL_MS, waitForSessionEnd } from "./debug-e2e-helpers"; -import { - pollUntilResult, - setupLspTestSuite, - teardownLspTestSuite, - closeAllEditors, -} from "./test-helpers"; -import { manifestMenus } from "./extension-manifest"; -import { stringField } from "../../unknown-shape"; - -/** One contributes.menus entry from the live manifest. */ -/** A captured notification: its message and the action buttons it offered. */ -interface Toast { - readonly message: string; - readonly actions: string[]; -} - -/** The label of one showInformationMessage item (string or MessageItem). */ -function actionLabel(item: unknown): string | undefined { - if (typeof item === "string") { return item; } - return stringField(item, "title"); -} - -/** Run `body` while capturing every information toast (message + actions). */ -async function captureToasts(body: () => Promise<void>): Promise<Toast[]> { - const toasts: Toast[] = []; - const win = vscode.window as { - showInformationMessage: typeof vscode.window.showInformationMessage; - }; - const original = win.showInformationMessage; - win.showInformationMessage = async (message: string, ...items: unknown[]) => { - const actions = items - .map(actionLabel) - .filter((label): label is string => label !== undefined); - toasts.push({ message, actions }); - return undefined; - }; - try { - await body(); - } finally { - win.showInformationMessage = original; - } - return toasts; -} - -/** A minimal dashboard snapshot (fresh session: one capture, no diff yet). */ -function dashboardSnapshot(): MemoryDashboardSnapshot { - return { - memorySessionId: "mem-disc-1", - snapshotId: "snap-1", - currentMemory: 1_048_576, - peakMemory: 2_097_152, - gcObjects: 1200, - gcCounts: [700, 12, 3], - topAllocations: [{ file: "/app/main.py", line: 10, size: 4096, count: 8 }], - timeline: [], - heapProfilePath: "", - }; -} - -// ── A. Debug toolbar ──────────────────────────────────────────────────── - -/** - * Starting a memory run focuses the Debug view (stopOnEntry breaks there), so - * the actions must be ON the debug toolbar — the one surface the user is - * guaranteed to be looking at. Palette-only actions are invisible. - */ -function assertDebugToolbarCarriesMemoryActions(): void { - const toolbar = manifestMenus()["debug/toolBar"] ?? []; - for (const command of ["basilisk.memorySnapshot", "basilisk.memoryDiff", "basilisk.memoryStop"]) { - const entry = toolbar.find((candidate) => candidate.command === command); - assert.ok( - entry !== undefined, - `"${command}" must be contributed to debug/toolBar — the user lands in the ` + - `Debug view with no visible memory controls (#263); got: ${JSON.stringify(toolbar)}`, - ); - assert.ok( - entry.when.includes("basilisk.memoryTracking"), - `"${command}" on the debug toolbar must only show while tracking, when: ${entry.when}`, - ); - assert.ok( - entry.when.includes("debugType == basilisk-debug"), - `"${command}" must not appear on other debuggers' toolbars, when: ${entry.when}`, - ); - // The profiling UI ships enabled — a leftover reference to the removed - // availability-gate key would evaluate falsy and hide the toolbar buttons - // from every shipped user. - assert.ok( - !entry.when.includes("basilisk.profilingEnabled"), - `"${command}" must not reference the removed profiling UI gate key, when: ${entry.when}`, - ); - } -} - -// ── D. Launch-panel parity ────────────────────────────────────────────── - -/** - * The panel the user clicked "Run & Track Memory" in currently collapses to a - * lone Stop button while tracking; it must offer the whole loop. - */ -function assertLaunchPanelDrivesTheSession(): void { - const title = (manifestMenus()["view/title"] ?? []).filter( - (entry) => entry.when.includes("basilisk.pythonProcesses"), - ); - for (const command of ["basilisk.memorySnapshot", "basilisk.memoryDiff"]) { - const entry = title.find((candidate) => candidate.command === command); - assert.ok( - entry !== undefined, - `"${command}" must join Stop on the pythonProcesses view title while tracking (#263); ` + - `got: ${JSON.stringify(title.map((item) => item.command))}`, - ); - assert.ok( - entry.when.includes("basilisk.memoryTracking"), - `"${command}" on the panel must only show while tracking, when: ${entry.when}`, - ); - } -} - -// ── B. The dashboard's advice is actionable ───────────────────────────── - -/** - * A fresh session's dashboard (one snapshot, no diff) tells the user to take - * more snapshots in two empty states — it must also let them DO it. - */ -function assertDashboardAdviceIsActionable(): void { - const html = buildMemoryDashboardHtml(dashboardSnapshot()); - assert.ok( - html.includes("Take multiple snapshots") || html.includes("Take more snapshots"), - "precondition: the dashboard advises taking snapshots (its empty states)", - ); - assert.ok( - html.includes("takeSnapshot"), - "the dashboard must wire a Take Snapshot action that posts 'takeSnapshot' back to the extension (#263)", - ); - assert.ok( - html.includes("compareSnapshots"), - "the dashboard must wire a Compare Snapshots action that posts 'compareSnapshots' back to the extension (#263)", - ); - assert.ok( - html.includes("Take Snapshot"), - "the Take Snapshot action must be a visible, labelled button", - ); - assert.ok( - html.includes("Compare"), - "the Compare action must be a visible, labelled button", - ); -} - -/** - * The webview buttons post messages; the extension side must translate them - * into the real basilisk.memorySnapshot / basilisk.memoryDiff runs. - */ -/** - * Whether the module's export is the dashboard's message router. - * - * A predicate rather than an assertion: this suite deliberately reads - * `memory-dashboard` as an untyped module so #263 stays a runtime check that - * the export exists, and a predicate keeps that check while still giving the - * caller something callable. - */ -function isMessageRouter(value: unknown): value is (msg: WebviewMessage) => boolean { - return typeof value === "function"; -} - -async function assertDashboardMessagesRouteToCommands(): Promise<void> { - const { handleMemoryDashboardMessage } = memoryDashboardModule as { - handleMemoryDashboardMessage?: unknown; - }; - assert.ok( - isMessageRouter(handleMemoryDashboardMessage), - "memory-dashboard must export handleMemoryDashboardMessage routing the action buttons (#263)", - ); - const route = handleMemoryDashboardMessage; - - const executed: string[] = []; - const commandsApi = vscode.commands as { - executeCommand: typeof vscode.commands.executeCommand; - }; - const original = commandsApi.executeCommand; - // Swapping in a recorder for `executeCommand` means producing its generic - // return from nothing — no runtime check can do that, so the double keeps - // one explained assertion. - commandsApi.executeCommand = (async (command: string) => { - executed.push(command); - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- see above. - return undefined as never; - }) as typeof vscode.commands.executeCommand; - try { - assert.strictEqual(route({ type: "takeSnapshot" }), true, "'takeSnapshot' must be handled"); - assert.strictEqual(route({ type: "compareSnapshots" }), true, "'compareSnapshots' must be handled"); - assert.strictEqual(route({ type: "unrelated" }), false, "unknown messages must not be claimed"); - } finally { - commandsApi.executeCommand = original; - } - assert.deepStrictEqual( - executed, - ["basilisk.memorySnapshot", "basilisk.memoryDiff"], - "the dashboard actions must run the real snapshot/compare commands", - ); -} - -// ── C. Toasts offer the actions they name ─────────────────────────────── - -/** - * Stopping without a capture says "Take a snapshot while paused…" — the toast - * must carry a button for that, not point at an invisible palette. - */ -async function assertStopToastOffersTheActionItDemands(): Promise<void> { - const toasts = await captureToasts(async () => { - await vscode.commands.executeCommand("basilisk.memoryStop"); - }); - const stopToast = toasts.find((toast) => /no snapshot was taken/i.test(toast.message)); - assert.ok( - stopToast !== undefined, - `stopping with no capture must explain itself, got: ${JSON.stringify(toasts)}`, - ); - assert.ok( - stopToast.actions.length > 0 && - stopToast.actions.some((label) => /snapshot|memory/i.test(label)), - `the stop toast tells the user to take a snapshot but offers no way to do it (#263) — ` + - `actions: ${JSON.stringify(stopToast.actions)}`, - ); -} - -/** - * Real flow: launch the run-forever allocator, start tracking via the real - * command, and assert the started toast is actionable at the exact moment the - * user is disoriented (#263). - */ -async function assertStartedToastOffersTakeSnapshot(): Promise<void> { - vscode.debug.removeBreakpoints(vscode.debug.breakpoints); - const fixture = path.resolve(__dirname, "../../src/test/fixtures/memory_busy.py"); - const started = await vscode.debug.startDebugging(undefined, { - name: "Memory discoverability E2E", - type: "basilisk-debug", - request: "launch", - program: fixture, - stopOnEntry: false, - justMyCode: true, - console: "internalConsole", - }); - assert.ok(started, "the debug session must launch"); - await pollUntilResult({ - fn: async () => vscode.debug.activeDebugSession, - predicate: (session) => session !== undefined, - timeoutMs: SESSION_WAIT_MS, - intervalMs: POLL_MS, - }); - - const toasts = await captureToasts(async () => { - await vscode.commands.executeCommand("basilisk.memoryStart"); - }); - assert.ok(activeMemorySession() !== undefined, "tracking must start for the toast to matter"); - const startedToast = toasts.find((toast) => /memory tracking started/i.test(toast.message)); - assert.ok( - startedToast !== undefined, - `starting must announce itself, got: ${JSON.stringify(toasts)}`, - ); - assert.ok( - startedToast.actions.some((label) => /snapshot/i.test(label)), - `the started toast is the moment the user is dropped into the Debug view — it must ` + - `offer Take Snapshot right there (#263), actions: ${JSON.stringify(startedToast.actions)}`, - ); - - await vscode.commands.executeCommand("basilisk.memoryStop"); - await vscode.debug.stopDebugging(); - await waitForSessionEnd(); -} - -suite("Memory discoverability — actions visible where the user lands (#263)", () => { - let tmpDir = ""; - - suiteSetup(async function () { - this.timeout(60_000); - const result = await setupLspTestSuite("basilisk-mem-disc-"); - tmpDir = result.tmpDir; - }); - - suiteTeardown(async function () { - this.timeout(30_000); - vscode.debug.removeBreakpoints(vscode.debug.breakpoints); - await closeAllEditors(); - teardownLspTestSuite(tmpDir); - }); - - teardown(async () => { - if (vscode.debug.activeDebugSession !== undefined) { - await vscode.debug.stopDebugging(); - await waitForSessionEnd(); - } - }); - - test("the debug toolbar offers Snapshot / Compare / Stop while memory tracking is active", () => { - assertDebugToolbarCarriesMemoryActions(); - }); - - test("the Python Processes panel can drive the session it launched — Snapshot and Compare beside Stop", () => { - assertLaunchPanelDrivesTheSession(); - }); - - test("the memory dashboard's 'take more snapshots' advice is a button, not homework", () => { - assertDashboardAdviceIsActionable(); - }); - - test("the dashboard's action messages route to the real memory commands", async () => { - await assertDashboardMessagesRouteToCommands(); - }); - - test("the 'no snapshot was taken' stop toast offers the action it demands", async () => { - await assertStopToastOffersTheActionItDemands(); - }); - - test("the 'memory tracking started' toast offers Take Snapshot on a real session", async function () { - this.timeout(60_000); - await assertStartedToastOffersTakeSnapshot(); - }); -}); diff --git a/vscode-extension/src/test/suite/memory-e2e.test.ts b/vscode-extension/src/test/suite/memory-e2e.test.ts deleted file mode 100644 index a6c177129..000000000 --- a/vscode-extension/src/test/suite/memory-e2e.test.ts +++ /dev/null @@ -1,568 +0,0 @@ -// Tests for [PROFILE-MEMORY-HOWTO] + [PROFILE-MEMORY-INGEST] + [PROFILE-PROCESSES-LAUNCH-FILE]. -// See docs/specs/LSP-PROFILING-SPEC.md#PROFILE-MEMORY-HOWTO -// -// REAL memory-profiling end-to-end: a real debugpy session pauses a real -// Python program, the editor-as-courier round-trip injects tracemalloc and -// posts the output back via `basilisk.memory.ingest`, and the assertions -// cover what the user actually sees — snapshot allocations attributed to the -// real allocation line, leak suspicion on growth, the purple heat-map track -// (via the applied-decoration ledger), the `.heapprofile` for VS Code's -// built-in viewer, and the "Run & Track Memory (Current File)" auto-start. - -import { delay } from "../../timeouts"; -import * as assert from "assert"; -import * as vscode from "vscode"; -import * as fs from "fs"; -import * as path from "path"; -import { currentStoppedFrameId } from "../../dap-evaluate"; -import { activeMemorySession, memoryStatusText } from "../../memory-profiler"; -import { getStore } from "../../extension"; -import { - SESSION_WAIT_MS, - POLL_MS, - setBreakpoints, - waitForPause, - resume, - waitForSessionEnd, - memoryRoundTrip, - type IngestResult, -} from "./debug-e2e-helpers"; -import { recordArrayField, recordField, stringField } from "../../unknown-shape"; -import { recordedOperations } from "../../progress-ops"; -import { buildProfileLaunchConfig } from "../../process-launch"; -import { - applyLeakDecorations, - applyMemoryDecorations, - appliedMemoryDecorations, - clearMemoryDecorations, - type MemoryDiffResult, - type MemorySnapshotResult, -} from "../../memory-decorations"; -import { - pollUntilResult, - setupLspTestSuite, - teardownLspTestSuite, - closeAllEditors, - isSamePath, - sameFile, -} from "./test-helpers"; - -/** The memory fixture, opened from the real fixtures directory. */ -const FIXTURE = path.resolve(__dirname, "../../src/test/fixtures/memory_growth.py"); -/** 1-based allocation site: `CACHE.append("x" * 5000)`. */ -const ALLOC_LINE = 9; -/** Breakpoints between allocation chunks in main(). */ -const BP_AFTER_CHUNK1 = 15; -const BP_AFTER_CHUNK2 = 16; -const BP_AFTER_CHUNK3 = 17; -/** The memory decoration palette (purple track + leak colors). */ -const MEMORY_PALETTE = ["#c084fc", "#a78bfa", "#8b5cf6", "#7c3aed"]; -const LEAK_PALETTE = ["#ef4444", "#f87171", "#fb923c", "#a78bfa"]; - -// The `.heapprofile` is bytes read off disk, so the walkers below take -// `unknown` and narrow field by field rather than asserting a `HeapNode` -// shape nothing has checked — a malformed artifact then fails the assertion -// it should, instead of type-erroring deeper in the walk. - -/** Depth of the heapprofile call tree (root counts as 1). */ -function heapTreeDepth(node: unknown): number { - const children = recordArrayField(node, "children"); - return 1 + children.reduce((deepest, child) => Math.max(deepest, heapTreeDepth(child)), 0); -} - -/** Every `callFrame.url` in the heapprofile tree. */ -function heapNodeUrls(node: unknown): string[] { - const here = stringField(recordField(node, "callFrame"), "url"); - const childUrls = recordArrayField(node, "children").flatMap(heapNodeUrls); - return here !== undefined && here !== "" ? [here, ...childUrls] : childUrls; -} - -/** Assert the snapshot's user-facing surface: heapprofile artifact + purple track. */ -function assertSnapshotSurface(snapshot: MemorySnapshotResult & IngestResult): void { - assert.ok(snapshot.currentMemory > 0, "tracemalloc must report live memory"); - assert.ok( - snapshot.topAllocations.some((a) => a.file.endsWith("memory_growth.py") && a.line === ALLOC_LINE), - `the real allocation site (line ${ALLOC_LINE}) must be attributed, got: ${ - snapshot.topAllocations.map((a) => `${path.basename(a.file)}:${a.line}`).join(", ")}`, - ); - - // Native .heapprofile artifact for the built-in viewer ([PROFILE-NATIVE]). - const heapProfilePath = snapshot.heapProfilePath; - assert.ok(typeof heapProfilePath === "string" && heapProfilePath !== "", "heapProfilePath must be returned"); - assert.ok(fs.existsSync(heapProfilePath), ".heapprofile must be written to disk"); - const heapprofile: unknown = JSON.parse(fs.readFileSync(heapProfilePath, "utf8")); - const head = recordField(heapprofile, "head"); - assert.ok(head !== undefined, ".heapprofile must have a head tree"); - - // [PROFILE-MEMORY-FINAL] The profile must be a real call tree of the USER's - // program — genuine depth (not a flat by-line list) and zero debugger frames. - assert.ok(heapTreeDepth(head) >= 3, `the .heapprofile must be a real call tree with depth, got depth ${heapTreeDepth(head)}`); - const urls = heapNodeUrls(head); - assert.ok( - urls.some((url) => url.endsWith("memory_growth.py")), - "the user's program must appear in the call tree", - ); - assert.ok( - !urls.some((url) => /pydevd|debugpy|_pydev|tracemalloc\.py|<frozen|<string>/.test(url)), - `the call tree must be filtered of debugger/runtime frames, got: ${[...new Set(urls)].map((u) => path.basename(u)).join(", ")}`, - ); - - // The purple memory track is really painted ([PROFILE-VIS-HEATMAP]). - applyMemoryDecorations(snapshot); - const memApplied = appliedMemoryDecorations().filter(sameFile(FIXTURE)); - assert.ok(memApplied.length > 0, "snapshot allocations must paint the open fixture"); - assert.ok( - memApplied.some((entry) => entry.line === ALLOC_LINE && MEMORY_PALETTE.includes(entry.color)), - `the allocation line must wear a purple-palette decoration, got: ${JSON.stringify(memApplied)}`, - ); - assert.ok( - memApplied.some((entry) => /(B|KB|MB|GB) allocated/.test(entry.contentText)), - "memory decorations must show allocation sizes", - ); -} - -/** - * Launch the run-forever fixture (no breakpoints) and probe - * `currentStoppedFrameId` `probes` times while it runs: every probe must - * decline, with the session still alive to prove the timing. - */ -async function probeRunningDebuggeeForFrames(probes: number): Promise<void> { - vscode.debug.removeBreakpoints(vscode.debug.breakpoints); - const runningFixture = path.resolve(__dirname, "../../src/test/fixtures/busy_wait.py"); - const started = await vscode.debug.startDebugging(undefined, { - name: "Memory E2E running probe", - type: "basilisk-debug", - request: "launch", - program: runningFixture, - stopOnEntry: false, - justMyCode: true, - console: "internalConsole", - }); - assert.ok(started, "the debug session must launch"); - await pollUntilResult({ - fn: async () => vscode.debug.activeDebugSession, - predicate: (session) => session !== undefined, - timeoutMs: SESSION_WAIT_MS, - intervalMs: POLL_MS, - }); - - for (let probe = 0; probe < probes; probe += 1) { - const frameId = await currentStoppedFrameId(); - assert.ok( - vscode.debug.activeDebugSession !== undefined, - "the program must still be running while probing", - ); - assert.strictEqual( - frameId, - null, - "a running debuggee must never yield a frame id (debugpy samples stackTrace for running threads)", - ); - await delay(200); - } - - await vscode.debug.stopDebugging(); - await waitForSessionEnd(); -} - -/** - * Launch the run-forever allocator WITHOUT breakpoints, start tracking and - * snapshot via the real command handlers while it runs, and assert the - * auto-pause/auto-resume surface: a session is minted, the live allocation - * line is attributed, and the program is still running afterwards. - */ -async function trackAndSnapshotRunningProgram(): Promise<void> { - vscode.debug.removeBreakpoints(vscode.debug.breakpoints); - const busyFixture = path.resolve(__dirname, "../../src/test/fixtures/memory_busy.py"); - const busyAllocLine = 12; // CACHE.append("x" * 5000) - const doc = await vscode.workspace.openTextDocument(vscode.Uri.file(busyFixture)); - await vscode.window.showTextDocument(doc, { preview: false }); - - const started = await vscode.debug.startDebugging(undefined, { - name: "Memory E2E auto-pause", - type: "basilisk-debug", - request: "launch", - program: busyFixture, - stopOnEntry: false, - justMyCode: true, - console: "internalConsole", - }); - assert.ok(started, "the debug session must launch"); - await pollUntilResult({ - fn: async () => vscode.debug.activeDebugSession, - predicate: (session) => session !== undefined, - timeoutMs: SESSION_WAIT_MS, - intervalMs: POLL_MS, - }); - - // Start tracking while RUNNING — must auto-pause, inject, auto-resume. - await vscode.commands.executeCommand("basilisk.memoryStart"); - assert.ok( - activeMemorySession() !== undefined, - "Start Memory Tracking on a running program must mint a session (auto-pause)", - ); - - // Let the program allocate under tracemalloc, then snapshot while RUNNING. - await delay(1000); - clearMemoryDecorations(); - const opsBefore = recordedOperations().length; - await vscode.commands.executeCommand("basilisk.memorySnapshot"); - const applied = appliedMemoryDecorations().filter(sameFile(busyFixture)); - assert.ok( - applied.some((entry) => entry.line === busyAllocLine), - `the snapshot must attribute the live allocation line ${busyAllocLine}, got: ${JSON.stringify(applied)}`, - ); - - // [PROFILE-UX-PROGRESS] The snapshot must run under a progress notification - // that narrates its stages and closes on completion — never a silent wait. - const snapshotOps = recordedOperations().slice(opsBefore); - const beginIdx = snapshotOps.indexOf("begin:Basilisk: Taking memory snapshot"); - const endIdx = snapshotOps.indexOf("end:Basilisk: Taking memory snapshot"); - assert.ok(beginIdx !== -1, `snapshot must show progress, ops: ${snapshotOps.join(" | ")}`); - assert.ok(endIdx > beginIdx, "the progress notification must close when the snapshot completes"); - assert.ok( - snapshotOps.includes("step:Basilisk: Taking memory snapshot:Pausing the program…"), - `stage messages must narrate the auto-pause, ops: ${snapshotOps.join(" | ")}`, - ); - - // The program must have been resumed — still alive after the snapshot. - assert.ok( - vscode.debug.activeDebugSession !== undefined, - "the program must keep running after an auto-paused snapshot", - ); - await vscode.debug.stopDebugging(); - await waitForSessionEnd(); -} - -/** - * Drive the real "Run & Track Memory (Current File)" launch on the allocating - * fixture with NO breakpoint, let it run to completion, and assert the run - * finalises into a VISIBLE result: the at-exit snapshot's live allocations - * paint the purple track on the real allocation line, and tracking settles back - * to idle. This is the #146 dead-end — a run that ended in nothing the user - * could look at ([PROFILE-MEMORY-FINAL]). - */ -async function runTrackMemoryToCompletionAndAssertResult(): Promise<void> { - vscode.debug.removeBreakpoints(vscode.debug.breakpoints); - clearMemoryDecorations(); - // The fixture must be the open editor so its at-exit allocations paint. - const doc = await vscode.workspace.openTextDocument(vscode.Uri.file(FIXTURE)); - await vscode.window.showTextDocument(doc, { preview: false }); - - const started = await vscode.debug.startDebugging(undefined, buildProfileLaunchConfig("memory", FIXTURE)); - assert.ok(started, "the metric-explicit memory launch must start"); - - // The auto-flow must mint a memory session at the entry pause… - await pollUntilResult({ - fn: async () => activeMemorySession(), - predicate: (sessionId) => sessionId !== undefined, - timeoutMs: SESSION_WAIT_MS, - intervalMs: POLL_MS, - }); - - // …resume the debuggee so the program actually runs to completion… - await waitForSessionEnd(); - - // …and the session end must finalise into a visible result: the at-exit - // snapshot's live allocations paint the purple memory track on the real - // allocation line. - const memApplied = await pollUntilResult({ - fn: async () => appliedMemoryDecorations().filter(sameFile(FIXTURE)), - predicate: (entries) => - entries.some((entry) => entry.line === ALLOC_LINE && MEMORY_PALETTE.includes(entry.color)), - timeoutMs: SESSION_WAIT_MS, - intervalMs: POLL_MS, - }); - assert.ok( - memApplied.some((entry) => entry.line === ALLOC_LINE && MEMORY_PALETTE.includes(entry.color)), - `the run must end in a visible memory result — a purple track on allocation line ${ALLOC_LINE}, got: ${JSON.stringify(memApplied)}`, - ); - - // No stale state: tracking settles back to idle once the run is finalised - // ([PROFILE-PROCESSES-REACTIVE]); the debuggee is gone, so "tracking" must not linger. - await pollUntilResult({ - fn: async () => activeMemorySession(), - predicate: (sessionId) => sessionId === undefined, - timeoutMs: SESSION_WAIT_MS, - intervalMs: POLL_MS, - }); - assert.strictEqual( - activeMemorySession(), - undefined, - "tracking must settle to idle after the run is finalised into a result", - ); -} - -/** - * Track a run-forever program, then launch and terminate an UNRELATED debug - * session. The unrelated session ending must NOT finalise/tear down the live - * tracking — only the *tracked* session's own termination may - * ([PROFILE-MEMORY-FINAL]). Guards the regression where the terminate handler - * keyed on tracking state alone and destroyed tracking for any session. - */ -async function trackedRunSurvivesUnrelatedSessionEnd(): Promise<void> { - vscode.debug.removeBreakpoints(vscode.debug.breakpoints); - clearMemoryDecorations(); - const busyFixture = path.resolve(__dirname, "../../src/test/fixtures/memory_busy.py"); - const doc = await vscode.workspace.openTextDocument(vscode.Uri.file(busyFixture)); - await vscode.window.showTextDocument(doc, { preview: false }); - - // Session A: a run-forever program with memory tracking active. - let sessionA: vscode.DebugSession | undefined; - const startSub = vscode.debug.onDidStartDebugSession((s) => { if (s.name === "Tracked A") { sessionA = s; } }); - const startedA = await vscode.debug.startDebugging(undefined, { - name: "Tracked A", type: "basilisk-debug", request: "launch", - program: busyFixture, stopOnEntry: false, justMyCode: true, console: "internalConsole", - }); - assert.ok(startedA, "the tracked session must launch"); - await pollUntilResult({ - fn: async () => vscode.debug.activeDebugSession, predicate: (s) => s !== undefined, - timeoutMs: SESSION_WAIT_MS, intervalMs: POLL_MS, - }); - await vscode.commands.executeCommand("basilisk.memoryStart"); // auto-pause → inject → resume - const trackedSession = activeMemorySession(); - assert.ok(trackedSession !== undefined, "tracking must start on the run-forever program"); - - // Session B: an unrelated program that runs to completion and terminates. - let sessionBId: string | undefined; - const bTerminated = new Promise<void>((resolve) => { - const startB = vscode.debug.onDidStartDebugSession((s) => { if (s.name === "Unrelated B") { sessionBId = s.id; } }); - const endB = vscode.debug.onDidTerminateDebugSession((s) => { - if (sessionBId !== undefined && s.id === sessionBId) { startB.dispose(); endB.dispose(); resolve(); } - }); - }); - const startedB = await vscode.debug.startDebugging(undefined, { - name: "Unrelated B", type: "basilisk-debug", request: "launch", - program: FIXTURE, stopOnEntry: false, justMyCode: true, console: "internalConsole", - }); - assert.ok(startedB, "the unrelated session must launch"); - await bTerminated; - await delay(500); // let the terminate handler run - - startSub.dispose(); - assert.ok(sessionBId !== undefined && sessionBId !== sessionA?.id, "the two sessions must be distinct"); - assert.strictEqual( - activeMemorySession(), trackedSession, - "an unrelated debug session ending must not finalise or tear down live memory tracking", - ); - - await vscode.debug.stopDebugging(sessionA); - await pollUntilResult({ - fn: async () => vscode.debug.activeDebugSession, predicate: (s) => s === undefined, - timeoutMs: SESSION_WAIT_MS, intervalMs: POLL_MS, - }); -} - -/** - * Walk the memory status bar through its whole lifecycle on a real session: - * hidden with no debug session, the idle affordance while debugging, the - * starting spinner while a start is in flight, the tracking readout while - * live, and hidden again after the session ends — no stale state on screen - * ([PROFILE-UX-PROGRESS], [PROFILE-PROCESSES-REACTIVE]). - */ -async function statusBarFollowsMemoryLifecycle(): Promise<void> { - vscode.debug.removeBreakpoints(vscode.debug.breakpoints); - assert.strictEqual( - memoryStatusText(), - undefined, - "the memory status bar must be hidden with no debug session", - ); - - const busyFixture = path.resolve(__dirname, "../../src/test/fixtures/memory_busy.py"); - const started = await vscode.debug.startDebugging(undefined, { - name: "Memory status bar E2E", - type: "basilisk-debug", - request: "launch", - program: busyFixture, - stopOnEntry: false, - justMyCode: true, - console: "internalConsole", - }); - assert.ok(started, "the debug session must launch"); - - // Debugging, not yet tracking: the one-click start affordance. - await pollUntilResult({ - fn: async () => memoryStatusText() ?? "", - predicate: (text) => text.includes("Memory") && !text.includes("tracking"), - timeoutMs: SESSION_WAIT_MS, - intervalMs: POLL_MS, - }); - - // A start in flight is never silent: the store's "starting" state renders - // the spinner through the real reactive effect ([PROFILE-UX-PROGRESS]). - const store = getStore(); - assert.ok(store, "store must be initialized"); - store.memoryTrackingStarting(); - assert.ok( - memoryStatusText()?.includes("$(loading~spin)") === true, - `a start in flight must show the spinner, got: ${String(memoryStatusText())}`, - ); - store.memoryTrackingStopped(); - - // Live tracking: the eye + tracking readout. - await vscode.commands.executeCommand("basilisk.memoryStart"); - assert.ok(activeMemorySession() !== undefined, "tracking must start"); - assert.ok( - memoryStatusText()?.includes("Memory: tracking") === true, - `live tracking must show in the status bar, got: ${String(memoryStatusText())}`, - ); - - // Stopped while still debugging: back to the start affordance. - await vscode.commands.executeCommand("basilisk.memoryStop"); - assert.ok( - memoryStatusText()?.includes("Memory") === true && - memoryStatusText()?.includes("tracking") === false, - `stopping must return the idle affordance, got: ${String(memoryStatusText())}`, - ); - - await vscode.debug.stopDebugging(); - await waitForSessionEnd(); - await pollUntilResult({ - fn: async () => memoryStatusText(), - predicate: (text) => text === undefined, - timeoutMs: SESSION_WAIT_MS, - intervalMs: POLL_MS, - }); -} - -/** Assert the diff's user-facing surface: leak suspicion + leak decorations. */ -function assertLeakSurface(diff: MemoryDiffResult & IngestResult): void { - assert.ok(diff.totalGrowth > 0, "allocating a chunk between pauses must register growth"); - assert.ok( - diff.suspectedLeaks.some((leak) => leak.file.endsWith("memory_growth.py") && leak.line === ALLOC_LINE), - `growth at the allocation site must be a suspected leak, got: ${JSON.stringify(diff.suspectedLeaks)}`, - ); - - applyLeakDecorations(diff); - const leakApplied = appliedMemoryDecorations().filter( - (entry) => isSamePath(entry.file, FIXTURE) && LEAK_PALETTE.includes(entry.color) && entry.contentText.includes("leak"), - ); - assert.ok(leakApplied.length > 0, "suspected leaks must paint leak decorations with a confidence badge"); -} - - -suite("Memory profiling — real end-to-end", () => { - let tmpDir = ""; - - suiteSetup(async function () { - this.timeout(60_000); - const result = await setupLspTestSuite("basilisk-mem-e2e-"); - tmpDir = result.tmpDir; - assert.ok(fs.existsSync(FIXTURE), `memory fixture must exist: ${FIXTURE}`); - }); - - suiteTeardown(async function () { - this.timeout(30_000); - vscode.debug.removeBreakpoints(vscode.debug.breakpoints); - clearMemoryDecorations(); - await closeAllEditors(); - teardownLspTestSuite(tmpDir); - }); - - teardown(async () => { - if (vscode.debug.activeDebugSession !== undefined) { - await vscode.debug.stopDebugging(); - await waitForSessionEnd(); - } - await vscode.commands.executeCommand("basilisk.memoryStop"); - }); - - test("courier round-trip: inject tracemalloc, snapshot real allocations, diff growth into leaks, paint the purple track", async function () { - this.timeout(90_000); - const doc = await vscode.workspace.openTextDocument(vscode.Uri.file(FIXTURE)); - await vscode.window.showTextDocument(doc, { preview: false }); - - setBreakpoints(FIXTURE, [BP_AFTER_CHUNK1, BP_AFTER_CHUNK2, BP_AFTER_CHUNK3]); - const started = await vscode.debug.startDebugging(undefined, { - name: "Memory E2E", - type: "basilisk-debug", - request: "launch", - program: FIXTURE, - stopOnEntry: false, - justMyCode: true, - console: "internalConsole", - }); - assert.ok(started, "the debug session must launch"); - - // Pause 1 (chunk 1 allocated): start tracking + seed the diff baseline. - let frameId = await waitForPause(); - const start = await memoryRoundTrip("basilisk.memory.start", undefined, frameId); - assert.strictEqual(start.kind, "ack", "memory.start ingest must acknowledge"); - const memorySessionId = start.memorySessionId; - assert.ok(typeof memorySessionId === "string" && memorySessionId.length > 0, "a memory session must be minted"); - const seeded = await memoryRoundTrip("basilisk.memory.diff", memorySessionId, frameId); - assert.strictEqual(seeded.kind, "diff", "the first diff must self-seed its baseline"); - await resume(); - - // Pause 2 (chunk 2 allocated under tracemalloc): snapshot + diff. - frameId = await waitForPause(); - const snapshot = await memoryRoundTrip<MemorySnapshotResult & IngestResult>( - "basilisk.memory.snapshot", - memorySessionId, - frameId, - ); - assert.strictEqual(snapshot.kind, "snapshot", "snapshot ingest must be kind-tagged"); - assertSnapshotSurface(snapshot); - - const diff = await memoryRoundTrip<MemoryDiffResult & IngestResult>( - "basilisk.memory.diff", - memorySessionId, - frameId, - ); - assert.strictEqual(diff.kind, "diff", "diff ingest must be kind-tagged"); - assertLeakSurface(diff); - await resume(); - - // Pause 3 (chunk 3): consecutive growth keeps the site suspected. - frameId = await waitForPause(); - const diff2 = await memoryRoundTrip<MemoryDiffResult & IngestResult>( - "basilisk.memory.diff", - memorySessionId, - frameId, - ); - assert.ok(diff2.totalGrowth > 0, "the third chunk must register growth too"); - assert.ok(diff2.suspectedLeaks.length > 0, "consecutive growth must keep the leak suspected"); - - await resume(); - await waitForSessionEnd(); - }); - - test("a running (not paused) debuggee yields no evaluable frame — snapshots route to 'pause first'", async function () { - this.timeout(60_000); - // debugpy answers `stackTrace` for a RUNNING thread with a sampled frame - // whose id is NOT evaluable — `evaluate` then fails with "Unable to find - // thread for evaluation" and the user sees a misleading generic error. - // currentStoppedFrameId must therefore refuse to mint a frame id unless a - // `stopped` event marked the thread paused; callers that can pause use - // acquireStoppedFrame's transparent pause/resume instead. - await probeRunningDebuggeeForFrames(5); - }); - - test("memory ops on a RUNNING program: auto-pause, snapshot real allocations, auto-resume", async function () { - this.timeout(60_000); - // IDE-grade behavior: clicking Start/Snapshot while the program runs must - // transparently pause → evaluate → resume, never demand a manual - // breakpoint. This drives the REAL command handlers, not the raw courier. - await trackAndSnapshotRunningProgram(); - }); - - test("Run & Track Memory (Current File): the run finalises into a visible memory result on session end (#146)", async function () { - this.timeout(60_000); - // #146: with no breakpoint the program runs to completion, so the OLD flow - // dead-ended — tracking started, the program exited, and NOTHING was shown - // (no chart / trace / report). The run must instead capture a final snapshot - // as the program exits and finalise it into a result the user can see. - await runTrackMemoryToCompletionAndAssertResult(); - }); - - test("the memory status bar follows the whole session lifecycle — never stale, never silent", async function () { - this.timeout(90_000); - await statusBarFollowsMemoryLifecycle(); - }); - - test("an unrelated debug session ending does not tear down live memory tracking (#146)", async function () { - this.timeout(60_000); - // Regression: the terminate handler must finalise ONLY the tracked session, - // not destroy tracking whenever any debug session in the window ends. - await trackedRunSurvivesUnrelatedSessionEnd(); - }); -}); diff --git a/vscode-extension/src/test/suite/memory-introspection-e2e.test.ts b/vscode-extension/src/test/suite/memory-introspection-e2e.test.ts deleted file mode 100644 index db34bc424..000000000 --- a/vscode-extension/src/test/suite/memory-introspection-e2e.test.ts +++ /dev/null @@ -1,165 +0,0 @@ -// Tests for [PROFILE-MEMORY-HOWTO] + [PROFILE-MEMORY-INGEST]. -// See docs/specs/LSP-PROFILING-SPEC.md#PROFILE-MEMORY-HOWTO -// -// REAL memory-introspection end-to-end: the two courier round-trips that ship a -// UI command but had no real-debuggee coverage — the reference-graph walk -// (basilisk.memory.references) and gc-collect cycle detection -// (basilisk.memory.gcCollect). A real basilisk-debug session pauses a real -// program; the editor injects the gc-introspection script, evaluates it in the -// paused frame, and posts the genuine `__BASILISK_MEM_REFS__`/`__BASILISK_MEM_GC__` -// output back through basilisk.memory.ingest. No mocks: the assertions are over -// the actual retained object graph and the actually-collected finalizer cycle. - -import * as assert from "assert"; -import * as vscode from "vscode"; -import * as fs from "fs"; -import * as path from "path"; -import { - setBreakpoints, - waitForPause, - resume, - waitForSessionEnd, - memoryCourier, - memoryRoundTrip, - type IngestResult, -} from "./debug-e2e-helpers"; -import { setupLspTestSuite, teardownLspTestSuite, closeAllEditors } from "./test-helpers"; - -/** The introspection fixture (retained Widgets + a dropped finalizer cycle). */ -const FIXTURE = path.resolve(__dirname, "../../src/test/fixtures/memory_introspect.py"); -/** `ready = True` — the registry is built; the cycle has NOT been made yet. */ -const BP_TRACK = 52; -/** `done = ready` — the finalizer cycle has been built and dropped. */ -const BP_GC = 54; - -/** The reference-graph ingest payload. */ -interface RefsResult extends IngestResult { - graph: { - nodes: { type: string; isTarget?: boolean; repr?: string }[]; - edges: unknown[]; - cycles: unknown[]; - }; -} - -/** The gc-collect ingest payload. */ -interface GcResult extends IngestResult { - collected: number; - uncollectable: number; - uncollectableObjects: { typeName: string; reason: string }[]; -} - -/** Launch the introspection fixture under the Basilisk debug adapter. */ -async function launchIntrospectSession(): Promise<void> { - const started = await vscode.debug.startDebugging(undefined, { - name: "Memory introspection E2E", - type: "basilisk-debug", - request: "launch", - program: FIXTURE, - stopOnEntry: false, - justMyCode: true, - console: "internalConsole", - }); - assert.ok(started, "the debug session must launch"); -} - -suite("Memory introspection — real end-to-end", () => { - let tmpDir = ""; - - suiteSetup(async function () { - this.timeout(60_000); - const result = await setupLspTestSuite("basilisk-mem-introspect-"); - tmpDir = result.tmpDir; - assert.ok(fs.existsSync(FIXTURE), `introspection fixture must exist: ${FIXTURE}`); - const doc = await vscode.workspace.openTextDocument(vscode.Uri.file(FIXTURE)); - await vscode.window.showTextDocument(doc, { preview: false }); - }); - - suiteTeardown(async function () { - this.timeout(30_000); - vscode.debug.removeBreakpoints(vscode.debug.breakpoints); - await closeAllEditors(); - teardownLspTestSuite(tmpDir); - }); - - teardown(async () => { - if (vscode.debug.activeDebugSession !== undefined) { - await vscode.debug.stopDebugging(); - await waitForSessionEnd(); - } - }); - - test("reference graph: walking a retained custom type yields real target nodes + edges", async function () { - this.timeout(60_000); - setBreakpoints(FIXTURE, [BP_TRACK]); - await launchIntrospectSession(); - const frameId = await waitForPause(); - - // Mint a session (start also injects tracemalloc — harmless for a refs walk). - const start = await memoryRoundTrip("basilisk.memory.start", undefined, frameId); - assert.strictEqual(start.kind, "ack", "memory.start ingest must acknowledge"); - const session = start.memorySessionId; - assert.ok(typeof session === "string" && session.length > 0, "a memory session must be minted"); - - // Walk the retainers of the module-global REGISTRY's Widgets. - const refs = await memoryCourier<RefsResult>({ - command: "basilisk.memory.references", - leg1Args: { targetType: "Widget", maxDepth: 4, maxNodes: 200 }, - frameId, - ingestSessionId: session, - }); - assert.strictEqual(refs.kind, "refs", "references ingest must be kind-tagged refs"); - - const nodes = refs.graph.nodes; - assert.ok( - Array.isArray(nodes) && nodes.length > 0, - `the walk must return real nodes, got: ${JSON.stringify(refs.graph)}`, - ); - assert.ok( - nodes.some((node) => node.type === "Widget" && node.isTarget === true), - `the retained Widget instances must appear as target nodes, got types: ${ - nodes.map((node) => node.type).join(", ")}`, - ); - assert.ok(Array.isArray(refs.graph.edges), "the graph must carry an edges array"); - - await resume(); - await waitForSessionEnd(); - }); - - test("gc collect: a dropped finalizer cycle is really collected and surfaced", async function () { - this.timeout(60_000); - setBreakpoints(FIXTURE, [BP_TRACK, BP_GC]); - await launchIntrospectSession(); - - // Pause 1 (registry built, cycle not yet made): start tracking. The start - // script sets gc DEBUG_SAVEALL, so the cycle a later collect reclaims is - // retained for inspection instead of vanishing. - let frameId = await waitForPause(); - const start = await memoryRoundTrip("basilisk.memory.start", undefined, frameId); - assert.strictEqual(start.kind, "ack", "memory.start ingest must acknowledge"); - const session = start.memorySessionId; - assert.ok(typeof session === "string" && session.length > 0, "a memory session must be minted"); - await resume(); - - // Pause 2 (the finalizer cycle has been built and dropped; the fixture - // disabled automatic gc, so it is still on the heap): force a collection. - frameId = await waitForPause(); - const collected = await memoryCourier<GcResult>({ - command: "basilisk.memory.gcCollect", - leg1Args: {}, - frameId, - ingestSessionId: session, - }); - assert.strictEqual(collected.kind, "gc", "gcCollect ingest must be kind-tagged gc"); - assert.ok( - collected.collected > 0, - `gc.collect() must report reclaiming the dropped cycle, got collected=${collected.collected}`, - ); - assert.ok( - collected.uncollectable > 0, - `DEBUG_SAVEALL must retain the reclaimed cycle for inspection, got uncollectable=${collected.uncollectable}`, - ); - - await resume(); - await waitForSessionEnd(); - }); -}); diff --git a/vscode-extension/src/test/suite/module-explorer-diagnostics.test.ts b/vscode-extension/src/test/suite/module-explorer-diagnostics.test.ts deleted file mode 100644 index 2fe3deb48..000000000 --- a/vscode-extension/src/test/suite/module-explorer-diagnostics.test.ts +++ /dev/null @@ -1,271 +0,0 @@ -// Tests for [EXTACT-MODULES-DIAGNOSTICS]. See docs/specs/EXTENSION-ACTIVITY-PANEL-SPEC.md#EXTACT-MODULES-DIAGNOSTICS -// -// Regression guard for GitHub #235: the module rows advertise `🔴 n 🟠 n` -// tallies, so expanding a module MUST list the actual diagnostics as the first -// children — above its symbols — each one a navigable row (message label, -// `code · Ln n` description, open-at-range click action). Before the fix the -// provider rendered symbol rows only, making every tally a dead number, and a -// symbol-less module with errors could not even be expanded. -// -// Same harness as module-explorer-tree.test.ts: a stubbed -// WorkspaceModulesResponse is fed through a fake LSP client and the REAL -// provider's getChildren() output is asserted. - -import * as assert from "assert"; -import * as vscode from "vscode"; -import { type LanguageClient } from "vscode-languageclient/node"; -import { - ModuleExplorerProvider, - ModuleTreeItem, - PackageTreeItem, -} from "../../module-explorer"; -import { createStore, type Store } from "../../store"; -import { rawField } from "../../unknown-shape"; - -// ── Fixtures ──────────────────────────────────────────────────────────────── - -interface TestDiagnostic { - readonly severity: "error" | "warning"; - readonly code: string; - readonly message: string; - readonly line: number; - readonly character: number; -} - -interface TestSymbol { - readonly name: string; - readonly kind: string; - readonly line: number; - readonly annotated: boolean; - readonly exported: boolean; -} - -interface TestModule { - readonly name: string; - readonly path: string; - readonly kind: "package" | "module"; - readonly symbols: readonly TestSymbol[]; - readonly diagnostics: readonly TestDiagnostic[]; - readonly coveragePercent: number; - readonly errors: number; - readonly warnings: number; - readonly adopted: boolean; -} - -function sym(name: string): TestSymbol { - return { name, kind: "function", line: 0, annotated: true, exported: false }; -} - -function diag( - severity: "error" | "warning", - message: string, - opts: { code?: string; line?: number; character?: number } = {}, -): TestDiagnostic { - return { - severity, - message, - code: opts.code ?? "returns_compatibility", - line: opts.line ?? 0, - character: opts.character ?? 0, - }; -} - -function mod( - name: string, - kind: "package" | "module", - opts: { symbols?: readonly TestSymbol[]; diagnostics?: readonly TestDiagnostic[] }, -): TestModule { - const diagnostics = opts.diagnostics ?? []; - return { - name, - kind, - symbols: opts.symbols ?? [], - diagnostics, - coveragePercent: 80, - path: `/ws/${name.split(".").join("/")}.py`, - errors: diagnostics.filter((d) => d.severity === "error").length, - warnings: diagnostics.filter((d) => d.severity === "warning").length, - adopted: false, - }; -} - -const WORKSPACE = { - typeCheckingEnabled: true, - totalSymbols: 2, - annotatedSymbols: 2, - coveragePercent: 100, - errors: 2, - warnings: 1, - adoptedFiles: 0, - totalFiles: 2, - scanComplete: true, -}; - -/** Build a Store whose LSP client returns the given flat module list. */ -function storeWith(modules: readonly TestModule[]): Store { - const store = createStore(); - // A stand-in for the members the code under test calls. No runtime check - // can produce the rest of `LanguageClient`, so the test double itself is - // the one assertion here — it is not a payload being read. - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- see above. - const client = { - isRunning: (): boolean => true, - onDidChangeState: (): vscode.Disposable => ({ dispose: (): undefined => undefined }), - sendRequest: async (): Promise<unknown> => ({ modules, workspace: WORKSPACE }), - } as unknown as LanguageClient; - store.setClient({ subscriptions: [] }, client); - return store; -} - -function labelOf(item: vscode.TreeItem): string { - const { label } = item; - return typeof label === "string" ? label : label?.label ?? ""; -} - -/** The provider's root rows for the given stubbed modules (tree view). */ -async function rootItems(modules: readonly TestModule[]): Promise<{ - provider: ModuleExplorerProvider; - roots: vscode.TreeItem[]; -}> { - const provider = new ModuleExplorerProvider(storeWith(modules)); - const roots = await provider.getChildren(); - return { provider, roots }; -} - -// ── Tests ───────────────────────────────────────────────────────────────── - -suite("Module Explorer diagnostics drill-down [EXTACT-MODULES-DIAGNOSTICS] (#235)", () => { - - test("expanding a module lists its diagnostics first — above its symbols — not just a dead tally", async () => { - const modules = [ - mod("util", "module", { - symbols: [sym("helper")], - // Deliberately out of order (warning first, later-line error before - // earlier-line error): the client must render errors before warnings, - // then ascending line, even if a server ever sends them unsorted. - diagnostics: [ - diag("warning", "unused import", { line: 1 }), - diag("error", "later error", { line: 9 }), - diag("error", "earlier error", { line: 4 }), - ], - }), - ]; - const { provider, roots } = await rootItems(modules); - const moduleRow = roots.find((row) => row instanceof ModuleTreeItem); - assert.ok(moduleRow, "the util module row should render"); - - const children = await provider.getChildren(moduleRow); - assert.strictEqual( - children.length, - 4, - `a module with 3 diagnostics and 1 symbol must expand to 4 rows (diagnostics + symbols), got: [${children.map(labelOf).join(", ")}]`, - ); - assert.deepStrictEqual( - children.map(labelOf), - ["earlier error", "later error", "unused import", "helper"], - "diagnostics come FIRST (errors before warnings, then ascending line), then the symbols", - ); - }); - - test("each diagnostic row is navigable: code · 1-based line description and an open-at-range click action", async () => { - const modules = [ - mod("util", "module", { - symbols: [], - diagnostics: [diag("error", "bad assignment", { code: "assignment_type", line: 41, character: 7 })], - }), - ]; - const { provider, roots } = await rootItems(modules); - const moduleRow = roots.find((row) => row instanceof ModuleTreeItem); - assert.ok(moduleRow, "the util module row should render"); - - const children = await provider.getChildren(moduleRow); - assert.strictEqual(children.length, 1, "the diagnostic must render as a child row"); - const row = children[0]; - - assert.strictEqual(labelOf(row), "bad assignment", "row label is the diagnostic message"); - assert.strictEqual( - row.description, - "assignment_type · Ln 42", - "row description is `code · Ln n` with the 1-based line", - ); - assert.strictEqual(row.command?.command, "vscode.open", "clicking opens the file"); - const args: readonly unknown[] = row.command?.arguments ?? []; - const target = args[0]; - assert.ok(target instanceof vscode.Uri, "the open command targets a Uri"); - // `fsPath` is rendered in the host's native form — `\ws\util.py` on Windows — - // so the literal is compared through `Uri.file`, and the platform-independent - // `path`/`scheme` are asserted alongside it rather than in place of it. - const expected = vscode.Uri.file("/ws/util.py"); - assert.strictEqual(target.scheme, "file", "the row must open a file, not a virtual document"); - assert.strictEqual(target.path, expected.path, "the Uri path must address the module's file"); - assert.strictEqual(target.fsPath, expected.fsPath, "the native path must address the same file"); - assert.strictEqual(target.toString(), expected.toString(), "the whole Uri must match"); - const selection = rawField(args[1], "selection"); - assert.ok( - selection instanceof vscode.Range, - "the open command must carry the diagnostic's range as the selection", - ); - assert.strictEqual(selection.start.line, 41, "selection anchors to the zero-based line"); - assert.strictEqual(selection.start.character, 7, "selection anchors to the zero-based character"); - }); - - test("a symbol-less module with diagnostics is expandable (its errors must be reachable)", async () => { - const modules = [ - mod("empty_but_broken", "module", { - symbols: [], - diagnostics: [diag("error", "syntax-adjacent error")], - }), - ]; - const { roots } = await rootItems(modules); - const moduleRow = roots.find((row) => row instanceof ModuleTreeItem); - assert.ok(moduleRow, "the module row should render"); - assert.notStrictEqual( - moduleRow.collapsibleState, - vscode.TreeItemCollapsibleState.None, - "a module whose only children are diagnostics must still be expandable (#235)", - ); - }); - - test("package rows surface their own diagnostics above their symbols too", async () => { - const modules = [ - mod("app", "package", { - symbols: [sym("app_init")], - diagnostics: [diag("error", "package-level error", { line: 2 })], - }), - mod("app.api", "module", { symbols: [sym("route")], diagnostics: [] }), - ]; - const { provider, roots } = await rootItems(modules); - const packageRow = roots.find((row) => row instanceof PackageTreeItem); - assert.ok(packageRow, "the app package row should render"); - - const children = await provider.getChildren(packageRow); - const labels = children.map(labelOf); - const diagnosticIndex = labels.indexOf("package-level error"); - const symbolIndex = labels.indexOf("app_init"); - assert.ok( - diagnosticIndex !== -1, - `the package's own diagnostic must render as a child row, got: [${labels.join(", ")}]`, - ); - assert.ok(symbolIndex !== -1, "the package's own symbols still render"); - assert.ok( - diagnosticIndex < symbolIndex, - `diagnostics render above the package's symbols, got: [${labels.join(", ")}]`, - ); - }); - - test("a clean module drills straight to its symbols (no empty diagnostic section)", async () => { - const modules = [ - mod("clean", "module", { symbols: [sym("fine")], diagnostics: [] }), - ]; - const { provider, roots } = await rootItems(modules); - const moduleRow = roots.find((row) => row instanceof ModuleTreeItem); - assert.ok(moduleRow, "the clean module row should render"); - - const children = await provider.getChildren(moduleRow); - assert.deepStrictEqual( - children.map(labelOf), - ["fine"], - "a diagnostics-free module shows exactly its symbols", - ); - }); -}); diff --git a/vscode-extension/src/test/suite/module-explorer-loading.test.ts b/vscode-extension/src/test/suite/module-explorer-loading.test.ts deleted file mode 100644 index 3fa24dc46..000000000 --- a/vscode-extension/src/test/suite/module-explorer-loading.test.ts +++ /dev/null @@ -1,131 +0,0 @@ -// Tests [EXTACT-MODULES-HEADER] loading state (issue #144). -// See docs/specs/EXTENSION-ACTIVITY-PANEL-SPEC.md#EXTACT-MODULES-HEADER -// -// Regression for issue #144: while the analyzer is still starting up or its -// initial background workspace scan is incomplete, basilisk.workspaceModules -// legitimately answers `{ modules: [], workspace: { totalFiles: 0, … } }`. -// The Modules panel must render a loading affordance for that window — it must -// NEVER claim "No Python files found" until the scan has actually finished. -// Per CLAUDE.md the real provider is driven end-to-end: a stubbed LSP response -// is fed through a real store and the tree view's native message chrome is -// asserted. - -import { fakeLanguageClient } from "./test-helpers"; -import * as assert from "assert"; -import type * as vscode from "vscode"; -import { ModuleExplorerProvider } from "../../module-explorer"; -import { createStore, type Store } from "../../store"; - -/** The panel's terminal empty-state — only valid once the scan has finished. */ -const EMPTY_STATE_MESSAGE = "No Python files found"; - -/** Build a Store whose running LSP client answers workspaceModules with `payload`. */ -function storeAnswering(payload: unknown): Store { - const store = createStore(); - const client = fakeLanguageClient({ - isRunning: (): boolean => true, - onDidChangeState: (): vscode.Disposable => ({ dispose: (): undefined => undefined }), - sendRequest: async (): Promise<unknown> => payload, - }); - store.setClient({ subscriptions: [] }, client); - return store; -} - -/** Minimal tree-view stub capturing the message/badge chrome the provider drives. */ -interface ChromeCapture { - message: string | undefined; - badge: vscode.ViewBadge | undefined; -} - -function bindChrome(provider: ModuleExplorerProvider): ChromeCapture { - const view: ChromeCapture = { message: undefined, badge: undefined }; - provider.setTreeView(view); - return view; -} - -suite("Modules panel loading state [EXTACT-MODULES-HEADER] (#144)", () => { - - test("mid-scan zero-file response renders a loading message, never 'No Python files found' (#144)", async () => { - // The server is Running but its initial background workspace scan has not - // finished (init.rs run_workspace_scan): the module list is still empty - // and the workspace rollup reports zero files with scanComplete: false. - const store = storeAnswering({ - modules: [], - workspace: { - typeCheckingEnabled: true, - totalSymbols: 0, - annotatedSymbols: 0, - coveragePercent: 100, - errors: 0, - warnings: 0, - adoptedFiles: 0, - totalFiles: 0, - scanComplete: false, - }, - }); - const provider = new ModuleExplorerProvider(store); - const chrome = bindChrome(provider); - try { - await provider.getChildren(); - assert.notStrictEqual( - chrome.message, - EMPTY_STATE_MESSAGE, - "the panel must never claim zero Python files while the initial scan is incomplete (#144)", - ); - assert.ok( - chrome.message?.includes("Analyzing"), - `mid-scan the panel must show a loading message, got: "${String(chrome.message)}"`, - ); - } finally { - provider.dispose(); - } - }); - - test("before the server runs (no stats yet) the panel shows a loading message, not silence (#144)", async () => { - // lspState idle/starting: there is no client to fetch from, so no workspace - // stats exist. The panel must still show the loading affordance. - const provider = new ModuleExplorerProvider(createStore()); - const chrome = bindChrome(provider); - try { - await provider.getChildren(); - assert.notStrictEqual(chrome.message, EMPTY_STATE_MESSAGE, "no empty-state before the analyzer ran"); - assert.ok( - chrome.message?.includes("Analyzing"), - `while the analyzer is starting the panel must show a loading message, got: "${String(chrome.message)}"`, - ); - } finally { - provider.dispose(); - } - }); - - test("a finished scan with genuinely zero files still renders 'No Python files found' (#57)", async () => { - // The dual guarantee: once the scan HAS finished and there really are no - // Python files, the explicit empty-state (issue #57) must survive. - const store = storeAnswering({ - modules: [], - workspace: { - typeCheckingEnabled: true, - totalSymbols: 0, - annotatedSymbols: 0, - coveragePercent: 100, - errors: 0, - warnings: 0, - adoptedFiles: 0, - totalFiles: 0, - scanComplete: true, - }, - }); - const provider = new ModuleExplorerProvider(store); - const chrome = bindChrome(provider); - try { - await provider.getChildren(); - assert.strictEqual( - chrome.message, - EMPTY_STATE_MESSAGE, - "a completed scan of a truly empty workspace keeps the explicit #57 empty-state", - ); - } finally { - provider.dispose(); - } - }); -}); diff --git a/vscode-extension/src/test/suite/module-explorer-tree.test.ts b/vscode-extension/src/test/suite/module-explorer-tree.test.ts deleted file mode 100644 index 62642fc1d..000000000 --- a/vscode-extension/src/test/suite/module-explorer-tree.test.ts +++ /dev/null @@ -1,647 +0,0 @@ -// Tests for [EXTACT-MODULES-TREE-STRUCTURE]. See docs/specs/EXTENSION-ACTIVITY-PANEL-SPEC.md#EXTACT-MODULES-TREE-STRUCTURE -// -// Coarse component tests for the Module Explorer's nested folder/package tree -// (#149) and the flat-view sort picker (#151/#189). Per CLAUDE.md we drive the real -// provider: a stubbed WorkspaceModulesResponse is fed through a fake LSP client -// and getChildren() output is asserted. Crucially the LSP returns a FLAT list of -// dotted module names — the provider must rebuild the hierarchy client-side, so -// these tests guard that reconstruction and that flat view never dumps bare -// symbols at the tree root. - -import * as assert from "assert"; -import type * as vscode from "vscode"; -import { type LanguageClient } from "vscode-languageclient/node"; -import { - ModuleExplorerProvider, - ModuleTreeItem, - PackageTreeItem, -} from "../../module-explorer"; -import { createStore, type Store } from "../../store"; -import { rawField, stringField } from "../../unknown-shape"; -import type { WorkspaceStateStore } from "../../store-types"; - -// ── Fixtures ──────────────────────────────────────────────────────────────── - -interface TestSymbol { - readonly name: string; - readonly kind: string; - readonly line: number; - readonly annotated: boolean; - readonly exported: boolean; - readonly children?: readonly TestSymbol[]; -} - -interface TestModule { - readonly name: string; - readonly path: string; - readonly kind: "package" | "module"; - readonly symbols: readonly TestSymbol[]; - readonly coveragePercent: number; - readonly totalSymbols?: number; - readonly annotatedSymbols?: number; - readonly errors: number; - readonly warnings: number; - readonly adopted: boolean; -} - -function sym(name: string): TestSymbol { - return { name, kind: "function", line: 0, annotated: true, exported: false }; -} - -function mod( - name: string, - kind: "package" | "module", - opts: { - coverage: number; - symbols?: readonly TestSymbol[]; - totalSymbols?: number; - annotatedSymbols?: number; - errors?: number; - warnings?: number; - path?: string; - }, -): TestModule { - return { - name, - kind, - symbols: opts.symbols ?? [], - coveragePercent: opts.coverage, - totalSymbols: opts.totalSymbols, - annotatedSymbols: opts.annotatedSymbols, - path: opts.path ?? `/ws/${name.split(".").join("/")}.py`, - errors: opts.errors ?? 0, - warnings: opts.warnings ?? 0, - adopted: false, - }; -} - -/** - * A representative flat module list — exactly the shape the LSP returns. Note - * `app.models` has NO entry of its own: `models/` is a plain folder (no - * `__init__.py`), so the provider must synthesise it as a container node. - */ -const MODULES: readonly TestModule[] = [ - mod("app", "package", { coverage: 90, symbols: [sym("app_init")] }), - mod("app.api", "package", { coverage: 80 }), - mod("app.api.auth", "module", { coverage: 50, symbols: [sym("login"), sym("logout")] }), - mod("app.models.user", "module", { coverage: 30, symbols: [sym("User")] }), - mod("util", "module", { coverage: 100, symbols: [sym("helper")] }), -]; - -const WORKSPACE = { - totalSymbols: 6, - annotatedSymbols: 6, - coveragePercent: 100, - errors: 0, - warnings: 0, - adoptedFiles: 0, - totalFiles: 5, -}; - -/** Minimal context for toggleViewMode (only workspaceState is touched). */ -const FAKE_CONTEXT: WorkspaceStateStore = { - workspaceState: { - get: (): undefined => undefined, - update: (): Thenable<void> => Promise.resolve(), - }, -}; - -/** A context whose workspace storage already holds `persisted` under any key. */ -function contextHolding(persisted: unknown): WorkspaceStateStore { - return { - workspaceState: { - get: (): unknown => persisted, - update: (): Thenable<void> => Promise.resolve(), - }, - }; -} - -/** Whether the provider's current root rows are flat-view module rows. */ -async function rootsAreFlat(provider: ModuleExplorerProvider): Promise<boolean> { - const roots = await provider.getChildren(); - return labelsOf(roots).includes("app.api.auth"); -} - -// ── Stubs ───────────────────────────────────────────────────────────────── - -/** - * Build a Store whose LSP client returns the given flat module list. - * - * `modules` is `unknown[]` because that is what the wire actually carries: the - * server sends JSON, and some of these fixtures deliberately omit grading - * fields to stand in for a server that does not send them. Typing the - * parameter as `TestModule[]` would force each such fixture through a cast and - * hide exactly the case the test exists to cover. - */ -function storeWith(modules: readonly unknown[]): Store { - const store = createStore(); - // A stub for `sendRequest<R>(…): Promise<R>` cannot be written without this - // cast: satisfying it means producing a caller-chosen `R` from canned data, - // and no runtime check narrows `unknown` to a type parameter. Every payload - // the provider then reads off this client IS checked — that is what the rule - // is for, and it stays on everywhere else in this file. - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- generic LSP client double; see above - const client = { - isRunning: (): boolean => true, - onDidChangeState: (): vscode.Disposable => ({ dispose: (): undefined => undefined }), - sendRequest: async (): Promise<unknown> => ({ modules, workspace: WORKSPACE }), - } as unknown as LanguageClient; - store.setClient({ subscriptions: [] }, client); - return store; -} - -function labelOf(item: vscode.TreeItem): string { - const { label } = item; - return typeof label === "string" ? label : label?.label ?? ""; -} - -function labelsOf(items: readonly vscode.TreeItem[]): string[] { - return items.map(labelOf); -} - -/** - * Theme-colour id of a row's icon tint, or undefined when untinted. - * - * `iconPath` is a union of four shapes and only `ThemeIcon` carries a colour, - * so the tint is read field by field rather than asserted: a row that turns out - * to hold a `Uri` reports "no tint" instead of throwing inside the assertion. - */ -function iconColorId(item: vscode.TreeItem): string | undefined { - return stringField(rawField(item.iconPath, "color"), "id"); -} - -/** - * A row's tooltip as plain text, failing the test when it is not. - * - * The assertion is the narrowing: every caller wants to state that the tooltip - * is plain text *and* then read it, and doing both in one place keeps the - * second half from becoming a cast that repeats the first half's claim. - */ -function tooltipText(item: vscode.TreeItem): string { - const { tooltip } = item; - assert.strictEqual(typeof tooltip, "string", "tooltip must be plain text"); - return typeof tooltip === "string" ? tooltip : ""; -} - -// ── Tests ───────────────────────────────────────────────────────────────── - -// eslint-disable-next-line max-lines-per-function -suite("Module Explorer tree structure [EXTACT-MODULES-TREE-STRUCTURE]", () => { - - test("tree view renders a nested folder/package tree, never a flat dotted list (#149)", async () => { - const provider = new ModuleExplorerProvider(storeWith(MODULES)); - try { - const roots = await provider.getChildren(); - assert.deepStrictEqual( - labelsOf(roots), - ["app", "util"], - "root shows top-level packages/folders by segment (containers first), not dotted names", - ); - assert.ok( - !labelsOf(roots).some((label) => label.includes(".")), - "no fully-qualified dotted module names at the root", - ); - assert.ok( - roots.find((row) => labelOf(row) === "app") instanceof PackageTreeItem, - "'app' is a package container row", - ); - assert.ok( - roots.find((row) => labelOf(row) === "util") instanceof ModuleTreeItem, - "'util' is a leaf module row", - ); - } finally { - provider.dispose(); - } - }); - - test("packages nest child packages/modules; modules nest symbols (#149)", async () => { - const provider = new ModuleExplorerProvider(storeWith(MODULES)); - try { - const roots = await provider.getChildren(); - const appNode = roots.find((row) => labelOf(row) === "app"); - assert.ok(appNode, "'app' node should exist"); - - const appChildren = await provider.getChildren(appNode); - assert.deepStrictEqual( - labelsOf(appChildren), - ["api", "models", "app_init"], - "package expands to child packages/folders first, then its own symbols", - ); - - const api = appChildren.find((row) => labelOf(row) === "api"); - assert.ok(api instanceof PackageTreeItem, "'api' is a package container"); - assert.deepStrictEqual( - labelsOf(await provider.getChildren(api)), - ["auth"], - "'api' nests the 'auth' module", - ); - - const auth = (await provider.getChildren(api)).find((row) => labelOf(row) === "auth"); - assert.ok(auth instanceof ModuleTreeItem, "'auth' is a leaf module"); - assert.deepStrictEqual( - labelsOf(await provider.getChildren(auth)), - ["login", "logout"], - "module expands to its symbols", - ); - - const models = appChildren.find((row) => labelOf(row) === "models"); - assert.ok( - models instanceof PackageTreeItem, - "'models' is a synthesised folder node (no __init__.py of its own)", - ); - assert.deepStrictEqual( - labelsOf(await provider.getChildren(models)), - ["user"], - "synthesised folder nests its module", - ); - } finally { - provider.dispose(); - } - }); - - test("flat view lists modules (full names) with symbols grouped under them, never at the root (#149)", async () => { - const provider = new ModuleExplorerProvider(storeWith(MODULES)); - try { - await provider.getChildren(); // prime the cache (tree mode) - provider.toggleViewMode(FAKE_CONTEXT); // tree -> flat - - const roots = await provider.getChildren(); - for (const row of roots) { - assert.ok( - row instanceof ModuleTreeItem, - `flat root rows must be modules, not bare symbols — got "${labelOf(row)}"`, - ); - } - assert.ok( - labelsOf(roots).includes("app.api.auth"), - "flat rows are labelled by full dotted module name", - ); - assert.ok( - !labelsOf(roots).includes("login") && !labelsOf(roots).includes("logout"), - "symbols must never be dumped at the flat-view root (#149 §2)", - ); - - const auth = roots.find((row) => labelOf(row) === "app.api.auth"); - assert.ok(auth, "'app.api.auth' module present in flat view"); - assert.deepStrictEqual( - labelsOf(await provider.getChildren(auth)), - ["login", "logout"], - "symbols remain reachable as children of their owning module", - ); - } finally { - provider.dispose(); - } - }); - - // Tests [EXTACT-MODULES-TOOLBAR] Sort — the explicit, labelled name/path/coverage picker. - test("flat-view exposes explicit name/path/coverage sort modes with a visible active mode (#151, #189)", async () => { - const provider = new ModuleExplorerProvider(storeWith(MODULES)); - try { - await provider.getChildren(); - provider.toggleViewMode(FAKE_CONTEXT); // -> flat - - // Default surfaces the least-typed modules first (ascending coverage). - assert.strictEqual(provider.getSortMode(), "coverage", "default flat sort is by coverage"); - const byCoverage = labelsOf(await provider.getChildren()); - assert.deepStrictEqual( - byCoverage, - ["app.models.user", "app.api.auth", "app.api", "app", "util"], - "coverage sort orders by ascending coverage (30, 50, 80, 90, 100)", - ); - - provider.setSortMode("name"); - const byName = labelsOf(await provider.getChildren()); - assert.deepStrictEqual( - byName, - ["app", "app.api", "app.api.auth", "app.models.user", "util"], - "name sort orders alphabetically by dotted module name", - ); - assert.notDeepStrictEqual(byName, byCoverage, "switching sort must change the rendered order"); - - provider.setSortMode("path"); - assert.strictEqual(provider.getSortMode(), "path", "explicit selection sticks"); - - // The three modes are explicit + labelled, and the active one is marked — - // never a blind toggle (#189). - const options = provider.sortOptions(); - assert.deepStrictEqual( - options.map((option) => option.label), - ["Module Name", "Path", "Type Coverage"], - "exactly the three labelled sort modes are offered, in order", - ); - assert.deepStrictEqual( - options.filter((option) => option.current).map((option) => option.mode), - ["path"], - "exactly the active mode is marked current so the picker can show it", - ); - } finally { - provider.dispose(); - } - }); - - // Tests [EXTACT-MODULES-TOOLBAR] Sort (Path mode). - test("flat-view offers an explicit sort-by-path mode (#189)", async () => { - // Paths are chosen so file-path order (a/ < b/ < c/) differs from BOTH name - // order (alpha < beta < gamma) and score order (10 < 50 < 90) — so only a - // genuine path sort can produce [beta, alpha, gamma]. - const byPath: readonly TestModule[] = [ - mod("beta", "module", { coverage: 10, path: "/ws/a/beta.py" }), - mod("alpha", "module", { coverage: 90, path: "/ws/b/alpha.py" }), - mod("gamma", "module", { coverage: 50, path: "/ws/c/gamma.py" }), - ]; - const provider = new ModuleExplorerProvider(storeWith(byPath)); - try { - await provider.getChildren(); - provider.toggleViewMode(FAKE_CONTEXT); // -> flat - - // #189 replaces the blind worst/best/alpha cycle with explicit - // name/path/coverage modes; selecting "path" sorts by file path. - provider.setSortMode("path"); - - assert.deepStrictEqual( - labelsOf(await provider.getChildren()), - ["beta", "alpha", "gamma"], - "path sort orders modules by file path, distinct from name/score order (#189)", - ); - } finally { - provider.dispose(); - } - }); - - // Tests [EXTACT-MODULES-TREE-STRUCTURE] coverage rollup: folder/package rows - // must show the subtree's symbol-weighted type-coverage % — not just error - // tallies, and not only the package's own __init__.py coverage. - test("folder/package rows roll up subtree type coverage, symbol-weighted like the workspace header", async () => { - // Weights are chosen so the honest symbol-weighted rollup for `app` - // ((2+1+0) annotated / (2+2+6) total = 30%) differs from a naive average of - // child percentages ((100+50+0)/3 = 50%) — only a weighted rollup passes. - const modules = [ - mod("app", "package", { coverage: 100, totalSymbols: 2, annotatedSymbols: 2 }), - mod("app.api.auth", "module", { coverage: 50, totalSymbols: 2, annotatedSymbols: 1 }), - mod("app.models.user", "module", { coverage: 0, totalSymbols: 6, annotatedSymbols: 0 }), - mod("util", "module", { coverage: 100, totalSymbols: 1, annotatedSymbols: 1 }), - ]; - const provider = new ModuleExplorerProvider(storeWith(modules)); - try { - const roots = await provider.getChildren(); - - const app = roots.find((row) => labelOf(row) === "app"); - assert.ok(app instanceof PackageTreeItem, "'app' is a package container"); - const appDesc = String(app.description); - assert.ok( - appDesc.includes("30%"), - `'app' must show the subtree's symbol-weighted coverage (3/10 = 30%), got: ${appDesc}`, - ); - assert.ok( - appDesc.includes("█") || appDesc.includes("░"), - `'app' must render the coverage bar like module rows do, got: ${appDesc}`, - ); - - // A synthesised pure folder (models/ has no __init__.py, so no module of - // its own) must still show its subtree's coverage — this is the exact - // "folders show no percentage" bug. - const appChildren = await provider.getChildren(app); - const models = appChildren.find((row) => labelOf(row) === "models"); - assert.ok(models instanceof PackageTreeItem, "'models' is a synthesised folder"); - const modelsDesc = String(models.description); - assert.ok( - modelsDesc.includes("0%"), - `pure folder must show its subtree coverage (0/6 = 0%), got: "${modelsDesc}"`, - ); - - const api = appChildren.find((row) => labelOf(row) === "api"); - assert.ok(api instanceof PackageTreeItem, "'api' is a synthesised folder"); - assert.ok( - String(api.description).includes("50%"), - `'api' folder must show its subtree coverage (1/2 = 50%), got: "${String(api.description)}"`, - ); - } finally { - provider.dispose(); - } - }); - - // Tests [EXTACT-MODULES-TREE-STRUCTURE] + [ANALYSIS-ENABLED] (#119): with type - // checking disabled the server omits all grading, so folder rows must render - // NO percentage — never a vacuous 100% conjured from zero data. - test("folder rows show no coverage percentage while type checking is disabled (#119)", async () => { - const ungraded = [ - { name: "app", kind: "package", symbols: [], path: "/ws/app/__init__.py" }, - { name: "app.mod", kind: "module", symbols: [], path: "/ws/app/mod.py" }, - ]; - const provider = new ModuleExplorerProvider(storeWith(ungraded)); - try { - const roots = await provider.getChildren(); - const app = roots.find((row) => labelOf(row) === "app"); - assert.ok(app instanceof PackageTreeItem, "'app' is a package container"); - assert.ok( - !String(app.description ?? "").includes("%"), - `ungraded folder must show no percentage, got: "${String(app.description)}"`, - ); - } finally { - provider.dispose(); - } - }); - - // Tests [EXTACT-MODULES-TREE-STRUCTURE] icon tint: the folder/package icon - // colour must follow the SUBTREE rollup, never the package's own - // __init__.py coverage — a green __init__.py over a red subtree reads red. - test("package icon tint follows the subtree coverage rollup, not the package's own coverage", async () => { - // `app`'s own module is fully typed (green on its own: 100% ≥ 90), but the - // subtree rolls up to 2/12 ≈ 17% (< 50) — only the rolled-up tint is red. - const modules = [ - mod("app", "package", { coverage: 100, totalSymbols: 2, annotatedSymbols: 2 }), - mod("app.core", "module", { coverage: 0, totalSymbols: 10, annotatedSymbols: 0 }), - ]; - const provider = new ModuleExplorerProvider(storeWith(modules)); - try { - const app = (await provider.getChildren()).find((row) => labelOf(row) === "app"); - assert.ok(app instanceof PackageTreeItem, "'app' is a package container"); - assert.strictEqual( - iconColorId(app), - "list.errorForeground", - "tint must come from the subtree rollup (17% → red), not the package's own 100% (green)", - ); - } finally { - provider.dispose(); - } - }); - - test("package icon tint bands: subtree errors win, then warnings, then coverage colour, untinted when ungraded", async () => { - const cases: readonly { readonly modules: readonly unknown[]; readonly expected: string | undefined; readonly why: string }[] = [ - { - modules: [ - mod("app", "package", { coverage: 100, totalSymbols: 2, annotatedSymbols: 2 }), - mod("app.core", "module", { coverage: 100, totalSymbols: 2, annotatedSymbols: 2, errors: 1 }), - ], - expected: "list.errorForeground", - why: "a subtree error tints red even when fully typed", - }, - { - modules: [ - mod("app", "package", { coverage: 100, totalSymbols: 2, annotatedSymbols: 2 }), - mod("app.core", "module", { coverage: 100, totalSymbols: 2, annotatedSymbols: 2, warnings: 3 }), - ], - expected: "list.warningForeground", - why: "a warning-only subtree tints yellow", - }, - { - modules: [ - mod("app", "package", { coverage: 100, totalSymbols: 9, annotatedSymbols: 9 }), - mod("app.core", "module", { coverage: 90, totalSymbols: 1, annotatedSymbols: 1 }), - ], - expected: "testing.iconPassed", - why: "a clean ≥90% subtree tints green", - }, - { - modules: [ - mod("app", "package", { coverage: 100, totalSymbols: 1, annotatedSymbols: 1 }), - mod("app.core", "module", { coverage: 0, totalSymbols: 1, annotatedSymbols: 0 }), - ], - expected: "list.warningForeground", - why: "a clean 50–89% subtree tints yellow", - }, - { - modules: [ - { name: "app", kind: "package", symbols: [], path: "/ws/app/__init__.py" }, - { name: "app.core", kind: "module", symbols: [], path: "/ws/app/core.py" }, - ], - expected: undefined, - why: "an ungraded subtree (Type Checking disabled, #119) stays untinted", - }, - ]; - for (const { modules, expected, why } of cases) { - const provider = new ModuleExplorerProvider(storeWith(modules)); - try { - const app = (await provider.getChildren()).find((row) => labelOf(row) === "app"); - assert.ok(app instanceof PackageTreeItem, `'app' is a package container (${why})`); - assert.strictEqual(iconColorId(app), expected, why); - } finally { - provider.dispose(); - } - } - }); - - // Tests [EXTACT-MODULES-TREE-STRUCTURE] tooltips: folder tooltips must quote - // the SUBTREE rollup (labelled as such) and module tooltips the row's stats. - test("package tooltip quotes the subtree coverage rollup and subtree tallies; module tooltip its own stats", async () => { - const modules = [ - mod("app", "package", { coverage: 100, totalSymbols: 2, annotatedSymbols: 2 }), - mod("app.core", "module", { - coverage: 0, totalSymbols: 10, annotatedSymbols: 0, errors: 1, warnings: 2, - }), - ]; - const provider = new ModuleExplorerProvider(storeWith(modules)); - try { - const roots = await provider.getChildren(); - const app = roots.find((row) => labelOf(row) === "app"); - assert.ok(app instanceof PackageTreeItem, "'app' is a package container"); - const packageTip = tooltipText(app); - assert.ok( - packageTip.includes("Coverage: 17% (subtree)"), - `package tooltip must quote the rolled-up subtree coverage (2/12 = 17%), not its own 100%, got: ${packageTip}`, - ); - assert.ok( - packageTip.includes("Subtree: 1 error, 2 warnings"), - `package tooltip must tally subtree diagnostics with correct pluralisation, got: ${packageTip}`, - ); - - const core = (await provider.getChildren(app)).find((row) => labelOf(row) === "core"); - assert.ok(core instanceof ModuleTreeItem, "'core' is a leaf module"); - const moduleTip = tooltipText(core); - for (const line of ["app.core", "/ws/app/core.py", "Coverage: 0%", "Errors: 1", "Warnings: 2"]) { - assert.ok(moduleTip.includes(line), `module tooltip must include "${line}", got: ${moduleTip}`); - } - } finally { - provider.dispose(); - } - }); - - // Tests the graded-but-empty branch: a graded subtree with zero symbols is - // vacuously fully typed — it must render 100%, never NaN or a blank. - test("a graded folder with zero symbols renders 100%, never NaN", async () => { - const modules = [ - mod("app", "package", { coverage: 100, totalSymbols: 0, annotatedSymbols: 0 }), - mod("app.core", "module", { coverage: 100, totalSymbols: 0, annotatedSymbols: 0 }), - ]; - const provider = new ModuleExplorerProvider(storeWith(modules)); - try { - const app = (await provider.getChildren()).find((row) => labelOf(row) === "app"); - assert.ok(app instanceof PackageTreeItem, "'app' is a package container"); - const desc = String(app.description); - assert.ok(desc.includes("100%"), `zero-symbol graded folder shows 100%, got: "${desc}"`); - assert.ok(!desc.includes("NaN"), `must never render NaN, got: "${desc}"`); - } finally { - provider.dispose(); - } - }); - - test("folder/package rows roll up subtree errors/warnings so problems show without drilling in (#149)", async () => { - const modules = [ - mod("app", "package", { coverage: 90 }), - mod("app.api.auth", "module", { coverage: 50, errors: 9, warnings: 2 }), - mod("app.models.user", "module", { coverage: 30, errors: 1 }), - mod("util", "module", { coverage: 100 }), - ]; - const provider = new ModuleExplorerProvider(storeWith(modules)); - try { - const roots = await provider.getChildren(); - - const app = roots.find((row) => labelOf(row) === "app"); - assert.ok(app instanceof PackageTreeItem, "'app' is a package container"); - const appDesc = String(app.description); - assert.ok(appDesc.includes("🔴 10"), `'app' must roll up all descendant errors (9+1), got: ${appDesc}`); - assert.ok(appDesc.includes("🟠 2"), `'app' must roll up descendant warnings, got: ${appDesc}`); - assert.strictEqual(app.node.errors, 10, "rolled-up error count on the node"); - assert.strictEqual(app.node.warnings, 2, "rolled-up warning count on the node"); - - // A synthesised intermediate folder rolls up too. - const appChildren = await provider.getChildren(app); - const api = appChildren.find((row) => labelOf(row) === "api"); - assert.ok(api instanceof PackageTreeItem, "'api' is a synthesised folder"); - assert.ok( - String(api.description).includes("🔴 9"), - `'api' folder must surface auth's 9 errors without drilling in, got: ${String(api.description)}`, - ); - - // A clean leaf must NOT show a spurious tally. - const util = roots.find((row) => labelOf(row) === "util"); - assert.ok(util instanceof ModuleTreeItem, "'util' is a clean leaf module"); - assert.ok( - !String(util.description).includes("🔴") && !String(util.description).includes("🟠"), - `clean module must show no error/warning tally, got: ${String(util.description)}`, - ); - } finally { - provider.dispose(); - } - }); - - // The persisted view mode was written by whichever version of the extension - // last ran, so `restoreViewMode` reads it as `unknown` and validates it. These - // pin both halves: a mode we recognise is honoured, and anything else falls - // back to the default rather than leaving the explorer in a mode it cannot - // render. - test("restoreViewMode honours a persisted 'flat'", async () => { - const provider = new ModuleExplorerProvider(storeWith(MODULES)); - try { - provider.restoreViewMode(contextHolding("flat")); - assert.ok(await rootsAreFlat(provider), "a persisted 'flat' must restore flat view"); - } finally { - provider.dispose(); - } - }); - - test("restoreViewMode falls back to tree when storage holds an unusable value", async () => { - for (const persisted of ["outline", "", 7, null, undefined, { mode: "flat" }]) { - const provider = new ModuleExplorerProvider(storeWith(MODULES)); - try { - provider.restoreViewMode(contextHolding(persisted)); - assert.ok( - !(await rootsAreFlat(provider)), - `stored ${JSON.stringify(persisted) ?? "undefined"} is not a view mode — must fall back to tree`, - ); - } finally { - provider.dispose(); - } - } - }); -}); diff --git a/vscode-extension/src/test/suite/nav-fixtures.ts b/vscode-extension/src/test/suite/nav-fixtures.ts deleted file mode 100644 index 2ed480118..000000000 --- a/vscode-extension/src/test/suite/nav-fixtures.ts +++ /dev/null @@ -1,61 +0,0 @@ -// Implements [LSPARCH-FEATURES-HOVER] / [LSPARCH-FEATURES-DEFINITION]. -// See docs/specs/LSP-ARCHITECTURE-SPEC.md#LSPARCH-FEATURES-HOVER -/** - * Shared Python fixtures for the hover (lsp-hover) and goto (lsp-goto) - * hammer suites. Kept in one place so both suites exercise the SAME rich - * symbol set and expected definition lines are derived (via `locate`) rather - * than hard-coded — the fixture cannot drift out from under the assertions. - * - * Tokens are deliberately distinct words so `locate(SUBJECT_SOURCE, token, n)` - * resolves unambiguously to a definition vs reference site. - */ - -/** Cross-file helper module imported by the subject file. */ -export const HELPER_SOURCE = [ - '"""Helper module for cross-file navigation."""', - '', - 'def helper_fn() -> None:', - ' """A helper function."""', - ' return None', - '', - 'class HelperClass:', - ' """A helper class."""', - ' member: int = 0', - '', -].join('\n'); - -/** Filename the subject file imports from; written into the same tmpDir. */ -export const HELPER_FILENAME = 'nav_helper.py'; - -/** One rich subject file packed with every hover/goto-relevant symbol kind. */ -export const SUBJECT_SOURCE = [ - '"""Module docstring for navigation subjects."""', // 0 - 'from typing import Final', // 1 - 'from nav_helper import helper_fn, HelperClass', // 2 - '', // 3 - 'PI: Final = 3.14', // 4 - 'counter = 5', // 5 - '', // 6 - 'def calculate(operand: int) -> int:', // 7 - ' """Compute the square of operand."""', // 8 - ' squared = operand * operand', // 9 - ' return squared', // 10 - '', // 11 - 'class Widget:', // 12 - ' """A configurable widget."""', // 13 - ' width: int = 10', // 14 - '', // 15 - ' def resize(self, factor: int) -> int:', // 16 - ' """Resize the widget by a factor."""', // 17 - ' return self.width * factor', // 18 - '', // 19 - 'result: int = calculate(5)', // 20 - 'gadget: Widget = Widget()', // 21 - 'helper_fn()', // 22 - 'instance: HelperClass = HelperClass()', // 23 - '', // 24 - 'def scaled_area(scale_factor: int) -> float:', // 25 - ' """Multiply the module constant by a factor."""', // 26 - ' return PI * scale_factor', // 27 - '', // 28 -].join('\n'); diff --git a/vscode-extension/src/test/suite/process-explorer.test.ts b/vscode-extension/src/test/suite/process-explorer.test.ts deleted file mode 100644 index dd4638107..000000000 --- a/vscode-extension/src/test/suite/process-explorer.test.ts +++ /dev/null @@ -1,802 +0,0 @@ -// Tests for [PROFILE-PROCESSES-PANEL]. See docs/specs/LSP-PROFILING-SPEC.md#PROFILE-PROCESSES-PANEL -// -// Component tests for the Python Processes panel (#62). Per CLAUDE.md these -// assert behavior through internal VSIX state — instantiate the provider, feed -// it a stubbed ProcessInfo[] via a fake LSP client, and assert getChildren() -// yields the expected sorted/grouped/filtered rows and that each row carries -// the PID a one-click profiling action needs. No getCommands()/whenCommandReady. - -import * as assert from "assert"; -import * as vscode from "vscode"; -import { type LanguageClient } from "vscode-languageclient/node"; -import { ProcessDecorationProvider, PythonProcessesProvider, type ProcessInfo } from "../../process-explorer"; -import { createProcessRowActions, memoryTrackRoute } from "../../process-launch"; -import { createStore, type Store } from "../../store"; -import { numberField, rawField, recordArrayField } from "../../unknown-shape"; - -const MB = 1024 * 1024; - -/** A representative process table covering launchers, users, and versions. */ -const STUB_PROCESSES: readonly ProcessInfo[] = [ - { - pid: 100, ppid: 1, name: "python3.12", interpreterPath: "/usr/bin/python3.12", - script: "/app/web.py", pythonVersion: "3.12.1", cpuPercent: 5, memoryBytes: 50 * MB, - runtimeSecs: 10, user: "alice", requiresElevation: false, - inWorkspace: true, launcher: null, debuggable: true, undebuggableReason: null, - }, - { - pid: 200, ppid: 1, name: "python3.11", interpreterPath: "/usr/bin/python3.11", - script: "/app/worker.py", pythonVersion: "3.11.7", cpuPercent: 42, memoryBytes: 10 * MB, - runtimeSecs: 99, user: "bob", requiresElevation: true, - inWorkspace: false, launcher: null, debuggable: true, undebuggableReason: null, - }, - { - pid: 300, ppid: 200, name: "python3.12", interpreterPath: "/usr/bin/python3.12", - script: "/app/uvicorn.py", pythonVersion: "3.12.1", cpuPercent: 1, memoryBytes: 99 * MB, - runtimeSecs: 5, user: "alice", requiresElevation: false, - inWorkspace: false, launcher: "uvicorn", debuggable: true, undebuggableReason: null, - }, -]; - -/** One `workspace/executeCommand` request captured by the recording client. */ -interface RecordedRequest { - readonly command: string; - readonly arguments: readonly unknown[]; -} - -/** - * Build a Store whose LSP client returns the given process table and records - * every executeCommand request so tests can assert what was sent (e.g. that an - * inline action really issued `basilisk.profiler.start` with the row's PID). - */ -function storeWith(processes: readonly ProcessInfo[], requests?: RecordedRequest[]): Store { - const store = createStore(); - // A stand-in for the three members the provider actually calls. No runtime - // check can produce the rest of `LanguageClient`, so this one assertion - // stays — it is the test double itself, not a payload being read. - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- see above. - const client = { - isRunning: (): boolean => true, - onDidChangeState: (): vscode.Disposable => ({ dispose: (): undefined => undefined }), - sendRequest: async (_method: string, param?: RecordedRequest): Promise<unknown> => { - if (param !== undefined) { requests?.push(param); } - if (param?.command === "basilisk.profiler.start") { return undefined; } - return { processes }; - }, - } as unknown as LanguageClient; - store.setClient({ subscriptions: [] }, client); - return store; -} - - -/** - * Build a provider over `store` and pull the process table into the store - * through the real store-side fetch path — getChildren() is a pure projection - * of centralised state and never fetches on its own (#148). - */ -async function loadedProvider(store: Store): Promise<PythonProcessesProvider> { - const provider = new PythonProcessesProvider(store); - await provider.refreshNow(); - return provider; -} - -/** The `basilisk.profiler.start` requests among the recorded ones. */ -function profilerStarts(requests: readonly RecordedRequest[]): RecordedRequest[] { - return requests.filter((req) => req.command === "basilisk.profiler.start"); -} - -/** The pid argument of a recorded `basilisk.profiler.start` request. */ -function startPid(request: RecordedRequest): unknown { - return rawField(request.arguments[0], "pid"); -} - -// `process` and `members` are attached to the tree item by the provider under -// test and are not part of `vscode.TreeItem`. They are read back by name rather -// than asserted onto the item, so a provider that stops attaching one fails the -// assertion instead of handing the test an `undefined` it would compare away. - -/** Read the PID a process row carries (the arg passed to inline commands). */ -function pidOf(item: vscode.TreeItem): number | undefined { - return numberField(rawField(item, "process"), "pid"); -} - -/** The PIDs of the members a group row carries. */ -function memberPids(item: vscode.TreeItem): number[] { - return recordArrayField(item, "members") - .map((member) => numberField(member, "pid")) - .filter((pid): pid is number => pid !== undefined); -} - -/** Read a group header's label (a plain string at runtime). */ -function labelText(item: vscode.TreeItem): string { - return typeof item.label === "string" ? item.label : ""; -} - -// ── Display-cue helpers ([PROFILE-PROCESSES-DISPLAY]) ────────────────────── - -/** The vscode.ThemeColor / ThemeIcon `id` a row or decoration resolves to. */ -function colorId(value: { id?: string } | undefined): string | undefined { - return value?.id; -} -function iconId(item: vscode.TreeItem): string | undefined { - return item.iconPath instanceof vscode.ThemeIcon ? item.iconPath.id : undefined; -} -function resourceUri(item: vscode.TreeItem): vscode.Uri | undefined { - return item.resourceUri; -} -/** The row's tooltip narrowed to its string form (rowTooltip always returns one). */ -function tooltipText(item: vscode.TreeItem): string { - return typeof item.tooltip === "string" ? item.tooltip : ""; -} - -/** Drop the pinned "Run & …(Current File)" launch-action rows from a root listing. */ -function processRows(rows: vscode.TreeItem[]): vscode.TreeItem[] { - return rows.filter((r) => r.contextValue !== "launchAction"); -} -/** Just the pinned launch-action rows. */ -function actionRows(rows: vscode.TreeItem[]): vscode.TreeItem[] { - return rows.filter((r) => r.contextValue === "launchAction"); -} - -/** A debugger-machinery row: listed, but non-debuggable (the 🚫 / grey / sunk case). */ -const MACHINERY: ProcessInfo = { - pid: 900, ppid: 1, name: "python3.12", interpreterPath: "/usr/bin/python3.12", - script: null, pythonVersion: "3.12.1", cpuPercent: 99, memoryBytes: 5 * MB, - runtimeSecs: 3, user: "alice", requiresElevation: false, - inWorkspace: false, launcher: null, debuggable: false, - undebuggableReason: "debugger machinery", -}; - -suite("Python Processes Panel", () => { - let provider: PythonProcessesProvider; - - teardown(() => { - provider.dispose(); - }); - - test("lists every process sorted by CPU descending by default", async () => { - provider = await loadedProvider(storeWith(STUB_PROCESSES)); - const rows = processRows(await provider.getChildren()); - assert.deepStrictEqual(rows.map(pidOf), [200, 100, 300], "CPU 42 > 5 > 1"); - }); - - test("each row carries its PID so inline Profile starts with no input box", async () => { - provider = await loadedProvider(storeWith(STUB_PROCESSES)); - const rows = processRows(await provider.getChildren()); - for (const row of rows) { - assert.strictEqual(typeof pidOf(row), "number", "row must carry a numeric pid for the command arg"); - } - const worker = rows.find((r) => pidOf(r) === 200); - assert.ok(worker, "the worker process row must exist"); - assert.ok( - String(worker.description).includes("PID 200"), - `row description should surface the PID: ${String(worker.description)}`, - ); - }); - - test("rows needing elevation get a distinct contextValue for the lock affordance", async () => { - provider = await loadedProvider(storeWith(STUB_PROCESSES)); - const rows = await provider.getChildren(); - const elevated = rows.find((r) => pidOf(r) === 200); - const normal = rows.find((r) => pidOf(r) === 100); - assert.strictEqual(elevated?.contextValue, "pythonProcessElevated"); - assert.strictEqual(normal?.contextValue, "pythonProcess"); - }); - - test("sort by memory orders rows by resident size descending", async () => { - provider = await loadedProvider(storeWith(STUB_PROCESSES)); - provider.cycleSortMode(); // cpu → memory - const rows = processRows(await provider.getChildren()); - assert.deepStrictEqual(rows.map(pidOf), [300, 100, 200], "memory 99 > 50 > 10 MB"); - }); - - test("group by Python version buckets processes under collapsible headers", async () => { - provider = await loadedProvider(storeWith(STUB_PROCESSES)); - provider.cycleGroupMode(); // none → version - const groups = processRows(await provider.getChildren()); - assert.deepStrictEqual( - groups.map(labelText), - ["3.11.7", "3.12.1"], - "groups are sorted by version label", - ); - const twelve = groups.find((g) => labelText(g) === "3.12.1"); - assert.ok(twelve, "3.12.1 group must exist"); - assert.strictEqual(String(twelve.description), "2", "group shows its member count"); - - const members = await provider.getChildren(twelve); - assert.deepStrictEqual(members.map(pidOf), [100, 300], "both 3.12 processes, CPU-ordered"); - }); - - test("filter narrows rows by name, script, or PID substring", async () => { - provider = await loadedProvider(storeWith(STUB_PROCESSES)); - provider.setFilter("worker"); - let rows = processRows(await provider.getChildren()); - assert.deepStrictEqual(rows.map(pidOf), [200], "only worker.py matches"); - - provider.setFilter("300"); - rows = processRows(await provider.getChildren()); - assert.deepStrictEqual(rows.map(pidOf), [300], "PID substring matches"); - }); - - // procexp-2: VS Code shows the "No Python processes running" welcome whenever - // getChildren returns []. When a filter hides a NON-empty process list, the - // tree must NOT be empty — it must say processes are running but filtered - // (the pinned launch rows stay too). - test("a filter that hides every running process shows an honest placeholder, not 'no processes' (procexp-2)", async () => { - provider = await loadedProvider(storeWith(STUB_PROCESSES)); - provider.setFilter("nonexistent-zzz"); - const nonAction = processRows(await provider.getChildren()); - assert.strictEqual(nonAction.length, 1, "must return a placeholder row, not an empty list that triggers the welcome"); - assert.strictEqual(nonAction[0].contextValue, "processesMessage", "the row is a non-process placeholder"); - const label = labelText(nonAction[0]); - assert.ok( - label.includes("nonexistent-zzz") && label.includes("3 running"), - `the placeholder must explain the filter hid running processes: ${label}`, - ); - }); - - test("group members expose the full member set for the count badge", async () => { - provider = await loadedProvider(storeWith(STUB_PROCESSES)); - provider.cycleGroupMode(); - const groups = await provider.getChildren(); - const eleven = groups.find((g) => labelText(g) === "3.11.7"); - assert.ok(eleven, "3.11.7 group must exist"); - assert.deepStrictEqual(memberPids(eleven), [200]); - }); -}); - -// Tests for [PROFILE-PROCESSES-LAUNCH] issue #79: the inline flame/database -// buttons arrive with `item === undefined` at runtime and must still profile -// the row the user clicked instead of warning "Select a Python process". -suite("Python Processes Panel — inline launch actions (#79)", () => { - let provider: PythonProcessesProvider; - - teardown(() => { - provider.dispose(); - }); - - test("rows keep a stable id across refreshes so inline buttons survive the auto-refresh", async () => { - provider = await loadedProvider(storeWith(STUB_PROCESSES)); - const before = await provider.getChildren(); - provider.refresh(); - const after = await provider.getChildren(); - - const beforeRow = before.find((row) => pidOf(row) === 200); - const afterRow = after.find((row) => pidOf(row) === 200); - assert.ok(beforeRow !== undefined && afterRow !== undefined, "PID 200 row must exist in both passes"); - assert.ok( - typeof beforeRow.id === "string" && beforeRow.id.length > 0, - "process rows must carry a stable TreeItem.id so VS Code can map an inline click " + - `back to the element after a 2s auto-refresh (#79); got: ${String(beforeRow.id)}`, - ); - assert.strictEqual(beforeRow.id, afterRow.id, "the id must be identical across refreshes"); - }); - - test("inline Profile CPU invoked without an argument profiles the selected row", async () => { - const requests: RecordedRequest[] = []; - const store = storeWith(STUB_PROCESSES, requests); - provider = await loadedProvider(store); - const rows = await provider.getChildren(); - const selectedRow = rows.find((row) => pidOf(row) === 200); - assert.ok(selectedRow !== undefined, "PID 200 row must exist"); - - const actions = createProcessRowActions(store, { selection: [selectedRow] }); - // VS Code passed no argument — the runtime shape of issue #79. - await actions.profileProcess(undefined); - - const starts = profilerStarts(requests); - assert.strictEqual( - starts.length, - 1, - "clicking the inline flame button must start profiling (not warn) when a row is selected (#79)", - ); - assert.strictEqual(startPid(starts[0]), 200, "profiling must target the selected row's PID"); - }); - - test("an explicitly passed row wins over a different selection", async () => { - const requests: RecordedRequest[] = []; - const store = storeWith(STUB_PROCESSES, requests); - provider = await loadedProvider(store); - const rows = await provider.getChildren(); - const clicked = rows.find((row) => pidOf(row) === 300); - const selected = rows.find((row) => pidOf(row) === 200); - assert.ok(clicked !== undefined && selected !== undefined, "both rows must exist"); - - const actions = createProcessRowActions(store, { selection: [selected] }); - await actions.profileProcess(clicked); - - const starts = profilerStarts(requests); - assert.strictEqual(starts.length, 1, "the clicked row must be profiled"); - assert.strictEqual(startPid(starts[0]), 300, "the explicit item must win over the selection"); - }); - - test("with no item and no selection, nothing is profiled", async () => { - const requests: RecordedRequest[] = []; - const store = storeWith(STUB_PROCESSES, requests); - provider = await loadedProvider(store); - await provider.getChildren(); - - const actions = createProcessRowActions(store, { selection: [] }); - await actions.profileProcess(undefined); - - assert.strictEqual( - profilerStarts(requests).length, - 0, - "without any resolvable target the action must not fire a profiler.start", - ); - }); -}); - -suite("Python Processes Panel — Track Memory routing", () => { - // Tests for the memory leg of [PROFILE-PROCESSES-LAUNCH]: tracemalloc rides - // the DAP courier, so the row action may only ever target the live debuggee. - - /** Drive the row's Track Memory action against PID 100 with the given session. */ - async function trackMemoryOnPid100( - session: { id: string; type: string } | undefined, - arrange: (store: Store) => void = () => undefined, - ): Promise<{ requests: RecordedRequest[]; executed: string[] }> { - const requests: RecordedRequest[] = []; - const executed: string[] = []; - const store = storeWith(STUB_PROCESSES, requests); - arrange(store); - const rows = await (await loadedProvider(store)).getChildren(); - const selectedRow = rows.find((row) => pidOf(row) === 100); - assert.ok(selectedRow !== undefined, "PID 100 row must exist"); - - const actions = createProcessRowActions(store, { selection: [selectedRow] }, { - runCommand: async (command) => { executed.push(command); }, - activeSession: () => session, - }); - await actions.memoryTrackProcess(undefined); - return { requests, executed }; - } - - test("on the live debuggee it routes to real memory tracking — never a CPU start", async () => { - const { requests, executed } = await trackMemoryOnPid100( - { id: "session-1", type: "basilisk-debug" }, - (store) => { store.setDebuggeeProcessId("session-1", 100); }, - ); - - assert.deepStrictEqual( - executed, - ["basilisk.memoryStart"], - "Track Memory on the debuggee row must start tracemalloc tracking", - ); - assert.strictEqual( - profilerStarts(requests).length, - 0, - "Track Memory must NEVER start a CPU profiling session (the preset:'memory' defect)", - ); - }); - - test("on an external process it starts nothing and offers the launch flow", async () => { - // No debug session at all — PID 100 is a foreign process. - const { requests, executed } = await trackMemoryOnPid100(undefined); - - assert.deepStrictEqual(executed, [], "no memory command can run against a foreign PID"); - assert.strictEqual( - profilerStarts(requests).length, - 0, - "an external row must not silently fall back to CPU profiling", - ); - }); - - test("memoryTrackRoute targets the debuggee only when session and PID both match", () => { - const store = storeWith(STUB_PROCESSES); - store.setDebuggeeProcessId("session-1", 100); - const basilisk = { id: "session-1", type: "basilisk-debug" }; - - assert.strictEqual(memoryTrackRoute(store, 100, basilisk), "start-tracking"); - assert.strictEqual(memoryTrackRoute(store, 200, basilisk), "offer-launch", "PID mismatch"); - assert.strictEqual(memoryTrackRoute(store, 100, undefined), "offer-launch", "no session"); - assert.strictEqual( - memoryTrackRoute(store, 100, { id: "session-1", type: "python" }), - "offer-launch", - "foreign debug adapter", - ); - }); -}); - -// Tests for [PROFILE-PROCESSES-DISPLAY] / [PROFILE-PROCESSES-SCOPE]: the panel -// shows EVERY process (zero filters) and renders cues — launcher chips, a green -// workspace row, and a 🚫 / greyed / sunk row for anything it can't profile. -suite("Python Processes Panel — zero-filter display cues", () => { - let provider: PythonProcessesProvider; - teardown(() => { provider.dispose(); }); - - test("launchers are always listed and carry a framework chip (zero filters)", async () => { - provider = await loadedProvider(storeWith(STUB_PROCESSES)); - const rows = await provider.getChildren(); - const uvicorn = rows.find((r) => pidOf(r) === 300); - assert.ok(uvicorn, "the uvicorn launcher must always be listed — nothing is hidden"); - assert.ok( - String(uvicorn.description).includes("[uvicorn]"), - `the launcher framework must render as a chip: ${String(uvicorn.description)}`, - ); - }); - - test("a workspace process resolves to a green decoration; an outside one does not", async () => { - provider = await loadedProvider(storeWith(STUB_PROCESSES)); - const decorations = new ProcessDecorationProvider(provider); - try { - const rows = await provider.getChildren(); - const inside = rows.find((r) => pidOf(r) === 100); // inWorkspace: true - const outside = rows.find((r) => pidOf(r) === 300); // inWorkspace: false, debuggable - assert.ok(inside && outside, "both the workspace and outside rows must exist"); - const insideUri = resourceUri(inside); - const outsideUri = resourceUri(outside); - assert.ok(insideUri && outsideUri, "process rows must carry a resourceUri for decoration"); - - const insideDeco = decorations.provideFileDecoration(insideUri); - assert.strictEqual(colorId(insideDeco?.color), "charts.green", "a workspace row must be green"); - const outsideDeco = decorations.provideFileDecoration(outsideUri); - assert.strictEqual(outsideDeco, undefined, "a non-workspace debuggable row keeps the default colour"); - - // A non-process URI is ignored, and a tree refresh re-fires decorations. - assert.strictEqual( - decorations.provideFileDecoration(vscode.Uri.file("/tmp/unrelated")), - undefined, - "URIs from other schemes are not decorated", - ); - let fired = false; - const sub = decorations.onDidChangeFileDecorations(() => { fired = true; }); - provider.refresh(); - sub.dispose(); - assert.ok(fired, "a tree refresh must re-fire decorations so colours never go stale"); - } finally { - decorations.dispose(); - } - }); - - test("a non-debuggable process is 🚫-marked, greyed, and sorted to the bottom", async () => { - // MACHINERY has the highest CPU (99%) but must still sink below the others. - provider = await loadedProvider(storeWith([MACHINERY, ...STUB_PROCESSES])); - const decorations = new ProcessDecorationProvider(provider); - try { - const rows = await provider.getChildren(); - assert.strictEqual( - pidOf(rows[rows.length - 1]), - 900, - "the non-debuggable row sinks to the bottom despite the highest CPU", - ); - const machineryRow = rows.find((r) => pidOf(r) === 900); - assert.ok(machineryRow, "the machinery process must still be LISTED, not hidden"); - assert.ok( - labelText(machineryRow).startsWith("🚫"), - `a non-debuggable row must be prefixed with 🚫: ${labelText(machineryRow)}`, - ); - assert.strictEqual(iconId(machineryRow), "circle-slash", "non-debuggable icon is circle-slash"); - - const machineryUri = resourceUri(machineryRow); - assert.ok(machineryUri, "the machinery row must carry a resourceUri"); - const deco = decorations.provideFileDecoration(machineryUri); - assert.strictEqual(colorId(deco?.color), "disabledForeground", "a non-debuggable row is greyed"); - assert.ok( - tooltipText(machineryRow).includes("debugger machinery"), - `the tooltip must explain why it can't be profiled: ${tooltipText(machineryRow)}`, - ); - } finally { - decorations.dispose(); - } - }); -}); - -// Icons, decoration precedence, tooltip detail, and within-group sinking — -// [PROFILE-PROCESSES-DISPLAY] (R4, R5, R6, R8). -suite("Python Processes Panel — display cues: icons, precedence, tooltip, grouping", () => { - let provider: PythonProcessesProvider; - teardown(() => { provider.dispose(); }); - - test("each process state renders its own info icon (R4)", async () => { - const plain: ProcessInfo = { ...STUB_PROCESSES[0], pid: 111, inWorkspace: false }; - const store = storeWith([MACHINERY, plain, ...STUB_PROCESSES]); - store.profilerActive(100, "sess-cpu"); // mark PID 100 as actively profiled (store-derived, #148) - provider = await loadedProvider(store); - const rows = await provider.getChildren(); - const icons = new Map(rows.map((r) => [pidOf(r), iconId(r)])); - assert.strictEqual(icons.get(100), "flame", "the actively-profiled row shows the flame"); - assert.strictEqual(icons.get(200), "lock", "an elevation row stays debuggable but shows the lock"); - assert.strictEqual(icons.get(300), "rocket", "a launcher row shows the rocket"); - assert.strictEqual(icons.get(111), "vm-running", "a plain interpreter shows the running-VM glyph"); - assert.strictEqual(icons.get(900), "circle-slash", "a non-debuggable row shows circle-slash"); - }); - - test("greying wins over green for a non-debuggable workspace process (R5 > R6)", async () => { - const wsMachinery: ProcessInfo = { ...MACHINERY, inWorkspace: true }; - provider = await loadedProvider(storeWith([wsMachinery])); - const decorations = new ProcessDecorationProvider(provider); - try { - const row = processRows(await provider.getChildren()).find((r) => pidOf(r) === 900); - assert.ok(row, "the process row must exist"); - const uri = resourceUri(row); - assert.ok(uri, "the row must carry a resourceUri"); - const deco = decorations.provideFileDecoration(uri); - assert.strictEqual( - colorId(deco?.color), - "disabledForeground", - "a workspace process you can't debug must be greyed, not green", - ); - } finally { - decorations.dispose(); - } - }); - - test("the tooltip surfaces every resolved detail (R8)", async () => { - const rich: ProcessInfo = { - pid: 555, ppid: 1, name: "python3.12", interpreterPath: "/usr/bin/python3.12", - script: "/app/svc.py", pythonVersion: "3.12.1", cpuPercent: 7, memoryBytes: 12 * MB, - runtimeSecs: 65, user: "carol", requiresElevation: false, - inWorkspace: true, launcher: "gunicorn", debuggable: true, undebuggableReason: null, - }; - provider = await loadedProvider(storeWith([rich])); - const row = processRows(await provider.getChildren()).find((r) => pidOf(r) === 555); - assert.ok(row, "the process row must exist"); - const tip = tooltipText(row); - for (const needle of [ - "PID 555", "Interpreter: /usr/bin/python3.12", "Script: /app/svc.py", - "Python: 3.12.1", "Runtime:", "User: carol", "Launcher: gunicorn", "Workspace", - ]) { - assert.ok(tip.includes(needle), `tooltip must surface "${needle}": ${tip}`); - } - }); - - test("when grouped, a non-debuggable process sinks within its group (R5)", async () => { - const machinery: ProcessInfo = { ...MACHINERY, pythonVersion: "3.12.1" }; // shares 100 & 300's group - provider = await loadedProvider(storeWith([machinery, ...STUB_PROCESSES])); - provider.cycleGroupMode(); // none → version - const groups = await provider.getChildren(); - const twelve = groups.find((g) => labelText(g) === "3.12.1"); - assert.ok(twelve, "the 3.12.1 group must exist"); - const members = await provider.getChildren(twelve); - assert.deepStrictEqual( - members.map(pidOf), - [100, 300, 900], - "debuggable rows first (CPU-ordered), the non-debuggable one sinks last within the group", - ); - }); -}); - -// The big "Run & …(Current File)" buttons can't live in viewsWelcome once the -// tree is populated (VS Code renders welcome only for an EMPTY view), so they are -// pinned as rows at the top — gated per activity. [PROFILE-PROCESSES-LAUNCH-FILE] -// / [PROFILE-PROCESSES-REACTIVE]. -suite("Python Processes Panel — pinned launch buttons", () => { - let provider: PythonProcessesProvider; - teardown(() => { provider.dispose(); }); - - function commandsOf(rows: vscode.TreeItem[]): (string | undefined)[] { - return actionRows(rows).map((r) => r.command?.command); - } - - test("the current-file launches are pinned above the process rows even when a process is listed", async () => { - provider = await loadedProvider(storeWith(STUB_PROCESSES)); - const rows = await provider.getChildren(); - assert.deepStrictEqual( - commandsOf(rows), - ["basilisk.profileCurrentFileCpu", "basilisk.trackMemoryCurrentFile"], - "both launches must be pinned, CPU then memory", - ); - assert.strictEqual(rows[0].contextValue, "launchAction", "a launch row is first"); - assert.ok(processRows(rows).length > 0, "the process rows still follow the launches"); - }); - - test("a busy metric hides ITS launch row but leaves the other (both: CPU during memory, memory during CPU)", async () => { - const store = storeWith(STUB_PROCESSES); - provider = await loadedProvider(store); - - store.profilerActive(4242, "sess-cpu"); // CPU busy - assert.deepStrictEqual( - commandsOf(await provider.getChildren()), - ["basilisk.trackMemoryCurrentFile"], - "while CPU profiles, the CPU launch is hidden but the memory launch remains", - ); - - store.profilerStopped(); - store.memoryTrackingActive("sess-mem"); // memory busy - assert.deepStrictEqual( - commandsOf(await provider.getChildren()), - ["basilisk.profileCurrentFileCpu"], - "while memory tracks, the memory launch is hidden but the CPU launch remains", - ); - store.memoryTrackingStopped(); - }); - - test("with no processes the tree is empty so the viewsWelcome big buttons render", async () => { - provider = await loadedProvider(storeWith([])); - assert.deepStrictEqual( - await provider.getChildren(), - [], - "an empty process list defers to the welcome buttons rather than pinning rows", - ); - }); -}); - -// Memory tracking can only target the active Basilisk debuggee, so the panel -// reveals the inline Track Memory action on that row alone and warns elsewhere — -// answering "why not grey it out beforehand?" ([PROFILE-PROCESSES-LAUNCH]). -suite("Python Processes Panel — Track Memory is debuggee-only", () => { - let provider: PythonProcessesProvider; - teardown(() => { provider.dispose(); }); - - test("only the active-debuggee row carries the Track-Memory-enabling contextValue", async () => { - const store = storeWith(STUB_PROCESSES); - store.setActiveDebuggeePid(100); // PID 100 is the active debuggee (centralised, #148) - provider = await loadedProvider(store); - const rows = processRows(await provider.getChildren()); - function ctxOf(pid: number): string | undefined { - return rows.find((r) => pidOf(r) === pid)?.contextValue; - } - assert.strictEqual(ctxOf(100), "pythonProcessDebuggee", "the debuggee row enables Track Memory"); - assert.strictEqual(ctxOf(200), "pythonProcessElevated", "an external (elevated) row does not"); - assert.strictEqual(ctxOf(300), "pythonProcess", "an external launcher row does not"); - }); - - test("non-debuggee rows warn that memory tracking is unavailable here; the debuggee does not", async () => { - const store = storeWith(STUB_PROCESSES); - store.setActiveDebuggeePid(100); - provider = await loadedProvider(store); - const rows = processRows(await provider.getChildren()); - const debuggee = rows.find((r) => pidOf(r) === 100); - const external = rows.find((r) => pidOf(r) === 300); - assert.ok(debuggee && external, "both rows must exist"); - assert.ok( - !tooltipText(debuggee).includes("Memory tracking needs"), - "the debuggee row offers tracking, so it shows no caveat", - ); - assert.ok( - tooltipText(external).includes("Memory tracking needs"), - `a non-debuggee row must warn memory tracking is unavailable: ${tooltipText(external)}`, - ); - }); - - test("with no active debuggee, no row enables Track Memory", async () => { - const store = storeWith(STUB_PROCESSES); - store.setActiveDebuggeePid(undefined); - provider = await loadedProvider(store); - const rows = processRows(await provider.getChildren()); - assert.ok( - !rows.some((r) => r.contextValue === "pythonProcessDebuggee"), - "no row may offer Track Memory when nothing runs under Basilisk", - ); - }); -}); - -// ── Inline action target resolution (issue #79) [PROFILE-PROCESSES-PANEL] ── -// -// Clicking the inline flame / database icon on a process row must act on -// THAT row. At runtime VS Code has been observed to invoke the command with -// `item === undefined`; the handler must fall back to the tree view's current -// selection — and only warn when there is truly no target. - -suite("Python Processes Panel — inline action target (issue #79)", () => { - /** Run fn with showWarningMessage stubbed, returning captured warnings. */ - async function captureWarnings(fn: () => Promise<void>): Promise<string[]> { - const warnings: string[] = []; - const original = vscode.window.showWarningMessage; - (vscode.window as { showWarningMessage: unknown }).showWarningMessage = async ( - message: string, - ): Promise<undefined> => { - warnings.push(message); - return Promise.resolve(undefined); - }; - try { - await fn(); - } finally { - (vscode.window as { showWarningMessage: unknown }).showWarningMessage = original; - } - return warnings; - } - - test("undefined item falls back to the tree selection and profiles that PID — without warning", async () => { - const requests: RecordedRequest[] = []; - const store = storeWith(STUB_PROCESSES, requests); - const provider = await loadedProvider(store); - try { - const rows = await provider.getChildren(); - const selected = rows.find((row) => pidOf(row) === 100); - assert.ok(selected, "expected the PID 100 row"); - - const actions = createProcessRowActions(store, { selection: [selected] }); - const warnings = await captureWarnings(async () => actions.profileProcess(undefined)); - - assert.deepStrictEqual(warnings, [], "must not warn when a row is selected"); - const starts = profilerStarts(requests); - assert.strictEqual(starts.length, 1, "profiler start must be requested"); - assert.strictEqual(startPid(starts[0]), 100, "must profile the selected row's PID"); - } finally { - provider.dispose(); - } - }); - - test("memory tracking falls back to the tree selection the same way — without warning", async () => { - const requests: RecordedRequest[] = []; - const executed: string[] = []; - const store = storeWith(STUB_PROCESSES, requests); - store.setDebuggeeProcessId("session-1", 200); - const provider = await loadedProvider(store); - try { - const rows = await provider.getChildren(); - const selected = rows.find((row) => pidOf(row) === 200); - assert.ok(selected, "expected the PID 200 row"); - - const actions = createProcessRowActions(store, { selection: [selected] }, { - runCommand: async (command) => { executed.push(command); }, - activeSession: () => ({ id: "session-1", type: "basilisk-debug" }), - }); - const warnings = await captureWarnings(async () => actions.memoryTrackProcess(undefined)); - - assert.deepStrictEqual(warnings, [], "must not warn when a row is selected"); - assert.deepStrictEqual( - executed, - ["basilisk.memoryStart"], - "the selection fallback must reach the real memory-tracking flow", - ); - } finally { - provider.dispose(); - } - }); - - test("warns exactly once when there is neither an item nor a selection", async () => { - const requests: RecordedRequest[] = []; - const store = storeWith(STUB_PROCESSES, requests); - - const actions = createProcessRowActions(store, { selection: [] }); - const warnings = await captureWarnings(async () => actions.profileProcess(undefined)); - - assert.strictEqual(warnings.length, 1, "must warn exactly once"); - assert.strictEqual( - profilerStarts(requests).length, - 0, - "must not start profiling without a target", - ); - }); -}); - -// A row the panel itself marks 🚫 (non-debuggable) must never offer the Profile -// CPU action: package.json reveals it via `viewItem =~ /^pythonProcess/`, and the -// command handler is the last line of defence against a stale/raced row (#266). -suite("Python Processes Panel — blocked rows never offer Profile CPU (#266)", () => { - const BLOCKED_MACHINERY: ProcessInfo = { - pid: 400, ppid: 100, name: "python3.13", interpreterPath: "/usr/bin/python3.13", - script: null, pythonVersion: "3.13.7", cpuPercent: 0, memoryBytes: 5 * MB, - runtimeSecs: 60, user: "alice", requiresElevation: true, - inWorkspace: false, launcher: null, debuggable: false, undebuggableReason: "debugger machinery", - }; - let provider: PythonProcessesProvider; - teardown(() => { provider.dispose(); }); - - test("a non-debuggable row's contextValue opts out of the /^pythonProcess/ menu clauses", async () => { - provider = await loadedProvider(storeWith([...STUB_PROCESSES, BLOCKED_MACHINERY])); - const rows = processRows(await provider.getChildren()); - const blocked = rows.find((row) => pidOf(row) === 400); - assert.ok(blocked !== undefined, "the machinery row must still be listed (zero-filter)"); - const ctx = blocked.contextValue ?? ""; - // package.json's clause is `viewItem =~ /^pythonProcess/` — an anchored - // prefix match, i.e. exactly a startsWith check. - assert.ok( - !ctx.startsWith("pythonProcess"), - "package.json reveals Profile CPU on `viewItem =~ /^pythonProcess/`, so a row the panel " + - `already marks 🚫 "Can't profile" must not match it; got contextValue: ${ctx}`, - ); - }); - - test("invoking Profile CPU on a blocked row refuses instead of attaching", async () => { - const requests: RecordedRequest[] = []; - const store = storeWith([...STUB_PROCESSES, BLOCKED_MACHINERY], requests); - provider = await loadedProvider(store); - const rows = processRows(await provider.getChildren()); - const blocked = rows.find((row) => pidOf(row) === 400); - assert.ok(blocked !== undefined, "the machinery row must exist"); - - const actions = createProcessRowActions(store, { selection: [blocked] }); - await actions.profileProcess(blocked); - - assert.strictEqual( - profilerStarts(requests).length, - 0, - "a row flagged \"Can't profile\" must never send basilisk.profiler.start — " + - "the attach is known to fail (#266)", - ); - }); -}); diff --git a/vscode-extension/src/test/suite/process-reactive-store.test.ts b/vscode-extension/src/test/suite/process-reactive-store.test.ts deleted file mode 100644 index c0e972990..000000000 --- a/vscode-extension/src/test/suite/process-reactive-store.test.ts +++ /dev/null @@ -1,183 +0,0 @@ -// Tests for [PROFILE-PROCESSES-REACTIVE]. See docs/specs/LSP-PROFILING-SPEC.md#PROFILE-PROCESSES-REACTIVE -// -// Issue #148: the Python Processes panel must be a pure projection of -// centralised store Signals — no panel-local timer, no panel-owned data. These -// tests drive the store's `processes` Signal and assert the tree re-renders -// reactively through the production wiring (`subscribeRevision` over -// `store.processesRevision`, the exact subscription registerPythonProcesses -// installs), that view state (sort/group/filter/debuggee) is shared by every -// subscriber, and that the poll feeding the store lives store-side -// (process-poll.ts), gated on view visibility. - -import { delay } from "../../timeouts"; -import * as assert from "assert"; -import * as vscode from "vscode"; -import { type LanguageClient } from "vscode-languageclient/node"; -import { PythonProcessesProvider, type ProcessInfo } from "../../process-explorer"; -import { bindProcessPolling, fetchProcessesIntoStore } from "../../process-poll"; -import { subscribeRevision } from "../../reactive-refresh"; -import { createStore, type Store } from "../../store"; -import { numberField, rawField } from "../../unknown-shape"; - -const MB = 1024 * 1024; - -/** A minimal, debuggable process row. */ -function proc(pid: number, overrides: Partial<ProcessInfo> = {}): ProcessInfo { - return { - pid, ppid: 1, name: `python-${pid}`, interpreterPath: "/usr/bin/python3.12", - script: `/app/p${pid}.py`, pythonVersion: "3.12.1", cpuPercent: pid, memoryBytes: pid * MB, - runtimeSecs: 10, user: "alice", requiresElevation: false, - inWorkspace: false, launcher: null, debuggable: true, undebuggableReason: null, - ...overrides, - }; -} - -/** A store whose fake LSP client serves the given process table. */ -function storeServing(processes: readonly ProcessInfo[]): Store { - const store = createStore(); - // A stand-in for the members the code under test calls. No runtime check - // can produce the rest of `LanguageClient`, so the test double itself is - // the one assertion here — it is not a payload being read. - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- see above. - const client = { - isRunning: (): boolean => true, - onDidChangeState: (): vscode.Disposable => ({ dispose: (): undefined => undefined }), - sendRequest: async (): Promise<unknown> => ({ processes }), - } as unknown as LanguageClient; - store.setClient({ subscriptions: [] }, client); - return store; -} - -/** A provider wired to the store exactly as registerPythonProcesses wires it. */ -function subscribedProvider(store: Store): PythonProcessesProvider { - const provider = new PythonProcessesProvider(store); - subscribeRevision(store.processesRevision, provider); - return provider; -} - -/** The PIDs of the plain process rows in a root listing. */ -function pidsOf(rows: vscode.TreeItem[]): number[] { - // `process` is attached to the row by the provider under test, not by - // `vscode.TreeItem`, so it is read back by name rather than asserted on. - return rows - .map((row) => numberField(rawField(row, "process"), "pid")) - .filter((pid): pid is number => pid !== undefined); -} - -suite("Python Processes — pure projection of store Signals (#148)", () => { - test("a store bump repaints the tree through the production subscription — no fetch, no timer", async () => { - // Deliberately NO LSP client: if the panel needed to fetch anything itself, - // this test could not render a single row. - const store = createStore(); - const provider = subscribedProvider(store); - try { - let repaints = 0; - provider.disposables.push(provider.onDidChangeTreeData(() => { repaints += 1; })); - - assert.deepStrictEqual(await provider.getChildren(), [], "nothing to render before the store holds data"); - - store.processesLoaded([proc(7)]); - assert.strictEqual(repaints, 1, "the revision bump must repaint the subscribed tree"); - assert.deepStrictEqual(pidsOf(await provider.getChildren()), [7], "the tree renders exactly the store's list"); - - store.processesLoaded([]); - assert.strictEqual(repaints, 2, "clearing the store repaints again"); - assert.deepStrictEqual(await provider.getChildren(), [], "an emptied store empties the tree"); - } finally { - provider.dispose(); - } - }); - - test("the provider owns no data: a second panel over the same store renders the fetched list without fetching", async () => { - const store = storeServing([proc(1), proc(2)]); - const fetcher = subscribedProvider(store); - const observer = subscribedProvider(store); // never fetches - try { - await fetcher.refreshNow(); - assert.deepStrictEqual( - pidsOf(await observer.getChildren()).sort((a, b) => a - b), - [1, 2], - "a panel that never fetched renders the centralised list — the data lives in the store", - ); - } finally { - fetcher.dispose(); - observer.dispose(); - } - }); - - test("view state (sort, filter, debuggee) is centralised — one panel's change drives every subscriber", async () => { - const store = storeServing([proc(10, { cpuPercent: 1, memoryBytes: 99 * MB }), proc(20, { cpuPercent: 50, memoryBytes: 1 * MB })]); - const panelA = subscribedProvider(store); - const panelB = subscribedProvider(store); - try { - await panelA.refreshNow(); - assert.deepStrictEqual(pidsOf(await panelB.getChildren()), [20, 10], "default sort: CPU descending"); - - panelA.cycleSortMode(); // cpu → memory - assert.deepStrictEqual( - pidsOf(await panelB.getChildren()), - [10, 20], - "panel A's sort change re-orders panel B — the mode lives in the store, not the panel", - ); - - panelA.setFilter("p20"); - assert.deepStrictEqual(pidsOf(await panelB.getChildren()), [20], "the filter is centralised too"); - panelA.setFilter(""); - - store.setActiveDebuggeePid(20); - const debuggeeRow = (await panelB.getChildren()).find( - (row) => numberField(rawField(row, "process"), "pid") === 20, - ); - assert.strictEqual( - debuggeeRow?.contextValue, - "pythonProcessDebuggee", - "the debuggee marker is a store signal every panel projects", - ); - } finally { - panelA.dispose(); - panelB.dispose(); - } - }); - - test("the poll lives store-side: binding a visible view fetches into the store immediately", async () => { - const store = storeServing([proc(42)]); - const visibility = new vscode.EventEmitter<vscode.TreeViewVisibilityChangeEvent>(); - const polling = bindProcessPolling(store, { visible: true, onDidChangeVisibility: visibility.event }); - try { - // The immediate fetch is fire-and-forget; wait for the signal to settle. - const deadline = Date.now() + 2000; - while (store.processes.value.fetch !== "loaded" && Date.now() < deadline) { - await delay(10); - } - assert.strictEqual(store.processes.value.fetch, "loaded", "the store-side poll must fetch on bind"); - assert.deepStrictEqual(store.processes.value.list.map((p) => p.pid), [42], "the fetch landed in the store signal"); - } finally { - polling.dispose(); - visibility.dispose(); - } - }); - - test("every fetch outcome lands honestly in the store (#147 via the store path)", async () => { - // No client → still loading (never "no processes"). - const bare = createStore(); - await fetchProcessesIntoStore(bare); - assert.strictEqual(bare.processes.value.fetch, "loading"); - - // Failing client → error, and any stale rows are dropped. - const failing = createStore(); - // A stand-in for the members the code under test calls. No runtime check - // can produce the rest of `LanguageClient`, so the test double itself is - // the one assertion here — it is not a payload being read. - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- see above. - const client = { - isRunning: (): boolean => true, - onDidChangeState: (): vscode.Disposable => ({ dispose: (): undefined => undefined }), - sendRequest: async (): Promise<unknown> => { throw new Error("disconnected"); }, - } as unknown as LanguageClient; - failing.setClient({ subscriptions: [] }, client); - failing.processesLoaded([proc(1)]); - await fetchProcessesIntoStore(failing); - assert.strictEqual(failing.processes.value.fetch, "error"); - assert.deepStrictEqual(failing.processes.value.list, [], "a failed fetch never leaves stale rows on screen"); - }); -}); diff --git a/vscode-extension/src/test/suite/profile-server.test.ts b/vscode-extension/src/test/suite/profile-server.test.ts deleted file mode 100644 index d06e4df11..000000000 --- a/vscode-extension/src/test/suite/profile-server.test.ts +++ /dev/null @@ -1,105 +0,0 @@ -// Tests for [PROFILE-VIEWER-DELIVERY]. See -// docs/specs/LSP-PROFILING-SPEC.md#PROFILE-VIEWER-DELIVERY -// -// "Open in Speedscope" must actually load the profile: speedscope.app is https -// and can never read file:// URLs, so the extension serves the exported JSON -// over a loopback HTTP URL its importer can fetch. These tests pin the served -// response (body, CORS, no-store) and the containment properties (unguessable -// token required, expiry, teardown) — without them the button regresses to -// dumping the user on speedscope's empty "Browse" landing page. - -import { delay } from "../../timeouts"; -import * as assert from "assert"; -import * as fs from "fs"; -import * as http from "http"; -import * as os from "os"; -import * as path from "path"; -import { disposeProfileServer, serveProfileForBrowser } from "../../profile-server"; -import { removeTestDir } from './test-helpers'; - -/** GET a URL and resolve status, headers, and body. */ -async function fetchUrl( - url: string, -): Promise<{ status: number; headers: http.IncomingHttpHeaders; body: string }> { - return new Promise((resolve, reject) => { - http - .get(url, (res) => { - let body = ""; - res.on("data", (chunk: Buffer) => { - body += chunk.toString(); - }); - res.on("end", () => { - resolve({ status: res.statusCode ?? 0, headers: res.headers, body }); - }); - }) - .on("error", reject); - }); -} - -suite("Profile loopback server — speedscope deep links load automatically", () => { - let tmpDir: string; - let profilePath: string; - const profileJson = '{"$schema":"https://www.speedscope.app/file-format-schema.json"}'; - - suiteSetup(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "bsk-profile-server-")); - profilePath = path.join(tmpDir, "profile.speedscope.json"); - fs.writeFileSync(profilePath, profileJson); - }); - - suiteTeardown(() => { - disposeProfileServer(); - removeTestDir(tmpDir); - }); - - test("a registered profile is served with the headers speedscope's importer needs", async () => { - const url = await serveProfileForBrowser(profilePath); - assert.ok( - url.startsWith("http://127.0.0.1:"), - `the URL must be loopback-only, never file:// ([PROFILE-VIEWER-DELIVERY]); got ${url}`, - ); - assert.ok( - url.endsWith("/profile.speedscope.json"), - `the URL must carry the real basename so speedscope's extension-based ` + - `format detection works (.heapprofile vs speedscope JSON); got ${url}`, - ); - const response = await fetchUrl(url); - assert.strictEqual(response.status, 200, "the registered profile must be fetchable"); - assert.strictEqual(response.body, profileJson, "the body must be the exact exported JSON"); - assert.strictEqual( - response.headers["access-control-allow-origin"], - "*", - "speedscope.app fetches cross-origin — without CORS the import fails silently", - ); - assert.strictEqual( - response.headers["cache-control"], - "no-store", - "profiles must never be cached beyond the registration", - ); - }); - - test("an unregistered or malformed token is a 404 — the token is the access control", async () => { - const url = await serveProfileForBrowser(profilePath); - const base = url.slice(0, url.indexOf("/", "http://".length)); - const wrongToken = await fetchUrl(`${base}/${"0".repeat(32)}/profile.json`); - assert.strictEqual(wrongToken.status, 404, "an unknown token must not serve anything"); - const noToken = await fetchUrl(`${base}/profile.json`); - assert.strictEqual(noToken.status, 404, "a token-less path must not serve anything"); - }); - - test("an expired registration stops being served", async () => { - const url = await serveProfileForBrowser(profilePath, 1); - await delay(10); - const response = await fetchUrl(url); - assert.strictEqual(response.status, 404, "expired registrations must 404, not serve stale data"); - }); - - test("dispose tears the server down — nothing stays reachable after deactivate", async () => { - const url = await serveProfileForBrowser(profilePath); - disposeProfileServer(); - await assert.rejects( - fetchUrl(url), - "after dispose the port must refuse connections ([PROFILE-VIEWER-DELIVERY] containment)", - ); - }); -}); diff --git a/vscode-extension/src/test/suite/profiler-cpu-e2e.test.ts b/vscode-extension/src/test/suite/profiler-cpu-e2e.test.ts deleted file mode 100644 index 9471c0e13..000000000 --- a/vscode-extension/src/test/suite/profiler-cpu-e2e.test.ts +++ /dev/null @@ -1,957 +0,0 @@ -// Tests for [PROFILE-VIS-HEATMAP] + [PROFILE-NATIVE] + [PROFILE-NOTIFICATIONS-PROGRESS]. -// See docs/specs/LSP-PROFILING-SPEC.md#PROFILE-VIS-HEATMAP -// -// REAL CPU-profiling end-to-end: spawn an actual CPU-bound Python process, -// attach through the real LSP, and assert the artifacts the user actually -// sees — the inline heat map (via the applied-decoration ledger), the live -// status-bar progress, the hot-function attribution, the `.cpuprofile` for -// VS Code's built-in viewer, the speedscope JSON, and the flamegraph webview -// HTML. Attach assertions are Linux-gated (CI runs ubuntu; macOS requires -// root for py-spy), and every platform asserts the actionable #81 error path. - -import { delay } from "../../timeouts"; -import * as assert from "assert"; -import * as vscode from "vscode"; -import * as fs from "fs"; -import * as path from "path"; -import { execFileSync, spawn, type ChildProcess } from "child_process"; -import { getStore } from "../../extension"; -import { evaluateInDebugSession, waitForStoppedFrame } from "../../dap-evaluate"; -import { buildProfileLaunchConfig } from "../../process-launch"; -import { profilerStatusText, startProfilingForPid } from "../../profiler"; -import { pythonProcessesViewState } from "../../process-reactivity"; -import { recordedOperations } from "../../progress-ops"; -import { - arrayField, - numberArrayField, - numberField, - recordArrayField, - recordField, - stringField, -} from "../../unknown-shape"; -import { - applyProfileDecorations, - clearProfileDecorations, - appliedProfileDecorations, - type ProfileResult, -} from "../../profiler-decorations"; -import { - buildFlamegraphHtml, - disposeFlamegraphPanel, - flamegraphPanelOpen, - profileHasNoUsableData, -} from "../../profiler-flamegraph-html"; -import { - openPythonFile, - pollUntilResult, - setupLspTestSuite, - teardownLspTestSuite, - closeAllEditors, - waitForLspReady, - isSamePath, -} from "./test-helpers"; - -/** How long the burner keeps spinning (covers the whole suite). */ -const BURNER_LIFETIME_SECS = 120; -/** Sampling window before stopping a profile. */ -const SAMPLE_WINDOW_MS = 2_500; -/** - * Ceiling for the cooperative sampler to attribute the hot loop. That path runs - * the debuggee under debugpy line-tracing, so on a slow/contended CI runner the - * interpreter can spend several seconds in Python/debugpy startup before the hot - * loop dominates the samples. Poll snapshots up to this budget instead of - * assuming a fixed window — keeping the assertion strict without being - * timing-fragile. The burner spins for BURNER_LIFETIME_SECS, well beyond this. - */ -const HOT_ATTRIBUTION_TIMEOUT_MS = 30_000; -/** Budget for the LSP attach + first progress notification. */ -const PROGRESS_WAIT_MS = 10_000; -/** Budget for profiler diagnostics to be published after stop. */ -const DIAGNOSTICS_WAIT_MS = 10_000; -/** The CPU heat-map palette ([PROFILE-VIS-PALETTE]). */ -const HEAT_PALETTE = ["#e8500a", "#f97316", "#fbbf24", "#4a5468"]; - -/** 1-based line of `def hot_function` in the burner source below. */ -const HOT_FUNCTION_DEF_LINE = 12; - -/** - * CPU burner: ~all samples land in hot_function. `PR_SET_PTRACER_ANY` lets a - * non-ancestor LSP attach under Linux Yama ptrace_scope=1 (same trick as the - * Rust e2e suites). - */ -const BURNER_SOURCE = `import sys -import time - -try: - import ctypes - _libc = ctypes.CDLL("libc.so.6", use_errno=True) - _libc.prctl(0x59616D61, ctypes.c_ulong(0xFFFFFFFFFFFFFFFF), 0, 0, 0) -except Exception: - pass - - -def hot_function(): - total = 0 - for i in range(1_000_000): - total += i * i - return total - - -def main(): - print("READY", flush=True) - deadline = time.time() + ${BURNER_LIFETIME_SECS} - while time.time() < deadline: - hot_function() - - -if __name__ == "__main__": - main() -`; - -/** The python interpreter for spawning helper processes. */ -const PYTHON = process.platform === "win32" ? "python" : "python3"; - -/** Spawn the burner and resolve once it prints READY. */ -async function spawnBurner(scriptPath: string): Promise<ChildProcess> { - const child = spawn(PYTHON, [scriptPath], { stdio: ["ignore", "pipe", "ignore"] }); - await new Promise<void>((resolve, reject) => { - const timer = setTimeout(() => reject(new Error("burner never printed READY")), PROGRESS_WAIT_MS); - child.stdout?.on("data", (chunk: Buffer) => { - if (chunk.toString().includes("READY")) { - clearTimeout(timer); - resolve(); - } - }); - child.on("exit", () => reject(new Error("burner exited before READY"))); - }); - return child; -} - -/** The shape `basilisk.profiler.start` resolves to. */ -interface StartResult { - sessionId: string; - pid: number; - pythonVersion: string; -} - -/** Stop any session left behind so suites stay independent. */ -async function stopAllProfilerSessions(): Promise<void> { - const list = await vscode.commands.executeCommand<{ sessions?: { sessionId: string }[] }>( - "basilisk.profiler.list", - ); - for (const session of list?.sessions ?? []) { - try { - await vscode.commands.executeCommand("basilisk.profiler.stop", { sessionId: session.sessionId }); - } catch { - // already gone - } - } -} - -/** Assert the speedscope JSON artifact exists and attributes hot_function. */ -function assertSpeedscopeArtifact(outputFile: string): void { - assert.ok(fs.existsSync(outputFile), `speedscope file must exist: ${outputFile}`); - const speedscope: unknown = JSON.parse(fs.readFileSync(outputFile, "utf8")); - const frames = recordArrayField(recordField(speedscope, "shared"), "frames"); - assert.ok( - frames.some((frame) => stringField(frame, "name") === "hot_function"), - "speedscope frames must include hot_function", - ); - assert.ok(arrayField(speedscope, "profiles").length > 0, "speedscope must contain at least one profile"); -} - -// Implements [PROFILE-FLAMEGRAPH]. See docs/specs/LSP-PROFILING-SPEC.md#PROFILE-FLAMEGRAPH -/** - * Assert the flame graph SVG artifact of a REAL profile exists, parses as SVG, - * and lands in the results webview as the inline hero — the full path from a - * live profile stop to the flame graph the user actually sees. - */ -function assertFlamegraphArtifact(result: ProfileResult): void { - const flamegraphPath = result.flamegraphPath; - assert.ok( - typeof flamegraphPath === "string" && flamegraphPath !== "", - "the stop response must carry flamegraphPath — the LSP always exports the SVG", - ); - assert.ok(fs.existsSync(flamegraphPath), `flame graph SVG must be written to disk: ${flamegraphPath}`); - const svg = fs.readFileSync(flamegraphPath, "utf8"); - assert.ok(svg.includes("<svg"), "the flame graph artifact must be a real SVG document"); - const html = buildFlamegraphHtml(result); - assert.ok( - html.includes("data:image/svg+xml;base64,"), - "the results webview must embed the flame graph SVG as its hero", - ); - assert.ok( - html.includes("openFlamegraphSvg"), - "the hero must offer opening the interactive SVG externally", - ); -} - -/** Assert the V8 `.cpuprofile` exists and opens as a valid call tree ([PROFILE-NATIVE]). */ -function assertCpuProfileArtifact(cpuProfilePath: string | undefined, expectedFunction?: string): void { - assert.ok(typeof cpuProfilePath === "string" && cpuProfilePath !== "", "cpuProfilePath returned"); - assert.ok(fs.existsSync(cpuProfilePath), ".cpuprofile must be written to disk"); - const cpuprofile: unknown = JSON.parse(fs.readFileSync(cpuProfilePath, "utf8")); - const nodes = recordArrayField(cpuprofile, "nodes"); - const samples = numberArrayField(cpuprofile, "samples"); - const timeDeltas = numberArrayField(cpuprofile, "timeDeltas"); - assert.ok(nodes.length > 0, ".cpuprofile must have a call tree"); - assert.ok(samples.length > 0, ".cpuprofile must have samples"); - assert.strictEqual( - samples.length, - timeDeltas.length, - ".cpuprofile samples and timeDeltas must be parallel arrays", - ); - if (expectedFunction !== undefined) { - assert.ok( - nodes.some((node) => stringField(recordField(node, "callFrame"), "functionName") === expectedFunction), - `.cpuprofile call tree must include ${expectedFunction}`, - ); - } -} - -/** Filenames that mark debugger/launcher scaffolding ([PROFILE-AGGREGATION-SCAFFOLD]). */ -const SCAFFOLDING_FILE_RE = /runpy|debugpy|pydevd|<string>/i; - -/** - * Assert every REAL artifact of a debug-launched profile roots at the user's - * code with zero launcher scaffolding — the hot lists, the speedscope JSON on - * disk, and the `.cpuprofile` call tree, whose root spine must reach the - * user's `<module>` immediately instead of nine rows of - * `_run_module_as_main`/`run_path`/debugpy frames - * ([PROFILE-AGGREGATION-SCAFFOLD]). No mocks: the inputs are the exact files - * a user opens. - */ -function assertArtifactsRootAtUserCode(result: ProfileResult, burnerPath: string): void { - assertHotListsCarryNoScaffolding(result); - assertSpeedscopeCarriesNoScaffolding(result.outputFile); - const cpuProfilePath = result.cpuProfilePath; - assert.ok(typeof cpuProfilePath === "string" && cpuProfilePath !== "", "cpuProfilePath returned"); - assertCpuprofileRootsAtUserCode(cpuProfilePath, burnerPath); -} - -function assertHotListsCarryNoScaffolding(result: ProfileResult): void { - for (const fn of result.hotFunctions) { - assert.ok(!SCAFFOLDING_FILE_RE.test(fn.file), `hotFunctions must carry no scaffolding, got ${fn.file}`); - } - for (const line of result.hotLines) { - assert.ok(!SCAFFOLDING_FILE_RE.test(line.file), `hotLines must carry no scaffolding, got ${line.file}`); - } -} - -function assertSpeedscopeCarriesNoScaffolding(outputFile: string): void { - const speedscope: unknown = JSON.parse(fs.readFileSync(outputFile, "utf8")); - for (const frame of recordArrayField(recordField(speedscope, "shared"), "frames")) { - const file = stringField(frame, "file"); - assert.ok( - !SCAFFOLDING_FILE_RE.test(file ?? ""), - `speedscope frames must carry no scaffolding, got ${String(file)}`, - ); - } -} - -function assertCpuprofileRootsAtUserCode(cpuProfilePath: string, burnerPath: string): void { - const cpuprofile: unknown = JSON.parse(fs.readFileSync(cpuProfilePath, "utf8")); - const nodes = recordArrayField(cpuprofile, "nodes"); - for (const node of nodes) { - const url = stringField(recordField(node, "callFrame"), "url"); - assert.ok( - !SCAFFOLDING_FILE_RE.test(url ?? ""), - `.cpuprofile must carry no scaffolding nodes, got ${String(url)}`, - ); - } - // The flame chart's first real row is the user's own module — the launcher - // spine is gone, so the user's code gets the full canvas. - const byId = new Map(nodes.map((node) => [numberField(node, "id"), node])); - const root = nodes[0]; - assert.ok(root !== undefined, ".cpuprofile must have a root node"); - for (const childId of numberArrayField(root, "children")) { - const url = stringField(recordField(byId.get(childId), "callFrame"), "url"); - // Path-compare, not string-compare: the `.cpuprofile` url and the fixture - // path are spelled differently on Windows (see `samePath`). - assert.ok( - url !== undefined && samePath(url, burnerPath), - `every top-level frame must be the user's file ${burnerPath}, got ${String(url)}`, - ); - } -} - -/** - * Compare two paths the way the host filesystem does. - * - * The paths under comparison come from different producers: the profiler - * reports the interpreter's own filename, while the decoration ledger records - * `Uri.fsPath`, whose drive letter VS Code lower-cases. Windows paths are - * case-insensitive, so the two name the same file; POSIX paths are - * case-SENSITIVE, so folding there would let a genuinely different file pass. - */ -function samePath(left: string, right: string): boolean { - const a = path.resolve(left); - const b = path.resolve(right); - return process.platform === "win32" ? a.toLowerCase() === b.toLowerCase() : a === b; -} - -function hasBurnerHotFunction(result: Pick<ProfileResult, "hotFunctions">, burnerPath: string): boolean { - return result.hotFunctions.some((fn) => samePath(fn.file, burnerPath)); -} - -function hotFunctionSummary(result: Pick<ProfileResult, "hotFunctions">): string { - return result.hotFunctions - .map((fn) => `${fn.name}@${fn.file}:${fn.line}`) - .join(", "); -} - -/** Assert the burner's hottest line wears the correctly-tiered palette color. */ -async function assertHottestLineTier(result: ProfileResult, burnerPath: string): Promise<void> { - // The heat map only paints VISIBLE editors, so open the burner rather than - // depending on the debugger having revealed the paused frame. That reveal is - // a side effect of the adapter, not something this test arranges, and it does - // not happen on every platform — which is exactly how this assertion failed - // on Windows while the profile data underneath it was perfectly correct. - await vscode.window.showTextDocument( - await vscode.workspace.openTextDocument(vscode.Uri.file(burnerPath)), - { preview: false }, - ); - applyProfileDecorations(result); - const applied = appliedProfileDecorations().filter((entry) => samePath(entry.file, burnerPath)); - // Name every side: this fires either when the ledger and the fixture disagree - // about the SAME file's path spelling (see `samePath`) or when no editor was - // open to paint at all, and a bare boolean makes those indistinguishable from - // "the profiler found nothing". - assert.ok( - applied.length > 0, - `real profile data must paint the open hot file ${burnerPath}; ` + - `ledger paths: ${JSON.stringify(appliedProfileDecorations().map((entry) => entry.file))}; ` + - `hot-line paths: ${JSON.stringify(result.hotLines.map((line) => line.file))}; ` + - `visible editors: ${JSON.stringify( - vscode.window.visibleTextEditors.map((editor) => editor.document.uri.fsPath), - )}`, - ); - // Tier-check the hottest line OF THE BURNER — under debugpy, tracer - // machinery can own the globally hottest line in a file that isn't open. - const topLine = [...result.hotLines] - .filter((line) => samePath(line.file, burnerPath)) - .sort((a, b) => b.percentage - a.percentage)[0]; - assert.ok(topLine !== undefined, `the burner must have hot lines, got: ${JSON.stringify(result.hotLines)}`); - const expectedColor = - topLine.percentage >= 20 ? "#e8500a" - : topLine.percentage >= 10 ? "#f97316" - : topLine.percentage >= 5 ? "#fbbf24" - : "#4a5468"; - assert.ok( - applied.some((entry) => entry.line === topLine.line && entry.color === expectedColor), - `hottest line ${topLine.line} (${topLine.percentage.toFixed(1)}%) must wear ${expectedColor}`, - ); -} - -/** Wait for the active debug session to terminate. */ -async function waitForDebugSessionEnd(): Promise<void> { - await pollUntilResult({ - fn: async () => vscode.debug.activeDebugSession, - predicate: (session) => session === undefined, - timeoutMs: 20_000, - intervalMs: 100, - }); -} - -/** Assert [PROFILE-NOTIFICATIONS-DIAG]: Hint diagnostics from basilisk-profiler. */ -async function assertProfilerDiagnosticsPublished(uri: vscode.Uri): Promise<void> { - const diagnostics = await pollUntilResult({ - fn: async () => vscode.languages.getDiagnostics(uri), - predicate: (diags) => diags.some((diag) => diag.source === "basilisk-profiler"), - timeoutMs: DIAGNOSTICS_WAIT_MS, - }); - const profDiag = diagnostics.find((diag) => diag.source === "basilisk-profiler"); - assert.ok(profDiag, "profiler diagnostics must be published for the hot file"); - assert.strictEqual(profDiag.severity, vscode.DiagnosticSeverity.Hint, "profiler diagnostics are Hints"); -} - -/** Assert the heat map painted on the burner with palette colors and % text. */ -function assertHeatMapPainted(burnerPath: string): void { - const applied = appliedProfileDecorations().filter((entry) => samePath(entry.file, burnerPath)); - const visible = vscode.window.visibleTextEditors.map((e) => e.document.uri.fsPath).join(", "); - assert.ok( - applied.length > 0, - `stopping must paint heat decorations on the open hot file ${burnerPath}; ` + - `ledger: ${JSON.stringify(appliedProfileDecorations())}; visible editors: [${visible}]; ` + - `status: ${String(profilerStatusText())}`, - ); - for (const decoration of applied) { - assert.ok( - HEAT_PALETTE.includes(decoration.color), - `heat colors must come from the brand palette, got ${decoration.color}`, - ); - } - assert.ok( - applied.some((entry) => /\d+(\.\d+)?%/.test(entry.contentText)), - `decorations must show CPU percentages, got: ${applied.map((entry) => entry.contentText).join(" | ")}`, - ); - assert.ok( - applied.some((entry) => entry.line >= HOT_FUNCTION_DEF_LINE), - "the hot_function body must carry heat decorations", - ); -} - -// eslint-disable-next-line max-lines-per-function -- e2e suite: six sequential journeys over one shared burner -suite("CPU profiling — real end-to-end", () => { - let tmpDir = ""; - let burner: ChildProcess | undefined; - let burnerPath = ""; - let burnerUri: vscode.Uri | undefined; - - suiteSetup(async function () { - this.timeout(60_000); - const result = await setupLspTestSuite("basilisk-cpu-e2e-"); - tmpDir = result.tmpDir; - burnerPath = path.join(tmpDir, "burner.py"); - const opened = await openPythonFile(tmpDir, "burner.py", BURNER_SOURCE); - burnerUri = opened.uri; - burner = await spawnBurner(burnerPath); - }); - - suiteTeardown(async function () { - this.timeout(30_000); - await stopAllProfilerSessions(); - burner?.kill("SIGKILL"); - clearProfileDecorations(); - await closeAllEditors(); - teardownLspTestSuite(tmpDir); - }); - - // Runs FIRST (the suite is fail-fast): it asserts the raw data, so a - // sampling failure surfaces with the profile contents instead of a bare - // "no decorations" from the UI-flow test below. - test("raw pipeline: hot function attributed, speedscope + .cpuprofile artifacts written and parseable", async function () { - if (process.platform !== "linux") { this.skip(); } - this.timeout(40_000); - const pid = burner?.pid; - assert.ok(pid !== undefined && pid > 0, "burner must be running"); - - const started = await vscode.commands.executeCommand<StartResult>("basilisk.profiler.start", { - pid, - sampleRate: 200, - }); - assert.ok(started.sessionId.length > 0, "start must mint a session"); - assert.ok(started.pythonVersion.startsWith("3."), `expected Python 3.x, got ${started.pythonVersion}`); - - await delay(SAMPLE_WINDOW_MS); - - const result = await vscode.commands.executeCommand<ProfileResult>("basilisk.profiler.stop", { - sessionId: started.sessionId, - format: "speedscope", - }); - - assert.ok(result.totalSamples > 0, "real sampling must collect samples"); - assert.ok( - result.hotFunctions.some((fn) => fn.name === "hot_function"), - `hot_function must be attributed, got: ${JSON.stringify(result.hotFunctions)}`, - ); - assert.ok( - result.hotLines.length > 0, - `hot lines must be detected; result: ${JSON.stringify(result)}`, - ); - assert.ok( - result.hotLines.some((line) => isSamePath(line.file, burnerPath)), - `hot lines must name the burner file ${burnerPath} (compared by path key, not spelling), got: ${ - JSON.stringify(result.hotLines.map((line) => line.file))}`, - ); - - assertSpeedscopeArtifact(result.outputFile); - assertCpuProfileArtifact(result.cpuProfilePath, "hot_function"); - assertFlamegraphArtifact(result); - await assertHottestLineTier(result, burnerPath); - }); - - test("panel one-click flow: attach → live progress in status bar → stop paints the heat map", async function () { - if (process.platform !== "linux") { this.skip(); } - this.timeout(40_000); - const store = getStore(); - assert.ok(store, "store must be initialized"); - const pid = burner?.pid; - assert.ok(pid !== undefined && pid > 0, "burner must be running"); - - const opsBefore = recordedOperations().length; - await startProfilingForPid(store, pid, "default"); - assert.ok(profilerStatusText() !== undefined, "status bar must show a profiling state after start"); - - // [PROFILE-PROCESSES-REACTIVE] The real session must drive the reactive - // store + panel: busy (so the launch buttons gate off) and the panel names - // the profiled PID. - assert.strictEqual(store.profiler.value.cpu, "active", "the store must mark the CPU profile active"); - assert.strictEqual(store.profilerBusy.value, true, "an active profile makes the panel busy"); - assert.ok( - pythonProcessesViewState().message?.includes(`PID ${pid}`) === true, - `the panel must show the profiled PID live: ${String(pythonProcessesViewState().message)}`, - ); - - // [PROFILE-UX-PROGRESS] The attach must run under a progress notification. - const startOps = recordedOperations().slice(opsBefore); - assert.ok( - startOps.includes("begin:Basilisk: Starting CPU profiler") && - startOps.includes("end:Basilisk: Starting CPU profiler"), - `panel attach must show progress, ops: ${startOps.join(" | ")}`, - ); - - // [PROFILE-NOTIFICATIONS-PROGRESS]: a NON-ZERO live sample count reaches - // the status bar — "0 samples" would mean sampling is silently broken. - await pollUntilResult({ - fn: async () => profilerStatusText() ?? "", - predicate: (text) => /[1-9][\d.]* ?K? samples/.test(text), - timeoutMs: PROGRESS_WAIT_MS, - }); - - await delay(SAMPLE_WINDOW_MS); - await vscode.commands.executeCommand("basilisk.profileStop"); - - // [PROFILE-PROCESSES-REACTIVE] Stop must clear the reactive state so the - // panel re-offers the launches and drops its live chrome. - assert.strictEqual(store.profiler.value.cpu, "idle", "stop must clear the store session"); - assert.strictEqual(store.profilerBusy.value, false, "stop must clear busy"); - assert.strictEqual(pythonProcessesViewState().message, undefined, "stop must clear the panel chrome"); - - assertHeatMapPainted(burnerPath); - - const uri = burnerUri; - assert.ok(uri, "burner uri must exist"); - await assertProfilerDiagnosticsPublished(uri); - }); - - // Runs on EVERY platform — the cooperative sampler needs no task ports, so - // this is the real CPU e2e that macOS can execute too ([PROFILE-COOPERATIVE]). - test("cooperative sampler: OOTB CPU profile of a debug-launched session", async function () { - this.timeout(60_000); - try { - const started = await vscode.debug.startDebugging(undefined, { - name: "Cooperative CPU E2E", - type: "basilisk-debug", - request: "launch", - program: burnerPath, - stopOnEntry: true, - justMyCode: true, - console: "internalConsole", - }); - assert.ok(started, "the debug session must launch"); - - const frameId = await waitForStoppedFrame(); - assert.ok(frameId !== null, "the debuggee must pause at entry for injection"); - - const leg1 = await vscode.commands.executeCommand<{ script: string; sampleFile: string }>( - "basilisk.profiler.cooperativeScript", - { sampleRate: 100 }, - ); - assert.ok(leg1.script.includes("sys._current_frames"), "the script must be the in-process sampler"); - - const ack = await evaluateInDebugSession(leg1.script, frameId); - await vscode.commands.executeCommand("workbench.action.debug.continue"); - assert.ok(ack?.includes("__BASILISK_CPU_ACK__") === true, `injection must ack, got: ${String(ack)}`); - - const session = await vscode.commands.executeCommand<StartResult>( - "basilisk.profiler.cooperativeAttach", - { sampleFile: leg1.sampleFile, sampleRate: 100 }, - ); - assert.ok(session.sessionId.length > 0, "cooperative attach must mint a session"); - assert.ok(session.pythonVersion.startsWith("3."), `expected Python 3.x, got ${session.pythonVersion}`); - - // The debuggee runs under debugpy line-tracing, so attribution may land on - // `main`/`<module>` instead of the nested hot function on CI. Poll until - // the opened burner.py is attributed rather than accepting debugpy-only - // frames or assuming a fixed window. - await pollUntilResult({ - fn: () => - vscode.commands.executeCommand<ProfileResult>("basilisk.profiler.snapshot", { - sessionId: session.sessionId, - format: "speedscope", - }), - predicate: (snap) => hasBurnerHotFunction(snap, burnerPath), - timeoutMs: HOT_ATTRIBUTION_TIMEOUT_MS, - intervalMs: SAMPLE_WINDOW_MS, - }); - - const result = await vscode.commands.executeCommand<ProfileResult>("basilisk.profiler.stop", { - sessionId: session.sessionId, - format: "speedscope", - }); - assert.ok(result.totalSamples > 0, "the in-process sampler must collect real ticks"); - assert.ok( - hasBurnerHotFunction(result, burnerPath), - `burner.py must be attributed, got: ${hotFunctionSummary(result)}`, - ); - assertCpuProfileArtifact(result.cpuProfilePath); - assertFlamegraphArtifact(result); - await assertHottestLineTier(result, burnerPath); - // The debug launch wraps the program in the runpy/debugpy spine; every - // real artifact of this run must root at the user's code with zero - // scaffolding ([PROFILE-AGGREGATION-SCAFFOLD]). - assertArtifactsRootAtUserCode(result, burnerPath); - } finally { - await vscode.debug.stopDebugging(); - await waitForDebugSessionEnd(); - } - }); - - test("one-click 'Run & Profile CPU' is OOTB on macOS via the cooperative sampler (#82)", async function () { - if (process.platform !== "darwin") { this.skip(); } - this.timeout(60_000); - const opsBefore = recordedOperations().length; - try { - const started = await vscode.debug.startDebugging( - undefined, - buildProfileLaunchConfig("cpu", burnerPath), - ); - assert.ok(started, "the metric-explicit CPU launch must start"); - - // [PROFILE-UX-PROGRESS] No silence between the click and live data: the - // status bar must show SOMETHING (starting spinner or sample counter) - // almost immediately after the launch. - await pollUntilResult({ - fn: async () => profilerStatusText(), - predicate: (text) => text !== undefined, - timeoutMs: 10_000, - }); - - // The cooperative flow injects at entry, resumes, and adopts the - // session — live NON-ZERO sample counts must reach the status bar - // without any elevation prompt. - await pollUntilResult({ - fn: async () => profilerStatusText() ?? "", - predicate: (text) => /[1-9][\d.]* ?K? samples/.test(text), - timeoutMs: 20_000, - }); - - // Let the sampler accumulate a real window before stopping, exactly like - // the sibling heat-map journeys: stopping at the first observed sample - // can leave zero samples attributed to the burner's lines (all in - // bootstrap/injection frames), so no heat decorations would paint. - await delay(SAMPLE_WINDOW_MS); - - // [PROFILE-PROCESSES-REACTIVE] The OOTB one-click flow must drive the - // reactive panel on macOS too: busy + a live "Profiling PID …" readout. - const store = getStore(); - assert.ok(store, "store must be initialized"); - assert.strictEqual(store.profiler.value.cpu, "active", "the cooperative session must mark the store active"); - assert.ok( - pythonProcessesViewState().message?.includes("Profiling PID") === true, - `the panel must show the live profile: ${String(pythonProcessesViewState().message)}`, - ); - - await vscode.commands.executeCommand("basilisk.profileStop"); - assert.strictEqual(store.profiler.value.cpu, "idle", "stop must clear the reactive store state"); - assert.strictEqual(pythonProcessesViewState().message, undefined, "stop must clear the panel chrome"); - assertHeatMapPainted(burnerPath); - - // [PROFILE-UX-PROGRESS] Both the start and the stop must have run under - // progress notifications that opened and closed. - const ops = recordedOperations().slice(opsBefore); - for (const title of ["Basilisk: Starting CPU profiler", "Basilisk: Stopping profiler"]) { - const begin = ops.indexOf(`begin:${title}`); - const end = ops.indexOf(`end:${title}`); - assert.ok(begin !== -1, `"${title}" must show progress, ops: ${ops.join(" | ")}`); - assert.ok(end > begin, `"${title}" progress must close on completion`); - } - assert.strictEqual( - profilerStatusText(), - undefined, - "the status bar must clear after stop — no zombie spinner", - ); - } finally { - await vscode.debug.stopDebugging(); - await waitForDebugSessionEnd(); - } - }); - - // The viewability flow (#145): a completed CPU profile must land the user on - // a working flame chart WITHOUT any extra click — the self-contained results - // panel opens as the primary view on stop (the built-in `.cpuprofile` viewer - // is a raw self/total-time table and can refuse to render, so it is on-demand - // only). The completion notification must still offer trace actions, and a - // closed panel must stay reachable via "Basilisk: Show Profile Results" — a - // dismissed toast never strands the results. Covers [PROFILE-NATIVE-FALLBACK] - // (docs/specs/LSP-PROFILING-SPEC.md#PROFILE-NATIVE-FALLBACK) + acceptance - // criteria 2/3/4 of the issue. - test("run → profile → view: the results panel opens on stop with no click, and stays reachable after closing (#145)", async function () { - if (process.platform === "win32") { this.skip(); } - this.timeout(60_000); - const store = getStore(); - assert.ok(store, "store must be initialized"); - const burnerPid = burner?.pid; - assert.ok(burnerPid !== undefined && burnerPid > 0, "burner must be running"); - - // Adopt a REAL active CPU session through each platform's proven path: - // the cooperative auto-launch on macOS, the panel py-spy attach on Linux. - if (process.platform === "darwin") { - const launched = await vscode.debug.startDebugging( - undefined, - buildProfileLaunchConfig("cpu", burnerPath), - ); - assert.ok(launched, "the metric-explicit CPU launch must start"); - await pollUntilResult({ - fn: async () => store.profiler.value.cpu, - predicate: (state) => state === "active", - timeoutMs: 30_000, - }); - } else { - await startProfilingForPid(store, burnerPid, "default"); - assert.strictEqual(store.profiler.value.cpu, "active", "the panel attach must activate the session"); - } - - // Capture the completion notification's actions but take NONE of them — - // the panel must open without any user click. - const toasts: { message: string; actions: string[] }[] = []; - const win = vscode.window as { showInformationMessage: typeof vscode.window.showInformationMessage }; - const originalShow = win.showInformationMessage; - win.showInformationMessage = async (message: string, ...items: unknown[]) => { - const actions = items.filter((item): item is string => typeof item === "string"); - toasts.push({ message, actions }); - return undefined; - }; - - disposeFlamegraphPanel(); // known-closed baseline so the post-stop check is meaningful - try { - await delay(SAMPLE_WINDOW_MS); - await vscode.commands.executeCommand("basilisk.profileStop"); - } finally { - win.showInformationMessage = originalShow; - } - - // Primary landing: the results panel is open right after stop, with no - // toast interaction — the user is never dumped on the raw `.cpuprofile` - // table as the only view. - await pollUntilResult({ - fn: async () => flamegraphPanelOpen(), - predicate: (open) => open, - timeoutMs: 5_000, - }).catch(() => { - assert.fail( - "stopping a profile must open the self-contained results panel without requiring " + - "any toast click — the raw .cpuprofile table must never be the primary landing (#145)", - ); - }); - - const completion = toasts.find((toast) => /Profile complete/i.test(toast.message)); - assert.ok( - completion !== undefined, - `a "Profile complete" notification must be shown, got: ${JSON.stringify(toasts)}`, - ); - assert.ok( - completion.actions.length > 0, - `the "Profile complete" notification must offer an action to reach the raw trace (#145) — ` + - `the toast currently dead-ends with no way to open or reveal the trace`, - ); - - // Re-entry: a closed panel is one palette command away — results are never - // trapped behind the dismissed completion toast. - disposeFlamegraphPanel(); - assert.strictEqual(flamegraphPanelOpen(), false, "baseline: panel closed before re-entry"); - await vscode.commands.executeCommand("basilisk.profileShowResults"); - await pollUntilResult({ - fn: async () => flamegraphPanelOpen(), - predicate: (open) => open, - timeoutMs: 5_000, - }).catch(() => { - assert.fail( - '"Basilisk: Show Profile Results" must re-open the results panel for the last profile', - ); - }); - - if (vscode.debug.activeDebugSession !== undefined) { - await vscode.debug.stopDebugging(); - await waitForDebugSessionEnd(); - } - disposeFlamegraphPanel(); - }); - - // The LSP runtime can be re-created within one extension session (store - // reset → a brand-new LanguageClient). The regression: the profiler's - // progress listener was registered once, on the first client only, so after - // a runtime re-creation the live sample counter silently died — the status - // bar sat on "Profiling..." with no data forever. The listener must follow - // the store's client signal ([PROFILE-NOTIFICATIONS-PROGRESS], - // [PROFILE-PROCESSES-REACTIVE]). - test("live progress survives an LSP client re-creation — the sample counter never goes silently dead", async function () { - if (process.platform === "win32") { this.skip(); } - this.timeout(120_000); - const store = getStore(); - assert.ok(store, "store must be initialized"); - const oldClient = store.client.value; - assert.ok(oldClient, "a running client must exist before the re-creation"); - - // Recreate the runtime: reset() → onReset → startRuntime → NEW LanguageClient. - store.reset(); - await pollUntilResult({ - fn: async () => store.client.value, - predicate: (client) => client !== undefined && client !== oldClient, - timeoutMs: 30_000, - }); - await waitForLspReady(); - - try { - // Profile on the fresh runtime through each platform's PROVEN-reliable - // path — the same ones the sibling journeys use — so this test isolates - // the thing under test (does the progress listener survive re-creation?) - // instead of also depending on the debug-launch auto-profile chain, which - // headless Linux CI does not deliver status-bar samples through: the - // cooperative sampler on macOS (see the #82 journey), the panel py-spy - // attach on Linux (see the "panel one-click flow" journey). - if (process.platform === "darwin") { - const launched = await vscode.debug.startDebugging( - undefined, - buildProfileLaunchConfig("cpu", burnerPath), - ); - assert.ok(launched, "the CPU launch must start on the re-created runtime"); - } else { - const pid = burner?.pid; - assert.ok(pid !== undefined && pid > 0, "burner must be running"); - await startProfilingForPid(store, pid, "default"); - } - - // The live NON-ZERO sample counter must reach the status bar on the - // re-created client — proving the progress listener rebound to the NEW - // LanguageClient. A dead listener leaves this empty until the timeout. - await pollUntilResult({ - fn: async () => profilerStatusText() ?? "", - predicate: (text) => /[1-9][\d.]* ?K? samples/.test(text), - timeoutMs: 30_000, - }); - - await vscode.commands.executeCommand("basilisk.profileStop"); - assert.strictEqual(store.profiler.value.cpu, "idle", "stop must clear the session"); - } finally { - // Best-effort teardown for whichever path ran. - if (store.profiler.value.cpu !== "idle") { - await vscode.commands.executeCommand("basilisk.profileStop").then(undefined, () => undefined); - } - if (vscode.debug.activeDebugSession !== undefined) { - await vscode.debug.stopDebugging(); - await waitForDebugSessionEnd(); - } - } - }); - - test("attaching to an exited process fails with a distinct, classified cause (#81)", async function () { - this.timeout(40_000); - // A guaranteed-dead PID exercises the classified failure path on every - // platform WITHOUT touching the macOS osascript elevation prompt — a GUI - // dialog no automated suite may trigger (the live-target helper paths are - // covered by the Rust profiler_helper_socket e2e suite). - const deadPid = Number( - execFileSync(PYTHON, ["-c", "import os; print(os.getpid())"], { encoding: "utf8" }).trim(), - ); - assert.ok(deadPid > 0, "must obtain a freshly exited PID"); - - let message = ""; - try { - await vscode.commands.executeCommand("basilisk.profiler.start", { pid: deadPid }); - assert.fail("attach to an exited process must fail"); - } catch (err: unknown) { - message = err instanceof Error ? err.message : String(err); - } - assert.ok( - /not found|No such process|py-spy attach failed/i.test(message), - `the failure must name the real cause distinctly (#81), got: ${message}`, - ); - assert.ok( - !message.trim().endsWith("helper closed the connection before confirming attach"), - `the bare EOF message must never be the whole story (#81): ${message}`, - ); - }); - - // [PROFILE-SHORT-PROGRAM] #145: a sub-tick program (debug_demo.py runs ~1ms) - // finishes before its work can be sampled, so the session can capture dozens - // of samples that resolve to ZERO user-code attribution (the real observed - // case: 48 samples, 0 hot functions, 0 hot lines). The launch flow flags - // "no usable data" honestly instead of presenting an empty chart — keying off - // attribution, not raw sample count. - test("a profile with no hot functions/lines is flagged unusable; one with hotspots is not (#145)", () => { - assert.ok( - profileHasNoUsableData({ hotFunctions: [], hotLines: [] }), - "the observed 48-sample/0-function idle case has nothing to show", - ); - const withFunction: Pick<ProfileResult, "hotFunctions" | "hotLines"> = { - hotFunctions: [{ name: "f", file: "/a.py", line: 1, samples: 3, percentage: 100, selfPercentage: 100 }], - hotLines: [], - }; - assert.ok(!profileHasNoUsableData(withFunction), "a real hot function is usable data, even if sparse"); - const withLine: Pick<ProfileResult, "hotFunctions" | "hotLines"> = { - hotFunctions: [], - hotLines: [{ file: "/a.py", line: 2, samples: 3, percentage: 100 }], - }; - assert.ok(!profileHasNoUsableData(withLine), "a real hot line is usable data"); - }); - - test("flamegraph webview HTML renders the dashboard from a profile result", () => { - const result: ProfileResult = { - sessionId: "s-test", - duration: 3, - totalSamples: 600, - outputFile: "/tmp/profile.speedscope.json", - hotFunctions: [ - { name: "hot_function", file: burnerPath, line: HOT_FUNCTION_DEF_LINE, samples: 540, percentage: 90, selfPercentage: 85 }, - ], - hotLines: [{ file: burnerPath, line: HOT_FUNCTION_DEF_LINE + 2, samples: 500, percentage: 83 }], - }; - const html = buildFlamegraphHtml(result); - assert.ok(html.includes("hot_function"), "the profile data (hot functions) must be embedded"); - assert.ok(html.toLowerCase().includes("#e8500a"), "the Basilisk orange palette must be used"); - assert.ok(html.includes("navigateToSource"), "rows must navigate to source"); - assert.ok(html.includes("fn-body"), "the hot-functions table must render"); - assert.ok(html.includes(String(result.totalSamples)), "the summary must show the real sample count"); - }); - - // [PROFILE-VIEWER-DELIVERY]: speedscope.app is https and cannot read file:// - // URLs, so a `#profileURL=file://` link always fails ("Something went wrong"). - // The "Open in Speedscope" link must instead post a message the extension - // actually handles (reveal the JSON + open the app for drag-and-drop import). - test("the flamegraph's Speedscope link uses a working import path, not a dead file:// URL", () => { - const result: ProfileResult = { - sessionId: "s-test", - duration: 3, - totalSamples: 600, - outputFile: "/tmp/profile.speedscope.json", - hotFunctions: [], - hotLines: [], - }; - const html = buildFlamegraphHtml(result); - assert.ok( - !html.includes("speedscope.app/#profileURL=file://"), - "must not emit the always-failing speedscope file:// URL ([PROFILE-VIEWER-DELIVERY])", - ); - assert.ok( - html.includes("openSpeedscope"), - "the Speedscope link must post an openSpeedscope message the extension handles", - ); - }); - - // [PROFILE-NATIVE-FALLBACK]: frame names/paths come from the profiled (possibly - // third-party) program. CPython emits synthetic names like <module>/<lambda>, - // and a hostile program can name a function `</script>…`. The webview must - // escape them before innerHTML, must not let the embedded JSON close the - // inline <script>, and gates that script behind a per-render CSP nonce. - test("flamegraph HTML escapes untrusted frame names/paths and nonce-gates its inline script", () => { - const hostile = "</script><img src=x onerror=alert(1)>"; - const result: ProfileResult = { - sessionId: "s-test", - duration: 3, - totalSamples: 600, - outputFile: "/tmp/profile.speedscope.json", - hotFunctions: [ - { name: hostile, file: hostile, line: 1, samples: 540, percentage: 90, selfPercentage: 85 }, - { name: "<module>", file: "/app/main.py", line: 1, samples: 60, percentage: 10, selfPercentage: 10 }, - ], - hotLines: [{ file: hostile, line: 2, samples: 500, percentage: 83 }], - }; - const html = buildFlamegraphHtml(result); - assert.ok( - !html.includes("</script><img"), - "embedded profile data must not close the inline <script> element early", - ); - assert.ok( - html.includes("escapeHtml(fn.name)"), - "frame names must be escaped before innerHTML", - ); - assert.ok( - html.includes("escapeHtml(basename(fn.file))"), - "frame file paths must be escaped before innerHTML", - ); - assert.ok(html.includes("Content-Security-Policy"), "the webview must declare a CSP"); - assert.ok(html.includes('<script nonce="'), "the inline script must carry the CSP nonce"); - }); -}); diff --git a/vscode-extension/src/test/suite/profiler-decorations.test.ts b/vscode-extension/src/test/suite/profiler-decorations.test.ts deleted file mode 100644 index 799a3c269..000000000 --- a/vscode-extension/src/test/suite/profiler-decorations.test.ts +++ /dev/null @@ -1,535 +0,0 @@ -// Implements [LSPPROF]. See docs/specs/LSP-PROFILING-SPEC.md#LSPPROF -/** - * Profiler E2E Tests — Decoration Modules, Heat Level Classification, Decoration Contracts. - * - * Validates: - * - Profiler decorations module exports correctly - * - Memory decorations module exports correctly - * - ProfileResult type has required fields - * - Heat level classification works correctly - * - Decoration apply/clear lifecycle - * - * These tests require the Basilisk LSP server binary to be built. - * They exercise the real LSP protocol, not mocks. - */ - -import * as assert from 'assert'; -import { - setupLspTestSuite, - teardownLspTestSuite, - closeAllEditors, - openPythonFile, -} from "./test-helpers"; - -// Import profiler decoration types for structural assertions. -import type { - ProfileResult, - ProfileHotLine, - ProfileHotFunction, -} from '../../profiler-decorations'; -import { - applyProfileDecorations, - clearProfileDecorations, -} from '../../profiler-decorations'; - -// Import memory decoration types for structural assertions. -import type { - MemoryAllocation, - MemorySnapshotResult, -} from '../../memory-decorations'; -import { - applyMemoryDecorations, - clearMemoryDecorations, -} from '../../memory-decorations'; - -let tmpDir = ''; - -suite('Profiler — Decoration Modules', () => { - suiteSetup(async function () { - const result = await setupLspTestSuite('basilisk-profiler-dec-'); - tmpDir = result.tmpDir; - }); - - suiteTeardown(() => { - teardownLspTestSuite(tmpDir); - }); - - teardown(async () => { - await closeAllEditors(); - }); - - test('profiler-decorations exports applyProfileDecorations function', () => { - assert.strictEqual(typeof applyProfileDecorations, 'function', - 'applyProfileDecorations should be a function'); - }); - - test('profiler-decorations exports clearProfileDecorations function', () => { - assert.strictEqual(typeof clearProfileDecorations, 'function', - 'clearProfileDecorations should be a function'); - }); - - test('memory-decorations exports applyMemoryDecorations function', () => { - assert.strictEqual(typeof applyMemoryDecorations, 'function', - 'applyMemoryDecorations should be a function'); - }); - - test('memory-decorations exports clearMemoryDecorations function', () => { - assert.strictEqual(typeof clearMemoryDecorations, 'function', - 'clearMemoryDecorations should be a function'); - }); - - test('clearProfileDecorations does not throw when no decorations exist', () => { - assert.doesNotThrow(() => { - clearProfileDecorations(); - }, 'clearProfileDecorations should be safe to call with no active decorations'); - }); - - test('clearMemoryDecorations does not throw when no decorations exist', () => { - assert.doesNotThrow(() => { - clearMemoryDecorations(); - }, 'clearMemoryDecorations should be safe to call with no active decorations'); - }); - - test('ProfileResult type has required fields', () => { - const result: ProfileResult = { - sessionId: 'test-session-001', - duration: 5.2, - totalSamples: 1000, - outputFile: '/tmp/test.speedscope.json', - hotFunctions: [], - hotLines: [], - }; - - assert.strictEqual(result.sessionId, 'test-session-001'); - assert.strictEqual(result.duration, 5.2); - assert.strictEqual(result.totalSamples, 1000); - assert.strictEqual(result.outputFile, '/tmp/test.speedscope.json'); - assert.ok(Array.isArray(result.hotFunctions), 'hotFunctions should be an array'); - assert.ok(Array.isArray(result.hotLines), 'hotLines should be an array'); - }); - - test('ProfileHotLine type has required fields', () => { - const hotLine: ProfileHotLine = { - file: '/src/app.py', - line: 42, - samples: 500, - percentage: 25.0, - }; - - assert.strictEqual(hotLine.file, '/src/app.py'); - assert.strictEqual(hotLine.line, 42); - assert.strictEqual(hotLine.samples, 500); - assert.strictEqual(hotLine.percentage, 25.0); - }); - - test('ProfileHotFunction type has required fields', () => { - const hotFunc: ProfileHotFunction = { - name: 'process_data', - file: '/src/pipeline.py', - line: 15, - samples: 800, - percentage: 40.0, - selfPercentage: 30.0, - }; - - assert.strictEqual(hotFunc.name, 'process_data'); - assert.strictEqual(hotFunc.file, '/src/pipeline.py'); - assert.strictEqual(hotFunc.line, 15); - assert.strictEqual(hotFunc.samples, 800); - assert.strictEqual(hotFunc.percentage, 40.0); - assert.strictEqual(hotFunc.selfPercentage, 30.0); - }); - - test('MemoryAllocation type has required fields', () => { - const alloc: MemoryAllocation = { - file: '/src/data.py', - line: 100, - size: 10485760, - count: 5000, - }; - - assert.strictEqual(alloc.file, '/src/data.py'); - assert.strictEqual(alloc.line, 100); - assert.strictEqual(alloc.size, 10485760); - assert.strictEqual(alloc.count, 5000); - }); - - test('MemorySnapshotResult type has required fields', () => { - const snapshot: MemorySnapshotResult = { - memorySessionId: 'mem-session-001', - snapshotId: 'snap-001', - currentMemory: 50000000, - peakMemory: 75000000, - topAllocations: [], - }; - - assert.strictEqual(snapshot.memorySessionId, 'mem-session-001'); - assert.strictEqual(snapshot.snapshotId, 'snap-001'); - assert.strictEqual(snapshot.currentMemory, 50000000); - assert.strictEqual(snapshot.peakMemory, 75000000); - assert.ok(Array.isArray(snapshot.topAllocations), 'topAllocations should be an array'); - }); - -}); - -suite('Profiler — Decoration Apply/Clear', () => { - suiteSetup(async function () { - const result = await setupLspTestSuite('basilisk-profiler-dec2-'); - tmpDir = result.tmpDir; - }); - - suiteTeardown(() => { - teardownLspTestSuite(tmpDir); - }); - - teardown(async () => { - clearProfileDecorations(); - clearMemoryDecorations(); - await closeAllEditors(); - }); - - test('applyProfileDecorations handles empty result without throwing', () => { - const emptyResult: ProfileResult = { - sessionId: 'empty-session', - duration: 0, - totalSamples: 0, - outputFile: '', - hotFunctions: [], - hotLines: [], - }; - - assert.doesNotThrow(() => { - applyProfileDecorations(emptyResult); - }, 'applyProfileDecorations should handle empty results gracefully'); - - clearProfileDecorations(); - }); - - test('applyMemoryDecorations handles empty result without throwing', () => { - const emptySnapshot: MemorySnapshotResult = { - memorySessionId: 'empty-mem', - snapshotId: 'snap-empty', - currentMemory: 0, - peakMemory: 0, - topAllocations: [], - }; - - assert.doesNotThrow(() => { - applyMemoryDecorations(emptySnapshot); - }, 'applyMemoryDecorations should handle empty snapshots gracefully'); - - clearMemoryDecorations(); - }); - - test('ProfileResult with populated hotFunctions validates structure', () => { - const result: ProfileResult = { - sessionId: 'populated-session', - duration: 10.5, - totalSamples: 5000, - outputFile: '/tmp/profile.speedscope.json', - hotFunctions: [ - { - name: 'compute', - file: '/src/math.py', - line: 10, - samples: 2500, - percentage: 50.0, - selfPercentage: 35.0, - }, - { - name: 'transform', - file: '/src/utils.py', - line: 88, - samples: 1000, - percentage: 20.0, - selfPercentage: 15.0, - }, - ], - hotLines: [ - { - file: '/src/math.py', - line: 12, - samples: 2000, - percentage: 40.0, - }, - ], - }; - - assert.strictEqual(result.hotFunctions.length, 2, 'Should have 2 hot functions'); - assert.strictEqual(result.hotLines.length, 1, 'Should have 1 hot line'); - assert.strictEqual(result.hotFunctions[0].name, 'compute'); - assert.strictEqual(result.hotFunctions[1].name, 'transform'); - assert.ok(result.hotFunctions[0].percentage > result.hotFunctions[1].percentage, - 'First function should have higher percentage'); - assert.ok(result.hotFunctions[0].selfPercentage <= result.hotFunctions[0].percentage, - 'selfPercentage should not exceed percentage'); - }); -}); - -suite('Profiler — Heat Level Classification', () => { - suiteSetup(async function () { - const result = await setupLspTestSuite('basilisk-profiler-heat-'); - tmpDir = result.tmpDir; - }); - - suiteTeardown(() => { - teardownLspTestSuite(tmpDir); - }); - - test('critical heat level classification (>= 20%)', () => { - const criticalLine: ProfileHotLine = { - file: '/src/hot.py', line: 1, samples: 400, percentage: 25.0, - }; - assert.ok(criticalLine.percentage >= 20, - 'Lines at 25% should fall in the critical range'); - - const borderlineCritical: ProfileHotLine = { - file: '/src/hot.py', line: 2, samples: 200, percentage: 20.0, - }; - assert.ok(borderlineCritical.percentage >= 20, - 'Lines at exactly 20% should fall in the critical range'); - }); - - test('hot heat level classification (10-20%)', () => { - const hotLine: ProfileHotLine = { - file: '/src/warm.py', line: 5, samples: 150, percentage: 15.0, - }; - assert.ok(hotLine.percentage >= 10 && hotLine.percentage < 20, - 'Lines at 15% should fall in the hot range'); - - const borderlineHot: ProfileHotLine = { - file: '/src/warm.py', line: 6, samples: 100, percentage: 10.0, - }; - assert.ok(borderlineHot.percentage >= 10 && borderlineHot.percentage < 20, - 'Lines at exactly 10% should fall in the hot range'); - }); - - test('warm heat level classification (5-10%)', () => { - const warmLine: ProfileHotLine = { - file: '/src/warm.py', line: 10, samples: 70, percentage: 7.0, - }; - assert.ok(warmLine.percentage >= 5 && warmLine.percentage < 10, - 'Lines at 7% should fall in the warm range'); - - const borderlineWarm: ProfileHotLine = { - file: '/src/warm.py', line: 11, samples: 50, percentage: 5.0, - }; - assert.ok(borderlineWarm.percentage >= 5 && borderlineWarm.percentage < 10, - 'Lines at exactly 5% should fall in the warm range'); - }); - - test('cool heat level classification (1-5%)', () => { - const coolLine: ProfileHotLine = { - file: '/src/cool.py', line: 20, samples: 30, percentage: 3.0, - }; - assert.ok(coolLine.percentage >= 1 && coolLine.percentage < 5, - 'Lines at 3% should fall in the cool range'); - - const borderlineCool: ProfileHotLine = { - file: '/src/cool.py', line: 21, samples: 10, percentage: 1.0, - }; - assert.ok(borderlineCool.percentage >= 1 && borderlineCool.percentage < 5, - 'Lines at exactly 1% should fall in the cool range'); - }); - - test('below threshold (< 1%) is not classified', () => { - const belowThreshold: ProfileHotLine = { - file: '/src/idle.py', line: 99, samples: 2, percentage: 0.5, - }; - assert.ok(belowThreshold.percentage < 1, - 'Lines below 1% should not be classified as any heat level'); - }); - - test('heat level boundaries are mutually exclusive', () => { - const testCases = [ - { pct: 25.0, expected: 'critical' }, - { pct: 20.0, expected: 'critical' }, - { pct: 19.9, expected: 'hot' }, - { pct: 10.0, expected: 'hot' }, - { pct: 9.9, expected: 'warm' }, - { pct: 5.0, expected: 'warm' }, - { pct: 4.9, expected: 'cool' }, - { pct: 1.0, expected: 'cool' }, - { pct: 0.9, expected: 'none' }, - ]; - - for (const tc of testCases) { - let level: string; - if (tc.pct >= 20) { level = 'critical'; } - else if (tc.pct >= 10) { level = 'hot'; } - else if (tc.pct >= 5) { level = 'warm'; } - else if (tc.pct >= 1) { level = 'cool'; } - else { level = 'none'; } - - assert.strictEqual(level, tc.expected, - `${tc.pct}% should be classified as "${tc.expected}", got "${level}"`); - } - }); -}); - -suite('Profiler — Decoration Contracts', () => { - suiteSetup(async function () { - const result = await setupLspTestSuite('basilisk-profiler-deco-'); - tmpDir = result.tmpDir; - }); - - suiteTeardown(() => { - teardownLspTestSuite(tmpDir); - }); - - teardown(async () => { - clearProfileDecorations(); - clearMemoryDecorations(); - await closeAllEditors(); - }); - - test('applyProfileDecorations with multiple files and varying percentages', async () => { - await openPythonFile(tmpDir, 'hot_module.py', - 'def hot_func():\n x = 1\n y = 2\n z = x + y\n return z\n'); - - const result: ProfileResult = { - sessionId: 'multi-file-session', - duration: 8.3, - totalSamples: 3000, - outputFile: '/tmp/multi.speedscope.json', - hotFunctions: [ - { name: 'hot_func', file: '/nonexistent/a.py', line: 1, samples: 1500, percentage: 50.0, selfPercentage: 40.0 }, - { name: 'warm_func', file: '/nonexistent/b.py', line: 10, samples: 300, percentage: 10.0, selfPercentage: 8.0 }, - { name: 'cool_func', file: '/nonexistent/c.py', line: 20, samples: 60, percentage: 2.0, selfPercentage: 1.5 }, - ], - hotLines: [ - { file: '/nonexistent/a.py', line: 3, samples: 1200, percentage: 40.0 }, - { file: '/nonexistent/b.py', line: 12, samples: 200, percentage: 6.7 }, - { file: '/nonexistent/c.py', line: 22, samples: 30, percentage: 1.0 }, - ], - }; - - assert.doesNotThrow(() => { - applyProfileDecorations(result); - }, 'applyProfileDecorations should handle multi-file results'); - - assert.strictEqual(result.hotFunctions.length, 3, 'Should have 3 hot functions'); - assert.strictEqual(result.hotLines.length, 3, 'Should have 3 hot lines'); - assert.ok(result.hotFunctions[0].percentage > result.hotFunctions[1].percentage, - 'Functions should be ordered by percentage'); - }); - - test('heat level classification boundary at exactly 1%', () => { - const atBoundary: ProfileHotLine = { - file: '/src/boundary.py', line: 1, samples: 10, percentage: 1.0, - }; - const belowBoundary: ProfileHotLine = { - file: '/src/boundary.py', line: 2, samples: 9, percentage: 0.99, - }; - - assert.ok(atBoundary.percentage >= 1, '1.0% should be classified (cool)'); - assert.ok(belowBoundary.percentage < 1, '0.99% should not be classified'); - assert.ok(atBoundary.percentage < 5, '1.0% should not be warm'); - }); - - test('heat level classification boundary at exactly 5%', () => { - const atBoundary: ProfileHotLine = { - file: '/src/boundary.py', line: 1, samples: 50, percentage: 5.0, - }; - const belowBoundary: ProfileHotLine = { - file: '/src/boundary.py', line: 2, samples: 49, percentage: 4.99, - }; - - assert.ok(atBoundary.percentage >= 5, '5.0% should be classified as warm'); - assert.ok(belowBoundary.percentage < 5, '4.99% should still be cool'); - assert.ok(atBoundary.percentage < 10, '5.0% should not be hot'); - }); - - test('heat level classification boundary at exactly 10%', () => { - const atBoundary: ProfileHotLine = { - file: '/src/boundary.py', line: 1, samples: 100, percentage: 10.0, - }; - const belowBoundary: ProfileHotLine = { - file: '/src/boundary.py', line: 2, samples: 99, percentage: 9.99, - }; - - assert.ok(atBoundary.percentage >= 10, '10.0% should be classified as hot'); - assert.ok(belowBoundary.percentage < 10, '9.99% should still be warm'); - assert.ok(atBoundary.percentage < 20, '10.0% should not be critical'); - }); - - test('heat level classification boundary at exactly 20%', () => { - const atBoundary: ProfileHotLine = { - file: '/src/boundary.py', line: 1, samples: 200, percentage: 20.0, - }; - const belowBoundary: ProfileHotLine = { - file: '/src/boundary.py', line: 2, samples: 199, percentage: 19.99, - }; - - assert.ok(atBoundary.percentage >= 20, '20.0% should be classified as critical'); - assert.ok(belowBoundary.percentage < 20, '19.99% should still be hot'); - assert.ok(belowBoundary.percentage >= 10, '19.99% must be at least hot-level'); - }); - -}); - -suite('Profiler — Decoration Lifecycle', () => { - suiteSetup(async function () { - const result = await setupLspTestSuite('basilisk-profiler-deco2-'); - tmpDir = result.tmpDir; - }); - - suiteTeardown(() => { - teardownLspTestSuite(tmpDir); - }); - - teardown(async () => { - clearProfileDecorations(); - clearMemoryDecorations(); - await closeAllEditors(); - }); - - test('clearProfileDecorations removes all decorations without error', () => { - const result: ProfileResult = { - sessionId: 'clear-test', - duration: 1.0, - totalSamples: 100, - outputFile: '', - hotFunctions: [], - hotLines: [ - { file: '/tmp/test.py', line: 1, samples: 50, percentage: 50.0 }, - ], - }; - - assert.doesNotThrow(() => { - applyProfileDecorations(result); - }, 'Applying decorations should not throw'); - - assert.doesNotThrow(() => { - clearProfileDecorations(); - }, 'Clearing decorations should not throw'); - - assert.doesNotThrow(() => { - clearProfileDecorations(); - }, 'Double-clearing decorations should not throw'); - }); - - test('decorations apply after opening file and survive re-application', async () => { - const { uri } = await openPythonFile(tmpDir, 'deco_survive.py', - 'x = 1\ny = 2\nz = x + y\n'); - - const result: ProfileResult = { - sessionId: 'survive-test', - duration: 2.0, - totalSamples: 500, - outputFile: '', - hotFunctions: [], - hotLines: [ - { file: uri.fsPath, line: 2, samples: 250, percentage: 50.0 }, - ], - }; - - assert.doesNotThrow(() => applyProfileDecorations(result), - 'First apply should succeed'); - assert.doesNotThrow(() => clearProfileDecorations(), - 'Clear should succeed'); - assert.doesNotThrow(() => applyProfileDecorations(result), - 'Re-apply should succeed'); - clearProfileDecorations(); - }); -}); diff --git a/vscode-extension/src/test/suite/profiler-entrypoints.test.ts b/vscode-extension/src/test/suite/profiler-entrypoints.test.ts deleted file mode 100644 index 8629b0066..000000000 --- a/vscode-extension/src/test/suite/profiler-entrypoints.test.ts +++ /dev/null @@ -1,349 +0,0 @@ -// Tests for [PROFILE-PROCESSES-LAUNCH-FILE]. See -// docs/specs/LSP-PROFILING-SPEC.md#PROFILE-PROCESSES-LAUNCH-FILE -// -// Issue #82: the PYTHON PROCESSES title-bar entry point must state WHAT it -// profiles (CPU vs memory) and let the user choose either metric, with icons -// and labels consistent with the per-row inline actions ("Profile CPU" 🔥 / -// "Track Memory" 🗄️). These tests assert the declarative VSIX surface in -// package.json — the same panel must speak one language at the row level and -// the title level. - -import * as assert from "assert"; -import * as vscode from "vscode"; -import { buildProfileLaunchConfig } from "../../process-launch"; -import { shouldProfileOnLaunch } from "../../profiler"; -import { PythonProcessesProvider } from "../../process-explorer"; -import { createStore } from "../../store"; -import { asRecord } from "../../unknown-shape"; - -import { EXTENSION_ID } from "./test-helpers"; -import { - type CommandContribution, - type WelcomeContribution, - manifestCommands, - manifestMenu, - manifestViewsWelcome -} from "./extension-manifest"; - -/** The metric-explicit run-and-profile entry points (#82). */ -const CPU_LAUNCH_COMMAND = "basilisk.profileCurrentFileCpu"; -const MEMORY_LAUNCH_COMMAND = "basilisk.trackMemoryCurrentFile"; -/** The ambiguous, metric-less command issue #82 retires. */ -const AMBIGUOUS_LAUNCH_COMMAND = "basilisk.profileCurrentFile"; - -/** The view/title entries scoped to the Python Processes panel. */ -function pythonProcessesTitleCommands(): string[] { - return manifestMenu("view/title") - .filter((entry) => entry.when.includes("basilisk.pythonProcesses")) - .map((entry) => entry.command); -} - -/** Look up a command's declaration, asserting it exists. */ -function commandEntry(commandId: string): CommandContribution { - const entry = manifestCommands().find((cmd) => cmd.command === commandId); - assert.ok(entry, `command "${commandId}" must be declared in package.json`); - return entry; -} - -suite("Python Processes — title-bar entry points state their metric (#82)", () => { - test("the title bar offers a CPU launch and a memory launch, not one ambiguous button", () => { - const titleCommands = pythonProcessesTitleCommands(); - assert.ok( - titleCommands.includes(CPU_LAUNCH_COMMAND), - `the title bar must offer a CPU-explicit launch (#82); got: ${titleCommands.join(", ")}`, - ); - assert.ok( - titleCommands.includes(MEMORY_LAUNCH_COMMAND), - `the title bar must offer a memory-explicit launch (#82); got: ${titleCommands.join(", ")}`, - ); - assert.ok( - !titleCommands.includes(AMBIGUOUS_LAUNCH_COMMAND), - "the metric-less 'Run & Profile Current File' button must be gone (#82)", - ); - }); - - test("the CPU launch is labelled CPU and wears the flame icon, matching the row action", () => { - const cpu = commandEntry(CPU_LAUNCH_COMMAND); - assert.ok( - cpu.title.includes("CPU"), - `the CPU launch title must say CPU (#82); got: ${cpu.title}`, - ); - assert.strictEqual(cpu.icon, "$(flame)", "the CPU launch must reuse the Profile CPU row icon"); - }); - - test("the memory launch is labelled Memory and wears the database icon, matching the row action", () => { - const memory = commandEntry(MEMORY_LAUNCH_COMMAND); - assert.ok( - memory.title.includes("Memory"), - `the memory launch title must say Memory (#82); got: ${memory.title}`, - ); - assert.strictEqual( - memory.icon, - "$(database)", - "the memory launch must reuse the Track Memory row icon", - ); - }); - - test("the empty-state welcome offers both metric-explicit launches", () => { - // [PROFILE-UX-PROGRESS] split the welcome into connecting/stopped/running - // states; the launch buttons live on the server-running empty state. - const welcome = manifestViewsWelcome().find( - (entry) => - entry.view === "basilisk.pythonProcesses" && - entry.contents.includes("No Python processes running"), - ); - assert.ok(welcome, "the Python Processes panel must declare a server-running empty state"); - assert.ok( - welcome.contents.includes(`command:${CPU_LAUNCH_COMMAND}`), - `the welcome view must link the CPU launch (#82); got: ${welcome.contents}`, - ); - assert.ok( - welcome.contents.includes(`command:${MEMORY_LAUNCH_COMMAND}`), - `the welcome view must link the memory launch (#82); got: ${welcome.contents}`, - ); - // Anchored with the markdown link's closing paren — `…CurrentFileCpu` - // legitimately contains `…CurrentFile` as a prefix. - assert.ok( - !welcome.contents.includes(`command:${AMBIGUOUS_LAUNCH_COMMAND})`), - "the welcome view must not link the retired ambiguous command (#82)", - ); - }); - - // The profiling UI ships enabled — the [PROFILE-UI-GATE] availability switch - // was removed. The regression this pins: a `when` clause referencing a context - // key nobody sets evaluates falsy and silently hides the entry point from - // every shipped user, which is indistinguishable from the feature not existing. - test("both launches are palette-reachable in production — never hidden behind a gate", () => { - const palette = manifestMenu("commandPalette"); - for (const command of [CPU_LAUNCH_COMMAND, MEMORY_LAUNCH_COMMAND]) { - const entry = palette.find((item) => item.command === command); - assert.ok( - entry === undefined, - `${command} must have no commandPalette suppression entry (ships ungated); ` + - `found when: ${entry?.when}`, - ); - } - }); - - // Whole-manifest sweep for the same regression class: any surface (palette, - // toolbar, view, keybinding) still referencing the removed gate key would be - // invisible in every session, since nothing sets the key any more. - test("no manifest surface references the removed profiling UI gate key", () => { - const extension = vscode.extensions.getExtension(EXTENSION_ID); - assert.ok(extension, "extension must be found"); - const manifest = JSON.stringify(extension.packageJSON); - assert.ok( - !manifest.includes("basilisk.profilingEnabled"), - "package.json must not reference the removed basilisk.profilingEnabled context key", - ); - }); -}); - -// Tests for [PROFILE-PROCESSES-PANEL]. See docs/specs/LSP-PROFILING-SPEC.md#PROFILE-PROCESSES-PANEL -// -// Issue #147 (scoped to empty-state honesty; the Signals refactor is #148): the -// Python Processes empty state must not float a redundant Refresh button, and -// "No Python processes running" must appear ONLY after a fetch has actually -// succeeded — loading / couldn't-load states must say so honestly instead of -// asserting the definitive negative. -const REFRESH_COMMAND = "basilisk.refreshProcesses"; - -/** The Python Processes panel's welcome entries. */ -function processWelcomes(): WelcomeContribution[] { - return manifestViewsWelcome().filter((entry) => entry.view === "basilisk.pythonProcesses"); -} - -suite("Python Processes — empty state honesty (#147)", () => { - test("no standalone Refresh button floats in the empty/welcome state", () => { - // The panel auto-refreshes and a view-title Refresh already exists; a Refresh - // button in the welcome copy is redundant and self-contradictory (#147). - for (const welcome of processWelcomes()) { - assert.ok( - !welcome.contents.includes(`command:${REFRESH_COMMAND}`), - `the empty state must not carry a standalone Refresh button (#147); got: ${welcome.contents}`, - ); - } - }); - - test('"No Python processes running" appears only after a fetch has succeeded', () => { - // The definitive negative must be gated on a SUCCEEDED fetch - // (processesState == loaded), not merely a running server — else a loading / - // errored / disconnected panel lies that there are no processes (#147). - const genuinelyEmpty = processWelcomes().find((entry) => - entry.contents.includes("No Python processes running"), - ); - assert.ok(genuinelyEmpty, "a genuinely-empty welcome must exist"); - assert.ok( - genuinelyEmpty.when?.includes("basilisk.processesState == loaded") === true, - `"No Python processes running" must be gated on a successful fetch (processesState == loaded), not just a running server (#147); got: ${String(genuinelyEmpty.when)}`, - ); - }); - - test("loading and couldn't-load states are declared, so the panel never lies", () => { - const whens = processWelcomes().map((entry) => entry.when ?? ""); - assert.ok( - whens.some((when) => when.includes("basilisk.processesState == loading")), - `a loading state must be declared so the panel never asserts "no processes" mid-load (#147); got: ${whens.join(" | ")}`, - ); - assert.ok( - whens.some((when) => when.includes("basilisk.processesState == error")), - `a couldn't-load state must be declared so a fetch error doesn't masquerade as "no processes" (#147); got: ${whens.join(" | ")}`, - ); - }); -}); - -/** - * Build a provider over a REAL store whose LSP client behaves as given (#147 - * seam). The provider is a pure projection of the store's `processes` Signal - * (#148), so the harness must go through the genuine store, not a stub bag. - */ -function providerWithClient(client: unknown): PythonProcessesProvider { - const store = createStore(); - if (client !== undefined) { - const fake = { - onDidChangeState: (): vscode.Disposable => ({ dispose: (): undefined => undefined }), - ...asRecord(client), - }; - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- generic LanguageClient double; the store only reads onDidChangeState here - store.setClient({ subscriptions: [] }, fake as never); - } - return new PythonProcessesProvider(store); -} - -suite("Python Processes — fetch-state drives the welcome (#147)", () => { - // The empty welcome only tells the truth if the provider publishes the right - // processesState. A still-loading or errored fetch must NEVER read as "loaded" - // (the genuinely-empty state), or "No Python processes running" lies again. - test("a freshly created panel is 'loading', never 'no processes'", () => { - const provider = providerWithClient({ isRunning: () => false }); - assert.strictEqual(provider.processesState, "loading"); - provider.dispose(); - }); - - test("a successful fetch settles to 'loaded' — only then is empty genuine", async () => { - const provider = providerWithClient({ isRunning: () => true, sendRequest: async () => ({ processes: [] }) }); - await provider.refreshNow(); - assert.strictEqual(provider.processesState, "loaded"); - provider.dispose(); - }); - - test("a failed fetch settles to 'error', not masquerading as 'no processes'", async () => { - const provider = providerWithClient({ - isRunning: () => true, - sendRequest: async () => { throw new Error("disconnected"); }, - }); - await provider.refreshNow(); - assert.strictEqual(provider.processesState, "error"); - provider.dispose(); - }); - - test("no running client stays 'loading' (the serverState welcome owns the copy)", async () => { - const provider = providerWithClient(undefined); - await provider.refreshNow(); - assert.strictEqual(provider.processesState, "loading"); - provider.dispose(); - }); -}); - -suite("Run & Profile registered commands (#82)", () => { - test("both registered launches refuse to start a session without a Python file open", async () => { - await vscode.commands.executeCommand("workbench.action.closeAllEditors"); - // Driving the REAL registered commands end-to-end: each must exist (or - // executeCommand rejects) and must decline to launch with no active - // Python editor instead of starting a broken session. - await vscode.commands.executeCommand("basilisk.profileCurrentFileCpu"); - await vscode.commands.executeCommand("basilisk.trackMemoryCurrentFile"); - assert.strictEqual( - vscode.debug.activeDebugSession, - undefined, - "no debug session may start when no Python file is open", - ); - }); -}); - -suite("Run & Profile launch configurations (#82)", () => { - test("the CPU launch asks for profiling on launch and names its metric", () => { - const config = buildProfileLaunchConfig("cpu", "/work/app.py"); - assert.strictEqual(config.type, "basilisk-debug"); - assert.strictEqual(config.request, "launch"); - assert.strictEqual(config.program, "/work/app.py"); - assert.strictEqual( - config.profileOnLaunch, - true, - "the CPU launch must actually start the profiler, not just run the file", - ); - assert.ok(config.name.includes("CPU"), `the session name must state the metric: ${config.name}`); - }); - - test("the memory launch stops on entry so tracemalloc can be injected, then tracked", () => { - const config = buildProfileLaunchConfig("memory", "/work/app.py"); - assert.strictEqual(config.program, "/work/app.py"); - assert.strictEqual( - config.stopOnEntry, - true, - "memory tracking injects tracemalloc via DAP evaluate, which needs a paused debuggee", - ); - assert.strictEqual( - config.memoryTrackOnLaunch, - true, - "memory-profiler.ts keys its auto-start flow off this flag", - ); - assert.ok(config.name.includes("Memory"), `the session name must state the metric: ${config.name}`); - }); - - test("a launch config requesting profileOnLaunch is honoured even with the global setting off", async () => { - const cfg = vscode.workspace.getConfiguration("basilisk"); - await cfg.update("profiler.profileOnLaunch", undefined, vscode.ConfigurationTarget.Global); - - const fromEntryPoint = { - type: "basilisk-debug", - configuration: buildProfileLaunchConfig("cpu", "/work/app.py"), - }; - assert.strictEqual( - shouldProfileOnLaunch(fromEntryPoint), - true, - "the 'Run & Profile CPU' launch must auto-profile without requiring the global setting (#82)", - ); - - const plainLaunch = { - type: "basilisk-debug", - configuration: { type: "basilisk-debug", request: "launch", name: "plain" }, - }; - assert.strictEqual( - shouldProfileOnLaunch(plainLaunch), - false, - "a plain debug session must not be auto-profiled by default", - ); - - const foreignSession = { - type: "node", - configuration: buildProfileLaunchConfig("cpu", "/work/app.py"), - }; - assert.strictEqual( - shouldProfileOnLaunch(foreignSession), - false, - "non-Basilisk sessions are never auto-profiled", - ); - }); - - // dap-1: the global setting is read directly by shouldProfileOnLaunch, so the - // stamp carve-out in applyDebugConfigDefaults is not enough on its own — the - // CPU auto-start must itself refuse a memory-tracking session. - test("a memory-tracking launch is never CPU-auto-profiled, even with the global setting on (dap-1)", async () => { - const cfg = vscode.workspace.getConfiguration("basilisk"); - await cfg.update("profiler.profileOnLaunch", true, vscode.ConfigurationTarget.Global); - try { - const memorySession = { - type: "basilisk-debug", - configuration: buildProfileLaunchConfig("memory", "/work/app.py"), - }; - assert.strictEqual( - shouldProfileOnLaunch(memorySession), - false, - "a memory launch must not auto-start the CPU sampler — it would collide with tracemalloc at the entry pause (dap-1)", - ); - } finally { - await cfg.update("profiler.profileOnLaunch", undefined, vscode.ConfigurationTarget.Global); - } - }); -}); diff --git a/vscode-extension/src/test/suite/profiler-memory-integration.test.ts b/vscode-extension/src/test/suite/profiler-memory-integration.test.ts deleted file mode 100644 index 68e908c6d..000000000 --- a/vscode-extension/src/test/suite/profiler-memory-integration.test.ts +++ /dev/null @@ -1,1118 +0,0 @@ -// Implements [LSPPROF]. See docs/specs/LSP-PROFILING-SPEC.md#LSPPROF -/** - * Profiler E2E Tests — Lifecycle, Memory Profiler, Error Handling, Integration. - * - * Validates: - * - Profile start/stop lifecycle works end-to-end - * - Memory profiler commands are registered and callable - * - Error handling produces user-friendly messages - * - Profiler and memory decorations can coexist - * - Cross-feature integration (profiler + document symbols + LSP) - * - * These tests require the Basilisk LSP server binary to be built. - * They exercise the real LSP protocol, not mocks. - */ - -import * as assert from 'assert'; -import * as vscode from 'vscode'; -import { getStore } from '../../extension'; -import { - EXTENSION_ID, - setupLspTestSuite, - teardownLspTestSuite, - closeAllEditors, - openPythonFile, -} from "./test-helpers"; -import { errorMessage } from "./caught-error"; - -import type { ProfileResult } from '../../profiler-decorations'; -import { - applyProfileDecorations, - clearProfileDecorations, - disposeProfileDecorations, -} from '../../profiler-decorations'; - -import type { - MemoryAllocation, - MemorySnapshotResult, - LeakConfidence, - SuspectedLeak, - MemoryDiffResult, -} from '../../memory-decorations'; -import { - applyMemoryDecorations, - clearMemoryDecorations, - disposeMemoryDecorations, - applyLeakDecorations, -} from '../../memory-decorations'; - -import { - PROFILER_CLIENT_COMMANDS, - PROFILER_SERVER_COMMANDS, - MEMORY_CLIENT_COMMANDS -} from './profiler-test-constants'; -import { - manifestCommands, - manifestConfigurationProperties -} from "./extension-manifest"; -import { rawField } from "../../unknown-shape"; - -let tmpDir = ''; - -/** Whether `value` is an array, without saying anything about its elements. */ -function isUnknownArray(value: unknown): value is unknown[] { - return Array.isArray(value); -} - -/** - * The `sessions` array of a `basilisk.profiler.list` reply. - * - * Each call site used to assert the reply into `{ sessions: unknown[] }` and - * then check `Array.isArray` separately. That check was the only thing actually - * proving the reply carried one, so it lives here now — callers get a real - * array, and a reply without one fails with the caller's own message. - */ -function sessionsOf(result: unknown, message: string): unknown[] { - const sessions = rawField(result, 'sessions'); - assert.ok(isUnknownArray(sessions), message); - return sessions; -} - -function assertCommandRegistered(commandId: string, label: string): void { - let threw = false; - let disposable: vscode.Disposable | undefined; - try { - disposable = vscode.commands.registerCommand(commandId, () => { /* probe */ }); - } catch { - threw = true; - } finally { - disposable?.dispose(); - } - assert.ok(threw, `${label} "${commandId}" should be registered after activation`); -} - -suite('Profiler — Start/Stop Lifecycle', () => { - suiteSetup(async function () { - const result = await setupLspTestSuite('basilisk-profiler-lc-'); - tmpDir = result.tmpDir; - }); - - suiteTeardown(() => { - teardownLspTestSuite(tmpDir); - }); - - teardown(async () => { - await closeAllEditors(); - }); - - test('profiler.start rejects invalid PID', async () => { - try { - await vscode.commands.executeCommand('basilisk.profiler.start', { - pid: 0, - }); - assert.fail('profiler.start with PID 0 should have thrown'); - } catch (err: unknown) { - const message = errorMessage(err); - assert.ok( - message.includes('not found') || - message.includes('Process') || - message.includes('-32001') || - message.includes('denied') || - message.includes('attach') || - message.includes('failed') || - message.includes('error'), - `Error should indicate process issue, got: ${message}`, - ); - } - }); - - test('profiler.stop rejects unknown session ID', async () => { - try { - await vscode.commands.executeCommand('basilisk.profiler.stop', { - sessionId: 'nonexistent-session-id', - }); - assert.fail('profiler.stop with unknown session should have thrown'); - } catch (err: unknown) { - const message = errorMessage(err); - assert.ok( - message.includes('session') || - message.includes('not found') || - message.includes('No active'), - `Error should mention session, got: ${message}`, - ); - } - }); - - test('profiler.snapshot rejects unknown session ID', async () => { - try { - await vscode.commands.executeCommand( - 'basilisk.profiler.snapshot', - { sessionId: 'nonexistent-session-id' }, - ); - assert.fail('profiler.snapshot with unknown session should have thrown'); - } catch (err: unknown) { - const message = errorMessage(err); - assert.ok( - message.includes('session') || - message.includes('not found') || - message.includes('No active'), - `Error should mention session, got: ${message}`, - ); - } - }); - - test('profiler.list returns array structure', async () => { - const result = await vscode.commands.executeCommand( - 'basilisk.profiler.list', - ); - assert.ok(result !== undefined, 'Should return a result'); - - sessionsOf(result, 'Result should have sessions array'); - }); - - test('profiler.start with no PID and no debug session gives clear error', async () => { - try { - await vscode.commands.executeCommand('basilisk.profiler.start', {}); - assert.fail('profiler.start with no PID should have thrown'); - } catch (err: unknown) { - const message = errorMessage(err); - assert.ok( - message.length > 0, - 'Should have an error message, got empty', - ); - } - }); - - test('profiler.stop with missing sessionId gives clear error', async () => { - try { - await vscode.commands.executeCommand('basilisk.profiler.stop', {}); - assert.fail('profiler.stop with missing sessionId should have thrown'); - } catch (err: unknown) { - const message = errorMessage(err); - assert.ok( - message.length > 0, - 'Should have an error message for missing sessionId', - ); - assert.ok( - message.includes('sessionId') || - message.includes('session') || - message.includes('required') || - message.includes('Missing'), - `Error should mention sessionId or session, got: ${message}`, - ); - } - }); - - test('consecutive profiler.list calls return consistent empty results', async () => { - const result1 = await vscode.commands.executeCommand('basilisk.profiler.list'); - const result2 = await vscode.commands.executeCommand('basilisk.profiler.list'); - - const sessions1 = sessionsOf(result1, 'First call sessions should be array'); - const sessions2 = sessionsOf(result2, 'Second call sessions should be array'); - - assert.strictEqual(sessions1.length, sessions2.length, - 'Consecutive list calls should return same session count'); - }); -}); - -suite('Memory Profiler — Command Registration', () => { - suiteSetup(async function () { - const result = await setupLspTestSuite('basilisk-memory-cmd-'); - tmpDir = result.tmpDir; - }); - - suiteTeardown(() => { - teardownLspTestSuite(tmpDir); - }); - - teardown(async () => { - await closeAllEditors(); - }); - - test('all memory client commands are registered', () => { - for (const cmd of MEMORY_CLIENT_COMMANDS) { - assertCommandRegistered(cmd, 'Memory command'); - } - }); - - test('memory commands appear in package.json commands section', () => { - const extension = vscode.extensions.getExtension(EXTENSION_ID); - assert.ok(extension, 'Extension should be found'); - - const commands = manifestCommands(); - const commandIds = commands.map((c) => c.command); - - for (const cmd of MEMORY_CLIENT_COMMANDS) { - assert.ok( - commandIds.includes(cmd), - `Memory command "${cmd}" should be in package.json contributes.commands`, - ); - } - }); - - test('memory commands have titles and categories', () => { - const extension = vscode.extensions.getExtension(EXTENSION_ID); - assert.ok(extension, 'Extension should be found'); - - const commands = manifestCommands(); - - for (const cmd of MEMORY_CLIENT_COMMANDS) { - const entry = commands.find((c) => c.command === cmd); - assert.ok(entry, `Command entry for "${cmd}" should exist in contributes.commands`); - assert.ok( - entry.title !== undefined && entry.title.length > 0, - `Memory command "${cmd}" should have a non-empty title`, - ); - assert.strictEqual( - entry.category, - 'Basilisk', - `Memory command "${cmd}" should have category "Basilisk"`, - ); - } - }); - - test('memory and profiler commands do not overlap', () => { - const profilerSet = new Set(PROFILER_CLIENT_COMMANDS as readonly string[]); - const memorySet = new Set(MEMORY_CLIENT_COMMANDS as readonly string[]); - - for (const cmd of PROFILER_CLIENT_COMMANDS) { - assert.ok(!memorySet.has(cmd), - `Profiler command "${cmd}" should not be in memory command set`); - } - for (const cmd of MEMORY_CLIENT_COMMANDS) { - assert.ok(!profilerSet.has(cmd), - `Memory command "${cmd}" should not be in profiler command set`); - } - }); - - test('all profiler and memory commands are distinct from each other', () => { - const allCommands = [ - ...PROFILER_CLIENT_COMMANDS, - ...MEMORY_CLIENT_COMMANDS, - ...PROFILER_SERVER_COMMANDS, - ]; - const unique = new Set(allCommands); - assert.strictEqual(unique.size, allCommands.length, - 'All profiler and memory commands should be unique'); - }); -}); - -suite('Profiler — Lifecycle Interaction', () => { - suiteSetup(async function () { - const result = await setupLspTestSuite('basilisk-profiler-lifecycle-'); - tmpDir = result.tmpDir; - }); - - suiteTeardown(() => { - teardownLspTestSuite(tmpDir); - }); - - teardown(async () => { - await closeAllEditors(); - }); - - test('profiler.start with PID 0 returns error with actionable info', async () => { - try { - await vscode.commands.executeCommand('basilisk.profiler.start', { pid: 0 }); - assert.fail('profiler.start with PID 0 should have thrown'); - } catch (err: unknown) { - const message = errorMessage(err); - assert.ok(message.length > 10, - `Error message should be descriptive, got: "${message}"`); - assert.ok(typeof message === 'string', 'Error should be a string message'); - assert.ok( - !message.includes('at Object.') || message.includes('Process') || message.includes('error'), - `Error should be user-friendly, not a stack trace: ${message}`, - ); - } - }); - - test('profiler.start with negative PID returns error', async () => { - try { - await vscode.commands.executeCommand('basilisk.profiler.start', { pid: -1 }); - assert.fail('profiler.start with negative PID should have thrown'); - } catch (err: unknown) { - const message = errorMessage(err); - assert.ok(message.length > 0, 'Error message should not be empty'); - assert.ok(typeof message === 'string', 'Error should produce a string message'); - assert.ok(!message.startsWith('undefined'), - 'Error message should not start with "undefined"'); - } - }); - - test('profiler.start with extremely large PID returns error', async () => { - try { - await vscode.commands.executeCommand('basilisk.profiler.start', { pid: 999999999 }); - assert.fail('profiler.start with nonexistent PID should have thrown'); - } catch (err: unknown) { - const message = errorMessage(err); - assert.ok(message.length > 0, 'Error message for large PID should not be empty'); - assert.ok(typeof message === 'string', 'Error should be a string'); - assert.ok( - message.includes('not found') || - message.includes('Process') || - message.includes('failed') || - message.includes('error') || - message.includes('denied') || - message.includes('attach'), - `Error should indicate process issue, got: ${message}`, - ); - } - }); - - test('profiler.stop without active session returns clear error', async () => { - try { - await vscode.commands.executeCommand('basilisk.profiler.stop', { - sessionId: 'definitely-not-a-real-session-id-abc123', - }); - assert.fail('profiler.stop without starting should have thrown'); - } catch (err: unknown) { - const message = errorMessage(err); - assert.ok(message.length > 0, 'Should have an error message'); - assert.ok( - message.includes('session') || - message.includes('not found') || - message.includes('No active'), - `Error should mention session, got: ${message}`, - ); - assert.ok(typeof message === 'string', 'Error message must be a string type'); - } - }); - - test('profiler.snapshot without active session returns clear error', async () => { - try { - await vscode.commands.executeCommand( - 'basilisk.profiler.snapshot', - { sessionId: 'no-such-snapshot-session-xyz789' }, - ); - assert.fail('profiler.snapshot without starting should have thrown'); - } catch (err: unknown) { - const message = errorMessage(err); - assert.ok(message.length > 0, 'Should have an error message'); - assert.ok( - message.includes('session') || - message.includes('not found') || - message.includes('No active'), - `Error should reference session state, got: ${message}`, - ); - assert.ok( - !message.includes('Cannot read properties of'), - 'Error should not be a null pointer error', - ); - } - }); - - test('profiler.list returns empty array when nothing is running', async () => { - const result = await vscode.commands.executeCommand('basilisk.profiler.list'); - assert.ok(result !== undefined, 'profiler.list should return a result'); - assert.ok(result !== null, 'profiler.list should not return null'); - - const sessions = sessionsOf(result, 'sessions should be an array'); - assert.strictEqual(sessions.length, 0, - 'no sessions should be active when nothing was started'); - }); - -}); - -suite('Profiler — Lifecycle Interaction (Continued)', () => { - suiteSetup(async function () { - const result = await setupLspTestSuite('basilisk-profiler-lifecycle2-'); - tmpDir = result.tmpDir; - }); - - suiteTeardown(() => { - teardownLspTestSuite(tmpDir); - }); - - teardown(async () => { - await closeAllEditors(); - }); - - test('error messages from profiler do not contain raw stack traces', async () => { - const errorProducingCalls = [ - vscode.commands.executeCommand('basilisk.profiler.start', { pid: 0 }), - vscode.commands.executeCommand('basilisk.profiler.stop', { sessionId: 'fake' }), - vscode.commands.executeCommand('basilisk.profiler.snapshot', { sessionId: 'fake' }), - ]; - - for (const call of errorProducingCalls) { - try { - await call; - } catch (err: unknown) { - const message = errorMessage(err); - const stackTraceLineCount = message.split('\n') - .filter((line: string) => line.trim().startsWith('at ')).length; - assert.ok(stackTraceLineCount < 3, - `Error should not contain full stack traces: ${message.slice(0, 200)}`); - } - } - }); - - test('multiple rapid profiler.list calls do not crash or diverge', async () => { - const results = await Promise.all([ - vscode.commands.executeCommand('basilisk.profiler.list'), - vscode.commands.executeCommand('basilisk.profiler.list'), - vscode.commands.executeCommand('basilisk.profiler.list'), - ]); - - for (const result of results) { - assert.ok(result !== undefined, 'Each list call must return a result'); - const sessions = sessionsOf(result, 'Each result must have sessions array'); - assert.strictEqual(sessions.length, 0, - 'All parallel list calls should return empty sessions'); - } - }); -}); - -suite('Profiler — Status Bar Behavior', () => { - suiteSetup(async function () { - const result = await setupLspTestSuite('basilisk-profiler-sb2-'); - tmpDir = result.tmpDir; - }); - - suiteTeardown(() => { - teardownLspTestSuite(tmpDir); - }); - - teardown(async () => { - await closeAllEditors(); - }); - - test('status bar item exists after extension activation', () => { - const store = getStore(); - assert.ok(store, 'Store should be initialized after activation'); - assert.ok( - store.lspState.value === 'running' || store.lspState.value === 'starting', - `LSP should be running or starting, got: ${store.lspState.value}`, - ); - const ext = vscode.extensions.getExtension(EXTENSION_ID); - assert.ok(ext, 'Extension should be found'); - assert.strictEqual(ext.isActive, true, 'Extension must be active'); - }); - - test('profiler status bar stop command is declared in package.json', () => { - const extension = vscode.extensions.getExtension(EXTENSION_ID); - assert.ok(extension, 'Extension should be found'); - - const commands = manifestCommands(); - const stopCmd = commands.find((c) => c.command === 'basilisk.profileStop'); - - assert.ok(stopCmd, 'profileStop command should exist in package.json'); - assert.ok(stopCmd.title !== undefined, 'profileStop should have a title'); - assert.ok(stopCmd.title.length > 0, 'profileStop title should not be empty'); - }); - - test('profiler status bar priority is declared correctly for ordering', () => { - const extension = vscode.extensions.getExtension(EXTENSION_ID); - assert.ok(extension, 'Extension should be found'); - assert.ok(extension.isActive, 'Extension must be active'); - - const store = getStore(); - assert.ok(store, 'Store must be initialized'); - assert.ok(store.client.value !== undefined, 'LSP client must exist'); - }); -}); - -suite('Profiler — Configuration Interaction', () => { - suiteSetup(async function () { - const result = await setupLspTestSuite('basilisk-profiler-cfgi-'); - tmpDir = result.tmpDir; - }); - - suiteTeardown(() => { - teardownLspTestSuite(tmpDir); - }); - - test('changing sampleRate config is reflected in workspace config', async () => { - const config = vscode.workspace.getConfiguration('basilisk.profiler'); - const originalRate = config.get<number>('sampleRate'); - assert.strictEqual(originalRate, 100, 'Default sampleRate should be 100'); - - await config.update('sampleRate', 50, vscode.ConfigurationTarget.Workspace); - const updatedConfig = vscode.workspace.getConfiguration('basilisk.profiler'); - assert.strictEqual(updatedConfig.get<number>('sampleRate'), 50, - 'sampleRate should be updated to 50'); - - await config.update('sampleRate', undefined, vscode.ConfigurationTarget.Workspace); - const restoredConfig = vscode.workspace.getConfiguration('basilisk.profiler'); - assert.strictEqual(restoredConfig.get<number>('sampleRate'), 100, - 'sampleRate should be restored to default 100'); - }); - - test('quick preset is offered for short burst profiling', () => { - const properties = manifestConfigurationProperties(); - const presetProp = properties['basilisk.profiler.preset'] as - { enum?: string[]; type?: string } | undefined; - assert.ok(presetProp, 'preset property should exist'); - assert.ok(Array.isArray(presetProp.enum) && presetProp.enum.includes('quick'), - 'quick (10 s @ 100 Hz, presets.rs) must be a valid preset'); - assert.ok(presetProp.type === 'string', 'preset should be a string type'); - }); - - test('detailed preset is offered and includeNative defaults off', () => { - const properties = manifestConfigurationProperties(); - const presetProp = properties['basilisk.profiler.preset'] as - { enum?: string[] } | undefined; - assert.ok(presetProp, 'preset property should exist'); - assert.ok(Array.isArray(presetProp.enum) && presetProp.enum.includes('detailed'), - 'detailed (60 s @ 200 Hz, presets.rs) must be a valid preset'); - - const config = vscode.workspace.getConfiguration('basilisk.profiler'); - assert.strictEqual(config.get<boolean>('includeNative'), false, - 'includeNative default should be false'); - }); - - test('all 4 presets are exactly the ones the server parses', () => { - const properties = manifestConfigurationProperties(); - const presetProp = properties['basilisk.profiler.preset'] as - { enum?: string[] } | undefined; - assert.ok(presetProp, 'preset property should exist'); - - const enumValues = presetProp.enum; - assert.ok(Array.isArray(enumValues), 'enum should be an array'); - // Mirrors ProfilingPreset::parse_name plus "default" — a name the - // server silently ignores (the old "memory"/"lightweight" entries) - // degrades to a default CPU session and must never be advertised. - assert.deepStrictEqual([...enumValues].sort(), - ['default', 'detailed', 'longRunning', 'quick'], - 'advertised presets must match the server parser exactly'); - }); - - test('numeric settings have reasonable bounds in config declarations', () => { - const properties = manifestConfigurationProperties(); - - const sampleRateProp = properties['basilisk.profiler.sampleRate']; - assert.ok(sampleRateProp !== undefined, 'sampleRate property must exist'); - const sampleRateDefault = sampleRateProp.default; - assert.ok(typeof sampleRateDefault === 'number', - 'sampleRate default should be a number'); - assert.ok(sampleRateDefault > 0, - 'sampleRate default should be positive'); - - const lineThresholdProp = properties['basilisk.profiler.lineThreshold']; - assert.ok(lineThresholdProp !== undefined, 'lineThreshold property must exist'); - const lineThresholdDefault = lineThresholdProp.default; - assert.ok(typeof lineThresholdDefault === 'number', - 'lineThreshold default should be a number'); - assert.ok(lineThresholdDefault > 0, - 'lineThreshold default should be positive'); - - const maxDiagProp = properties['basilisk.profiler.maxDiagnosticsPerFile']; - assert.ok(maxDiagProp !== undefined, 'maxDiagnosticsPerFile property must exist'); - const maxDiagDefault = maxDiagProp.default; - assert.ok(typeof maxDiagDefault === 'number', - 'maxDiagnosticsPerFile default should be a number'); - assert.ok(maxDiagDefault > 0, - 'maxDiagnosticsPerFile default should be positive'); - }); -}); - -suite('Memory Profiler — Extended', () => { - suiteSetup(async function () { - const result = await setupLspTestSuite('basilisk-memory-ext-'); - tmpDir = result.tmpDir; - }); - - suiteTeardown(() => { - teardownLspTestSuite(tmpDir); - }); - - teardown(async () => { - clearMemoryDecorations(); - await closeAllEditors(); - }); - - test('memoryStart command is callable and returns without crash', async () => { - const store = getStore(); - assert.ok(store, 'Store should be initialized'); - assert.ok(store.client.value !== undefined, 'LSP client should exist'); - - try { - await vscode.commands.executeCommand('basilisk.memoryStart'); - } catch (err: unknown) { - const message = errorMessage(err); - assert.ok(message.length > 0, 'Error message should not be empty'); - assert.ok(typeof message === 'string', 'Error should be a string'); - } - assert.ok(true, 'memoryStart command was callable'); - }); - - test('memorySnapshot without active session warns gracefully', async () => { - try { - await vscode.commands.executeCommand('basilisk.memorySnapshot'); - } catch (err: unknown) { - const message = errorMessage(err); - assert.ok(message.length > 0, 'Error should have a message'); - } - assert.ok(true, 'memorySnapshot without session did not crash'); - const store = getStore(); - assert.ok(store, 'Store should still be intact after memorySnapshot call'); - }); - - test('memoryReferences command is callable', async () => { - const store = getStore(); - assert.ok(store, 'Store should be initialized'); - assert.ok(store.client.value !== undefined, 'LSP client should exist'); - - const INPUT_BOX_DISMISS_DELAY_MS = 200; - const dismiss = new Promise<void>((resolve) => { - setTimeout(() => { - void vscode.commands.executeCommand('workbench.action.closeQuickOpen').then(() => { resolve(); }); - }, INPUT_BOX_DISMISS_DELAY_MS); - }); - - try { - await Promise.all([ - vscode.commands.executeCommand('basilisk.memoryReferences'), - dismiss, - ]); - } catch (err: unknown) { - const message = errorMessage(err); - assert.ok(typeof message === 'string', 'Error should be a string'); - } - assert.ok(true, 'memoryReferences command was callable without crash'); - }); - - test('MemoryAllocation type enforces required fields', () => { - const alloc: MemoryAllocation = { - file: '/src/allocator.py', - line: 55, - size: 52428800, - count: 10000, - }; - - assert.strictEqual(alloc.file, '/src/allocator.py'); - assert.strictEqual(alloc.line, 55); - assert.strictEqual(alloc.size, 52428800); - assert.strictEqual(alloc.count, 10000); - }); - - test('leak confidence levels map to correct severity ordering', () => { - const confidences: LeakConfidence[] = ['LOW', 'MEDIUM', 'HIGH', 'DEFINITE']; - const severityOrder = new Map<LeakConfidence, number>([ - ['LOW', 0], - ['MEDIUM', 1], - ['HIGH', 2], - ['DEFINITE', 3], - ]); - - assert.strictEqual(confidences.length, 4, - 'There should be exactly 4 confidence levels'); - - for (let idx = 0; idx < confidences.length - 1; idx++) { - const current = severityOrder.get(confidences[idx]); - const next = severityOrder.get(confidences[idx + 1]); - assert.ok(current !== undefined && next !== undefined, - `Severity for ${confidences[idx]} and ${confidences[idx + 1]} must be defined`); - assert.ok(current < next, - `${confidences[idx]} should have lower severity than ${confidences[idx + 1]}`); - } - }); - - test('SuspectedLeak type has all required fields', () => { - const leak: SuspectedLeak = { - file: '/src/leaky.py', - line: 42, - sizeGrowth: 1048576, - countGrowth: 500, - currentSize: 5242880, - confidence: 'HIGH', - reason: 'Monotonic growth detected across 10 snapshots', - }; - - assert.strictEqual(leak.file, '/src/leaky.py'); - assert.strictEqual(leak.line, 42); - assert.strictEqual(leak.sizeGrowth, 1048576); - assert.strictEqual(leak.countGrowth, 500); - assert.strictEqual(leak.currentSize, 5242880); - assert.strictEqual(leak.confidence, 'HIGH'); - assert.ok(leak.reason.length > 0, 'Leak reason should be non-empty'); - }); - -}); - -suite('Memory Profiler — Types and Decorations', () => { - suiteSetup(async function () { - const result = await setupLspTestSuite('basilisk-memory-ext2-'); - tmpDir = result.tmpDir; - }); - - suiteTeardown(() => { - teardownLspTestSuite(tmpDir); - }); - - teardown(async () => { - clearMemoryDecorations(); - await closeAllEditors(); - }); - - test('MemoryDiffResult type has all required fields', () => { - const diff: MemoryDiffResult = { - totalGrowth: 10485760, - totalFreed: 2097152, - netGrowth: 8388608, - suspectedLeaks: [ - { - file: '/src/data.py', - line: 10, - sizeGrowth: 5242880, - countGrowth: 200, - currentSize: 10485760, - confidence: 'DEFINITE', - reason: 'Allocation grows every snapshot with zero frees', - }, - ], - }; - - assert.strictEqual(diff.totalGrowth, 10485760); - assert.strictEqual(diff.totalFreed, 2097152); - assert.strictEqual(diff.netGrowth, 8388608); - assert.strictEqual(diff.suspectedLeaks.length, 1); - assert.strictEqual(diff.suspectedLeaks[0].confidence, 'DEFINITE'); - }); - - test('applyMemoryDecorations with populated allocations does not throw', async () => { - await openPythonFile(tmpDir, 'mem_alloc.py', - 'data = []\nfor i in range(1000):\n data.append(i)\n'); - - const snapshot: MemorySnapshotResult = { - memorySessionId: 'mem-populated', - snapshotId: 'snap-pop-001', - currentMemory: 104857600, - peakMemory: 209715200, - topAllocations: [ - { file: '/nonexistent/a.py', line: 1, size: 52428800, count: 5000 }, - { file: '/nonexistent/b.py', line: 15, size: 10485760, count: 1000 }, - { file: '/nonexistent/c.py', line: 30, size: 1048576, count: 100 }, - ], - }; - - assert.doesNotThrow(() => { - applyMemoryDecorations(snapshot); - }, 'applyMemoryDecorations should handle populated results'); - - assert.strictEqual(snapshot.topAllocations.length, 3, 'Should have 3 allocations'); - assert.ok(snapshot.currentMemory <= snapshot.peakMemory, - 'currentMemory should not exceed peakMemory'); - - clearMemoryDecorations(); - }); - - test('applyLeakDecorations with suspected leaks does not throw', async () => { - await openPythonFile(tmpDir, 'leak_test.py', - 'cache = {}\ndef leak():\n cache[id(object())] = object()\n'); - - const diff: MemoryDiffResult = { - totalGrowth: 5242880, - totalFreed: 524288, - netGrowth: 4718592, - suspectedLeaks: [ - { - file: '/nonexistent/leaky.py', - line: 3, - sizeGrowth: 2097152, - countGrowth: 300, - currentSize: 8388608, - confidence: 'HIGH', - reason: 'Monotonic growth pattern', - }, - ], - }; - - assert.doesNotThrow(() => { - applyLeakDecorations(diff); - }, 'applyLeakDecorations should handle suspected leaks'); - - assert.ok(diff.netGrowth > 0, 'Net growth should be positive for a leak'); - assert.ok(diff.totalGrowth > diff.totalFreed, - 'totalGrowth should exceed totalFreed when there is a net leak'); - - clearMemoryDecorations(); - }); -}); - -suite('Profiler — Error Handling', () => { - suiteSetup(async function () { - const result = await setupLspTestSuite('basilisk-profiler-err-'); - tmpDir = result.tmpDir; - }); - - suiteTeardown(() => { - teardownLspTestSuite(tmpDir); - }); - - teardown(async () => { - await closeAllEditors(); - }); - - test('profiler.start with invalid params returns error code or message', async () => { - try { - await vscode.commands.executeCommand('basilisk.profiler.start', { - pid: 0, sampleRate: -1, - }); - assert.fail('Should have thrown for invalid params'); - } catch (err: unknown) { - const message = errorMessage(err); - assert.ok(message.length > 0, 'Error message should not be empty'); - assert.ok(typeof message === 'string', 'Error should be string'); - assert.ok( - !message.includes('command not found') && - !message.includes('is not registered'), - `Error should be about the params, not command registration: ${message}`, - ); - } - }); - - test('profiler error codes are within expected range', async () => { - const expectedCodes = [-32001, -32002, -32003, -32004, -32005, -32006]; - - for (const code of expectedCodes) { - assert.ok(code < 0, `Error code ${code} should be negative`); - assert.ok(code >= -32099, `Error code ${code} should be >= -32099`); - assert.ok(code <= -32000, `Error code ${code} should be <= -32000`); - } - - const unique = new Set(expectedCodes); - assert.strictEqual(unique.size, expectedCodes.length, - 'All error codes should be unique'); - }); - - test('profiler.stop with empty string sessionId returns descriptive error', async () => { - try { - await vscode.commands.executeCommand('basilisk.profiler.stop', { sessionId: '' }); - assert.fail('Empty sessionId should produce an error'); - } catch (err: unknown) { - const message = errorMessage(err); - assert.ok(message.length > 0, 'Error should have a message'); - assert.ok(typeof message === 'string', 'Error should be string type'); - assert.ok( - !message.includes('panic') && !message.includes('PANIC'), - `Error should not indicate a panic: ${message}`, - ); - } - }); - - test('profiler.snapshot with null-like args returns error gracefully', async () => { - try { - await vscode.commands.executeCommand('basilisk.profiler.snapshot', { sessionId: null }); - assert.fail('Null sessionId should produce an error'); - } catch (err: unknown) { - const message = errorMessage(err); - assert.ok(message.length > 0, 'Error should have a message'); - assert.ok(typeof message === 'string', 'Error should be string type'); - assert.ok( - !message.includes('segfault') && !message.includes('SIGSEGV'), - 'Error should not be a segfault', - ); - } - }); - - test('connection errors are handled when LSP client is present', async () => { - const store = getStore(); - assert.ok(store, 'Store should exist'); - assert.ok(store.client.value !== undefined, 'Client should exist'); - - try { - await vscode.commands.executeCommand('basilisk.profiler.start', { pid: 2147483647 }); - assert.fail('Nonexistent PID should produce an error'); - } catch (err: unknown) { - const message = errorMessage(err); - assert.ok( - !message.includes('ECONNREFUSED') && - !message.includes('ECONNRESET'), - `Error should be protocol-level, not network: ${message}`, - ); - assert.ok(message.length > 0, 'Error message should not be empty'); - assert.ok( - !message.includes('undefined'), - 'Error message should not contain "undefined"', - ); - } - }); - - test('error messages are user-friendly strings, not JSON blobs', async () => { - try { - await vscode.commands.executeCommand('basilisk.profiler.stop', { - sessionId: 'nonexistent-for-ux-check', - }); - assert.fail('Should have thrown'); - } catch (err: unknown) { - const message = errorMessage(err); - assert.ok( - !message.trimStart().startsWith('{') || - message.includes('session') || - message.includes('error'), - `Error should be human-readable, not a raw JSON blob: ${message.slice(0, 200)}`, - ); - assert.ok(message.length < 2000, 'Error message should not be excessively long'); - assert.ok(typeof message === 'string', 'Error must be a string'); - } - }); -}); - -suite('Profiler — Cross-Feature Integration', () => { - suiteSetup(async function () { - const result = await setupLspTestSuite('basilisk-profiler-xfeat-'); - tmpDir = result.tmpDir; - }); - - suiteTeardown(() => { - teardownLspTestSuite(tmpDir); - }); - - teardown(async () => { - clearProfileDecorations(); - clearMemoryDecorations(); - await closeAllEditors(); - }); - - test('profiler commands do not interfere with document symbol provider', async () => { - const { uri } = await openPythonFile(tmpDir, 'symbols_test.py', - 'def hello():\n pass\n\nclass Foo:\n pass\n'); - - const listResult = await vscode.commands.executeCommand('basilisk.profiler.list'); - assert.ok(listResult !== undefined, 'profiler.list should work'); - - const symbols = await vscode.commands.executeCommand<vscode.DocumentSymbol[]>( - 'vscode.executeDocumentSymbolProvider', uri, - ); - assert.ok(symbols !== undefined && symbols !== null, - 'Document symbols should still work after profiler commands'); - assert.ok(symbols.length >= 1, - 'Should find at least one symbol in the test file'); - }); - - test('profiler.list is idempotent and does not corrupt LSP state', async () => { - for (let iteration = 0; iteration < 5; iteration++) { - const result = await vscode.commands.executeCommand('basilisk.profiler.list'); - sessionsOf(result, `Iteration ${iteration}: sessions should be an array`); - } - - const store = getStore(); - assert.ok(store, 'Store should exist'); - assert.ok( - store.lspState.value === 'running', - `LSP should still be running after repeated list calls, got: ${store.lspState.value}`, - ); - }); - - test('multiple quick start/stop error cycles do not crash', async () => { - const iterations = 3; - for (let cycle = 0; cycle < iterations; cycle++) { - try { - await vscode.commands.executeCommand('basilisk.profiler.start', { pid: 0 }); - } catch { - // Expected error. - } - - try { - await vscode.commands.executeCommand('basilisk.profiler.stop', { - sessionId: `fake-session-cycle-${cycle}`, - }); - } catch { - // Expected error. - } - } - - const store = getStore(); - assert.ok(store, 'Store should exist after rapid cycles'); - assert.ok( - store.lspState.value === 'running', - `LSP should still be running after ${iterations} error cycles, got: ${store.lspState.value}`, - ); - - const result = await vscode.commands.executeCommand('basilisk.profiler.list'); - sessionsOf(result, 'profiler.list should still work after error cycles'); - }); - -}); - -suite('Profiler — Coexistence and Disposal', () => { - suiteSetup(async function () { - const result = await setupLspTestSuite('basilisk-profiler-xfeat2-'); - tmpDir = result.tmpDir; - }); - - suiteTeardown(() => { - teardownLspTestSuite(tmpDir); - }); - - teardown(async () => { - clearProfileDecorations(); - clearMemoryDecorations(); - await closeAllEditors(); - }); - - test('profiler decorations and memory decorations can coexist', async () => { - await openPythonFile(tmpDir, 'coexist.py', 'x = 1\ny = 2\nz = 3\n'); - - const profileResult: ProfileResult = { - sessionId: 'coexist-cpu', - duration: 1.0, - totalSamples: 100, - outputFile: '', - hotFunctions: [], - hotLines: [ - { file: '/tmp/coexist.py', line: 1, samples: 50, percentage: 50.0 }, - ], - }; - - const memSnapshot: MemorySnapshotResult = { - memorySessionId: 'coexist-mem', - snapshotId: 'snap-coexist', - currentMemory: 1048576, - peakMemory: 2097152, - topAllocations: [ - { file: '/tmp/coexist.py', line: 2, size: 524288, count: 100 }, - ], - }; - - assert.doesNotThrow(() => { - applyProfileDecorations(profileResult); - }, 'Profile decorations should apply without error'); - - assert.doesNotThrow(() => { - applyMemoryDecorations(memSnapshot); - }, 'Memory decorations should apply without error'); - - assert.doesNotThrow(() => { - clearProfileDecorations(); - }, 'Clearing profile decorations should not throw'); - - assert.doesNotThrow(() => { - clearMemoryDecorations(); - }, 'Clearing memory decorations should not throw'); - }); - - test('profiler commands exist alongside non-profiler commands', async () => { - const store = getStore(); - assert.ok(store, 'Store should exist'); - - const serverCmds = store.serverCommands.value; - assert.ok(serverCmds.size > PROFILER_SERVER_COMMANDS.length, - 'Server should advertise commands beyond just profiler ones'); - - for (const cmd of PROFILER_SERVER_COMMANDS) { - assert.ok(serverCmds.has(cmd), - `Server command "${cmd}" should still be present alongside other commands`); - } - }); - - test('dispose functions are idempotent and safe to call multiple times', () => { - assert.doesNotThrow(() => { - disposeProfileDecorations(); - }, 'First disposeProfileDecorations call should not throw'); - - assert.doesNotThrow(() => { - disposeProfileDecorations(); - }, 'Second disposeProfileDecorations call should not throw'); - - assert.doesNotThrow(() => { - disposeMemoryDecorations(); - }, 'First disposeMemoryDecorations call should not throw'); - - assert.doesNotThrow(() => { - disposeMemoryDecorations(); - }, 'Second disposeMemoryDecorations call should not throw'); - }); -}); diff --git a/vscode-extension/src/test/suite/profiler-panel-reactive.test.ts b/vscode-extension/src/test/suite/profiler-panel-reactive.test.ts deleted file mode 100644 index 82926a23b..000000000 --- a/vscode-extension/src/test/suite/profiler-panel-reactive.test.ts +++ /dev/null @@ -1,259 +0,0 @@ -// Tests for [PROFILE-PROCESSES-REACTIVE]. See docs/specs/LSP-PROFILING-SPEC.md#PROFILE-PROCESSES-REACTIVE -// -// The Python Processes panel must REACT to the profiling session: while a CPU -// or memory session is starting/running it shows live chrome (message + badge), -// hides the "Run & Profile" launches behind a Stop affordance, and marks the -// profiled row — so it never offers to start a second session on top of a -// running one. Three layers are asserted without spawning a real profiler: -// 1. the declarative button gating in package.json (`when` clauses); -// 2. the pure chrome builders (panelMessage/panelBadge); -// 3. the live wiring — driving the real store's signal and reading the live -// tree-view chrome + status bar back through their e2e seams. - -import * as assert from "assert"; -import { getStore } from "../../extension"; -import { profilerStatusText } from "../../profiler"; -import { panelBadge, panelMessage, pythonProcessesViewState } from "../../process-reactivity"; -import { IDLE_PROFILER_SESSION, type ProfilerSession } from "../../profiler-state"; - -import { setupLspTestSuite, teardownLspTestSuite } from "./test-helpers"; -import { - type MenuContribution, - manifestMenu -} from "./extension-manifest"; - -/** Panel-scoped entries of a given menu. */ -function panelMenu(menuId: string): MenuContribution[] { - return manifestMenu(menuId).filter((entry) => (entry.when ?? "").includes("basilisk.pythonProcesses")); -} - -/** Assert a menu entry exists and its `when` contains a clause. */ -function assertWhenHas(entry: MenuContribution | undefined, command: string, clause: string): void { - assert.ok(entry, `${command} must be declared in the panel menu`); - assert.ok((entry.when ?? "").includes(clause), `${command} when must contain "${clause}"; got: ${entry.when ?? "(none)"}`); -} - -/** Build a session by overriding the idle baseline. */ -function session(partial: Partial<ProfilerSession>): ProfilerSession { - return { ...IDLE_PROFILER_SESSION, ...partial }; -} - -/** Assert `text` is present and contains `needle`. */ -function assertHas(text: string | undefined, needle: string): void { - assert.ok(text?.includes(needle), `expected "${needle}" in: ${text ?? "(none)"}`); -} - -/** Assert `text` does not contain `needle` (absent counts as not containing). */ -function assertLacks(text: string | undefined, needle: string): void { - assert.ok(!text?.includes(needle), `unexpected "${needle}" in: ${text ?? "(none)"}`); -} - -suite("Python Processes panel — reactive button gating (manifest)", () => { - test("the Run & Profile launches gate per activity — CPU on cpuBusy, memory on memoryBusy", () => { - const title = panelMenu("view/title"); - assertWhenHas( - title.find((item) => item.command === "basilisk.profileCurrentFileCpu"), - "basilisk.profileCurrentFileCpu", - "!basilisk.cpuBusy", - ); - assertWhenHas( - title.find((item) => item.command === "basilisk.trackMemoryCurrentFile"), - "basilisk.trackMemoryCurrentFile", - "!basilisk.memoryBusy", - ); - }); - - test("the CPU launch does NOT gate on the memory activity, so it stays available during memory tracking", () => { - const cpu = panelMenu("view/title").find((item) => item.command === "basilisk.profileCurrentFileCpu"); - assert.ok(cpu, "the CPU launch must be declared"); - assert.ok( - !(cpu.when ?? "").includes("memoryBusy"), - `the CPU launch must not key off memoryBusy: ${cpu.when ?? "(none)"}`, - ); - const mem = panelMenu("view/title").find((item) => item.command === "basilisk.trackMemoryCurrentFile"); - assert.ok(mem, "the memory launch must be declared"); - assert.ok( - !(mem.when ?? "").includes("cpuBusy"), - `the memory launch must not key off cpuBusy: ${mem.when ?? "(none)"}`, - ); - }); - - test("the title bar reveals a Stop button keyed on the active metric", () => { - const title = panelMenu("view/title"); - assertWhenHas(title.find((item) => item.command === "basilisk.profileStop"), "Stop Profiling", "basilisk.profiling"); - assertWhenHas(title.find((item) => item.command === "basilisk.memoryStop"), "Stop Memory", "basilisk.memoryTracking"); - }); - - test("the per-row Profile/Track inline actions gate per activity", () => { - const inline = panelMenu("view/item/context").filter((entry) => (entry.group ?? "").startsWith("inline")); - assertWhenHas( - inline.find((item) => item.command === "basilisk.profileProcess"), - "basilisk.profileProcess", - "!basilisk.cpuBusy", - ); - assertWhenHas( - inline.find((item) => item.command === "basilisk.memoryTrackProcess"), - "basilisk.memoryTrackProcess", - "!basilisk.memoryBusy", - ); - }); - - test("Track Memory is offered only on the active-debuggee row, never an external process", () => { - // Memory tracking can't target an external process, so its row action gates - // on the debuggee-only contextValue ([PROFILE-PROCESSES-LAUNCH]). - for (const entry of panelMenu("view/item/context").filter((e) => e.command === "basilisk.memoryTrackProcess")) { - assertWhenHas(entry, "basilisk.memoryTrackProcess", "viewItem == pythonProcessDebuggee"); - } - // The CPU Profile action, by contrast, stays available on every attachable - // row — blocked (🚫) rows opt out via `blockedPythonProcess`, which the - // `/^pythonProcess/` clause deliberately does not match (#266). - const profile = panelMenu("view/item/context").find( - (e) => e.command === "basilisk.profileProcess" && (e.group ?? "").startsWith("inline"), - ); - assertWhenHas(profile, "basilisk.profileProcess", "/^pythonProcess/"); - }); - - test("the actively-profiled row carries an inline Stop action", () => { - const inlineStop = panelMenu("view/item/context").find( - (entry) => entry.command === "basilisk.profileStop" && (entry.group ?? "").startsWith("inline"), - ); - assert.ok(inlineStop, "the profiled row must offer an inline Stop"); - assert.strictEqual( - inlineStop.when, - "view == basilisk.pythonProcesses && viewItem == pythonProcessProfiling", - "the inline Stop must target only the row whose contextValue marks it as profiling", - ); - }); -}); - -suite("Python Processes panel — reactive chrome builders (pure)", () => { - test("idle renders neither a message nor a badge", () => { - assert.strictEqual(panelMessage(IDLE_PROFILER_SESSION), undefined); - assert.strictEqual(panelBadge(IDLE_PROFILER_SESSION), undefined); - }); - - test("a CPU start renders a spinner message and a badge dot", () => { - assertHas(panelMessage(session({ cpu: "starting" })), "Starting CPU"); - assert.strictEqual(panelBadge(session({ cpu: "starting" }))?.value, 1); - }); - - test("an active CPU profile renders PID, then live count/duration/top once samples arrive", () => { - const before = panelMessage(session({ cpu: "active", cpuPid: 7 })); - assertHas(before, "PID 7"); - assertLacks(before, "samples"); - - const live = panelMessage(session({ - cpu: "active", - cpuPid: 4242, - sampleCount: 1500, - durationSecs: 3, - topFunction: "hot_function", - })); - assertHas(live, "PID 4242"); - assertHas(live, "1.5K samples"); - assertHas(live, "(3s)"); - assertHas(live, "hot_function"); - }); - - test("memory tracking renders a memory-specific message", () => { - assertHas(panelMessage(session({ memory: "starting" })), "memory tracking"); - assertHas(panelMessage(session({ memory: "active" })), "memory"); - }); - - test("a CPU profile takes precedence over concurrent memory tracking in the readout", () => { - assertHas(panelMessage(session({ cpu: "active", cpuPid: 9, memory: "active", memorySessionId: "m" })), "PID 9"); - }); -}); - -suite("Python Processes panel — reactive chrome (store-driven, live)", () => { - let tmpDir = ""; - - suiteSetup(async function () { - this.timeout(90_000); - const result = await setupLspTestSuite("basilisk-reactive-"); - tmpDir = result.tmpDir; - }); - - suiteTeardown(() => { - resetProfilerState(); - teardownLspTestSuite(tmpDir); - }); - - teardown(() => { resetProfilerState(); }); - - /** The live store, asserted present. */ - function liveStore(): NonNullable<ReturnType<typeof getStore>> { - const store = getStore(); - assert.ok(store, "the store must be initialized after activation"); - return store; - } - - /** Return the panel to idle so suites stay independent. */ - function resetProfilerState(): void { - const store = getStore(); - store?.profilerStopped(); - store?.memoryTrackingStopped(); - } - - test("idle: no live chrome, the status bar is hidden, and nothing is busy", () => { - resetProfilerState(); - assert.strictEqual(pythonProcessesViewState().message, undefined, "no panel message when idle"); - assert.strictEqual(pythonProcessesViewState().badge, undefined, "no badge when idle"); - assert.strictEqual(profilerStatusText(), undefined, "status bar hidden when idle"); - assert.strictEqual(liveStore().profilerBusy.value, false); - }); - - test("starting → active → progress → stop drives the panel and status bar live", () => { - const store = liveStore(); - - store.profilerStarting(); - assert.strictEqual(store.profilerBusy.value, true, "a start in flight is busy"); - assertHas(pythonProcessesViewState().message, "Starting CPU"); - assertHas(profilerStatusText(), "starting"); - - store.profilerActive(4242, "sess-reactive-1"); - assert.strictEqual(store.profiler.value.cpu, "active"); - assertHas(pythonProcessesViewState().message, "PID 4242"); - assert.strictEqual(pythonProcessesViewState().badge, 1, "a badge dot must mark an active profile"); - assertHas(profilerStatusText(), "Profiling"); - - store.profilerProgress(1500, 3, "hot_function"); - assertHas(pythonProcessesViewState().message, "1.5K samples"); - assertHas(profilerStatusText(), "1.5K samples"); - - store.profilerStopped(); - assert.strictEqual(store.profilerBusy.value, false, "stop clears busy"); - assert.strictEqual(pythonProcessesViewState().message, undefined, "stop clears the panel chrome"); - assert.strictEqual(pythonProcessesViewState().badge, undefined, "stop clears the badge"); - assert.strictEqual(profilerStatusText(), undefined, "stop hides the status bar — no zombie spinner"); - }); - - test("memory tracking surfaces in the panel and clears on stop", () => { - const store = liveStore(); - store.memoryTrackingActive("mem-reactive-1"); - assert.strictEqual(store.profilerBusy.value, true, "memory tracking counts as busy"); - assertHas(pythonProcessesViewState().message, "memory"); - store.memoryTrackingStopped(); - assert.strictEqual(pythonProcessesViewState().message, undefined, "stop clears the memory chrome"); - assert.strictEqual(store.profilerBusy.value, false); - }); - - // The crux of "both": each metric's busy signal is independent, so a CPU run - // can start while memory tracks (and vice versa), but never a second of the - // same metric ([PROFILE-PROCESSES-REACTIVE]). - test("per-activity busy signals gate independently — CPU free during memory, memory free during CPU", () => { - const store = liveStore(); - - store.profilerActive(1234, "sess-cpu"); - assert.strictEqual(store.cpuBusy.value, true, "an active CPU profile makes cpuBusy true"); - assert.strictEqual(store.memoryBusy.value, false, "an active CPU profile leaves memory free"); - store.profilerStopped(); - - store.memoryTrackingActive("sess-mem"); - assert.strictEqual(store.memoryBusy.value, true, "active memory tracking makes memoryBusy true"); - assert.strictEqual(store.cpuBusy.value, false, "active memory tracking leaves CPU free"); - assert.strictEqual(store.profilerBusy.value, true, "the aggregate still reflects either leg"); - store.memoryTrackingStopped(); - assert.strictEqual(store.profilerBusy.value, false, "stopping the last leg clears the aggregate"); - }); -}); diff --git a/vscode-extension/src/test/suite/profiler-test-constants.ts b/vscode-extension/src/test/suite/profiler-test-constants.ts deleted file mode 100644 index fbbcd5492..000000000 --- a/vscode-extension/src/test/suite/profiler-test-constants.ts +++ /dev/null @@ -1,53 +0,0 @@ -// Implements [LSPPROF]. See docs/specs/LSP-PROFILING-SPEC.md#LSPPROF -/** - * Shared constants and type helpers for profiler test suites. - * - * Extracted to avoid duplication across profiler.test.ts, - * profiler-decorations.test.ts, and profiler-memory-integration.test.ts. - * - * Manifest contributions live in ./extension-manifest — they are read by every - * suite, not just the profiler ones. - */ - -/** Profiler client-side commands (registered in profiler.ts). */ -export const PROFILER_CLIENT_COMMANDS = [ - 'basilisk.profileStart', - 'basilisk.profileStop', - 'basilisk.profileSnapshot', - 'basilisk.profileAttachToDebug', - 'basilisk.profileShowResults', -] as const; - -/** Memory profiler client-side commands (registered in memory-profiler.ts). */ -export const MEMORY_CLIENT_COMMANDS = [ - 'basilisk.memoryStart', - 'basilisk.memorySnapshot', - 'basilisk.memoryStop', - 'basilisk.memoryReferences', -] as const; - -/** Profiler server-side commands (advertised by LSP). */ -export const PROFILER_SERVER_COMMANDS = [ - 'basilisk.profiler.start', - 'basilisk.profiler.stop', - 'basilisk.profiler.snapshot', - 'basilisk.profiler.list', - 'basilisk.profiler.cooperativeScript', - 'basilisk.profiler.cooperativeAttach', -] as const; - -/** Profiler configuration keys. */ -export const PROFILER_SETTINGS = [ - 'basilisk.profiler.sampleRate', - 'basilisk.profiler.includeNative', - 'basilisk.profiler.lineThreshold', - 'basilisk.profiler.functionThreshold', - 'basilisk.profiler.maxDiagnosticsPerFile', - 'basilisk.profiler.showInlineHeatMap', -] as const; - -/** Additional profiler configuration keys. */ -export const PROFILER_EXTRA_SETTINGS = [ - 'basilisk.profiler.profileOnLaunch', - 'basilisk.profiler.preset', -] as const; diff --git a/vscode-extension/src/test/suite/profiler-ux.test.ts b/vscode-extension/src/test/suite/profiler-ux.test.ts deleted file mode 100644 index e2ea61625..000000000 --- a/vscode-extension/src/test/suite/profiler-ux.test.ts +++ /dev/null @@ -1,105 +0,0 @@ -// Tests for [PROFILE-UX-PROGRESS]. See docs/specs/LSP-PROFILING-SPEC.md#PROFILE-UX-PROGRESS -// -// Loading & progress states: the processes panel must never claim "no -// processes" while the language server is still connecting, a manual refresh -// must visibly run under the view's progress bar, and progress operations -// must open AND close (no zombie notifications). The heavier flow-level -// progress assertions live inside the CPU/memory e2e suites where the real -// flows run; this suite covers the panel chrome and the manifest gating. - -import * as assert from "assert"; -import * as vscode from "vscode"; -import { recordedOperations } from "../../progress-ops"; -import { - setupLspTestSuite, - teardownLspTestSuite, -} from "./test-helpers"; -import { - manifestViewsWelcome, - type WelcomeContribution -} from "./extension-manifest"; - -/** One viewsWelcome contribution. */ -/** The pythonProcesses panel's welcome entries from the live manifest. */ -function pythonProcessesWelcome(): WelcomeContribution[] { - return manifestViewsWelcome().filter( - (entry) => entry.view === "basilisk.pythonProcesses", - ); -} - -suite("Profiler UX — loading & progress states", () => { - let tmpDir = ""; - - suiteSetup(async function () { - this.timeout(60_000); - const result = await setupLspTestSuite("basilisk-ux-"); - tmpDir = result.tmpDir; - }); - - suiteTeardown(() => { - teardownLspTestSuite(tmpDir); - }); - - test("panel empty state is server-state aware — never 'no processes' while connecting", () => { - const entries = pythonProcessesWelcome(); - assert.strictEqual( - entries.length, - 5, - `the panel needs connecting/stopped/loading/couldn't-load/empty welcome states (#147), got ${entries.length}`, - ); - - const connecting = entries.find((entry) => entry.contents.includes("Connecting")); - assert.ok(connecting !== undefined, "a 'connecting' state must exist"); - assert.ok( - connecting.when?.includes("basilisk.serverState != running") === true, - `the connecting state must hide once the server runs, when: ${String(connecting.when)}`, - ); - - const stopped = entries.find((entry) => entry.contents.includes("not running")); - assert.ok(stopped !== undefined, "a 'server stopped' state must exist"); - assert.strictEqual(stopped.when, "basilisk.serverState == stopped"); - assert.ok( - stopped.contents.includes("command:basilisk.restartServer"), - "the stopped state must offer a one-click restart", - ); - - const running = entries.find((entry) => - entry.contents.includes("No Python processes running"), - ); - assert.ok(running !== undefined, "the true empty state must exist"); - const runningWhen = running.when ?? ""; - assert.ok( - runningWhen.includes("basilisk.serverState == running") && - runningWhen.includes("basilisk.processesState == loaded"), - `'No Python processes' may only show when the server runs AND a fetch actually succeeded (#147); when: ${runningWhen}`, - ); - assert.ok( - running.contents.includes("command:basilisk.profileCurrentFileCpu") && - running.contents.includes("command:basilisk.trackMemoryCurrentFile"), - "the empty state must keep the metric-explicit launch buttons", - ); - }); - - test("manual panel refresh runs under the view's progress bar and completes", async () => { - const before = recordedOperations().length; - await vscode.commands.executeCommand("basilisk.refreshProcesses"); - const ops = recordedOperations().slice(before); - const begin = ops.indexOf("begin:Refresh Python processes"); - const end = ops.indexOf("end:Refresh Python processes"); - assert.ok(begin !== -1, `refresh must show view progress, ops: ${ops.join(" | ")}`); - assert.ok(end > begin, "the refresh progress must close when the fetch completes"); - }); - - test("every recorded progress operation closes — no zombie notifications", () => { - // Cumulative invariant across everything the suite run has done so far: - // each begin has a matching end (failed flows close via finally). - const ops = recordedOperations(); - const begins = ops.filter((entry) => entry.startsWith("begin:")).length; - const ends = ops.filter((entry) => entry.startsWith("end:")).length; - assert.strictEqual( - begins, - ends, - `unbalanced progress operations — a notification leaked: ${ops.join(" | ")}`, - ); - }); -}); diff --git a/vscode-extension/src/test/suite/profiler-webviews.test.ts b/vscode-extension/src/test/suite/profiler-webviews.test.ts deleted file mode 100644 index 73b3ea22d..000000000 --- a/vscode-extension/src/test/suite/profiler-webviews.test.ts +++ /dev/null @@ -1,325 +0,0 @@ -// Implements [PROFILE-WEBVIEW-HOST] + [PROFILE-FLAMEGRAPH]. -// See docs/specs/LSP-PROFILING-SPEC.md#PROFILE-WEBVIEW-HOST -/** - * PROFILER WEBVIEW HOST — hardening and honesty of every profiler results - * panel (CPU results, memory dashboard, retention graph). - * - * Why this suite exists: before the shared host, the memory dashboard and the - * retention graph shipped with NO Content-Security-Policy, embedded - * profiled-program data (allocation paths, type reprs, leak reasons) into - * their inline <script> without escaping `<` (a hostile `</script>` payload - * broke out of the script element), and re-registered their message handler on - * EVERY open — with the autopilot re-rendering the dashboard on each debugger - * pause, one row click navigated N times. These tests pin all three fixes on - * the real builders and, for the handler, on a real live webview panel. - */ - -import { delay } from '../../timeouts'; -import * as assert from 'assert'; -import * as fs from 'fs'; -import * as os from 'os'; -import * as path from 'path'; -import { - buildWebviewDocument, - embedJson, - SingletonWebviewPanel, - type WebviewMessage, -} from '../../profiler-webview'; -import { - buildMemoryDashboardHtml, - type MemoryDashboardSnapshot, - type MemoryDiffData, -} from '../../memory-dashboard'; -import { buildRefGraphHtml, type ReferenceGraphResult } from '../../memory-ref-graph'; -import { - buildFlamegraphHtml, - loadFlamegraphSvgDataUri, -} from '../../profiler-flamegraph-html'; -import type { ProfileResult } from '../../profiler-decorations'; -import { stringField } from '../../unknown-shape'; -import { pollUntilResult, closeAllEditors, removeTestDir } from './test-helpers'; - -/** A payload that closes the surrounding <script> if embedding is unescaped. */ -const HOSTILE = '</script><img src=x onerror=alert(1)>'; - -function dashboardSnapshot(overrides: Partial<MemoryDashboardSnapshot> = {}): MemoryDashboardSnapshot { - return { - memorySessionId: 'mem-1', - snapshotId: 'snap-1', - currentMemory: 1_048_576, - peakMemory: 2_097_152, - gcObjects: 1200, - gcCounts: [700, 12, 3], - topAllocations: [{ file: '/app/main.py', line: 10, size: 4096, count: 8 }], - timeline: [], - heapProfilePath: '', - ...overrides, - }; -} - -function profileResult(overrides: Partial<ProfileResult> = {}): ProfileResult { - return { - sessionId: 's-1', - duration: 2.5, - totalSamples: 250, - outputFile: '/tmp/profile.speedscope.json', - hotFunctions: [ - { name: 'hot', file: '/app/main.py', line: 3, samples: 200, percentage: 80, selfPercentage: 75 }, - ], - hotLines: [{ file: '/app/main.py', line: 5, samples: 180, percentage: 72 }], - ...overrides, - }; -} - -suite('Profiler webviews — shared host hardening', () => { - test('memory dashboard HTML is CSP-locked and hostile allocation data cannot escape the script', () => { - const snapshot = dashboardSnapshot({ - topAllocations: [{ file: HOSTILE, line: 1, size: 1024, count: 2 }], - }); - const diff: MemoryDiffData = { - totalGrowth: 2048, - totalFreed: 0, - netGrowth: 2048, - suspectedLeaks: [{ - file: HOSTILE, line: 1, sizeGrowth: 2048, countGrowth: 2, - currentSize: 4096, currentCount: 4, confidence: 'high', reason: HOSTILE, - }], - grownAllocations: [], - }; - const html = buildMemoryDashboardHtml(snapshot, diff); - assert.ok(html.includes('Content-Security-Policy'), 'the dashboard must declare a CSP'); - assert.ok(/script-src 'nonce-[^']+'/.test(html), 'the inline script must be nonce-gated'); - assert.ok( - !html.includes('</script><img'), - 'profiled-program data must not close the inline <script> element early', - ); - assert.ok(html.includes('escapeHtml(basename(a.file))'), 'allocation paths must be escaped before innerHTML'); - assert.ok(html.includes('escapeHtml(lk.reason)'), 'leak reasons must be escaped before innerHTML'); - }); - - // [PROFILE-NATIVE] The dashboard is the landing view for memory results; - // the raw V8 .heapprofile opens on demand from its own button, and no - // button is rendered when no trace was written. - test('the dashboard offers the raw heap profile via a button only when one exists', () => { - const withTrace = buildMemoryDashboardHtml( - dashboardSnapshot({ heapProfilePath: '/tmp/basilisk-mem-1.heapprofile' }), - ); - assert.ok( - withTrace.includes('Open Heap Profile in VS Code Viewer'), - 'the dashboard must offer opening the native .heapprofile viewer', - ); - assert.ok( - withTrace.includes('openHeapProfile'), - 'the heap-profile button must post a message the extension handles', - ); - assert.ok( - withTrace.includes('Open in Speedscope (external)'), - 'the dashboard must offer the speedscope deep link (speedscope imports .heapprofile)', - ); - assert.ok( - withTrace.includes('openSpeedscope'), - 'the speedscope button must post a message the extension handles', - ); - - // The action script always references the ids defensively; only the - // BUTTON ELEMENTS (their visible labels) must be absent without a trace. - const withoutTrace = buildMemoryDashboardHtml(dashboardSnapshot()); - assert.ok( - !withoutTrace.includes('Open Heap Profile in VS Code Viewer'), - 'no heap-profile button may render when no .heapprofile was written', - ); - assert.ok( - !withoutTrace.includes('Open in Speedscope (external)'), - 'no speedscope button may render when no .heapprofile was written', - ); - }); - - test('retention graph HTML is CSP-locked and hostile type names/reprs cannot escape', () => { - const result: ReferenceGraphResult = { - targetType: HOSTILE, - maxDepth: 5, - maxNodes: 200, - script: '', - graph: { - nodes: [{ id: 1, type: HOSTILE, size: 64, repr: HOSTILE, depth: 0, isTarget: true }], - edges: [{ from: 1, to: 1, label: HOSTILE }], - cycles: [], - retentionPath: [HOSTILE], - }, - }; - const html = buildRefGraphHtml(result); - assert.ok(html.includes('Content-Security-Policy'), 'the retention graph must declare a CSP'); - assert.ok(/script-src 'nonce-[^']+'/.test(html), 'the inline script must be nonce-gated'); - assert.ok( - !html.includes('</script><img'), - 'node reprs / retention path steps must not close the inline <script> early', - ); - assert.ok(html.includes('</script>'), 'the target type in the heading must be HTML-escaped'); - }); - - test('retention graph renders an honest empty state when the walk found nothing', () => { - const html = buildRefGraphHtml({ - targetType: 'Widget', maxDepth: 5, maxNodes: 200, script: '', graph: undefined, - }); - assert.ok( - html.includes('No reference graph data available'), - 'an empty walk must say so, never show a blank canvas', - ); - }); - - test('profiler webviews follow the editor theme instead of hardcoding a dark palette', () => { - const surfaces = [ - buildMemoryDashboardHtml(dashboardSnapshot()), - buildRefGraphHtml({ targetType: 'W', maxDepth: 5, maxNodes: 200, script: '' }), - buildFlamegraphHtml(profileResult()), - ]; - for (const html of surfaces) { - assert.ok( - html.includes('var(--vscode-editor-background'), - 'panel background must track the active VS Code theme', - ); - assert.ok( - html.includes('var(--vscode-editor-foreground'), - 'panel text must track the active VS Code theme', - ); - } - }); - - test('embedJson keeps a </script> payload inert inside an inline script', () => { - const embedded = embedJson({ name: HOSTILE }); - assert.ok(!embedded.includes('</script>'), 'embedJson must escape < so the script cannot be closed'); - const roundTripped: unknown = JSON.parse(embedded); - assert.strictEqual(stringField(roundTripped, 'name'), HOSTILE, 'escaping must not corrupt the payload'); - }); -}); - -// Implements [PROFILE-FLAMEGRAPH]. See docs/specs/LSP-PROFILING-SPEC.md#PROFILE-FLAMEGRAPH -suite('Profiler webviews — flame graph hero', () => { - let tmpDir = ''; - - suiteSetup(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'basilisk-flame-hero-')); - }); - - suiteTeardown(() => { - removeTestDir(tmpDir); - }); - - function writeSvg(name: string, contents: string): string { - const svgPath = path.join(tmpDir, name); - fs.writeFileSync(svgPath, contents); - return svgPath; - } - - test('the results panel embeds the LSP-exported flame graph SVG as its hero', () => { - const svgPath = writeSvg('profile.svg', '<svg xmlns="http://www.w3.org/2000/svg"><rect/></svg>'); - const html = buildFlamegraphHtml(profileResult({ flamegraphPath: svgPath })); - assert.ok( - html.includes('data:image/svg+xml;base64,'), - 'the flame graph SVG must be inlined as a data URI (a profiler must show a flame graph)', - ); - assert.ok( - html.includes('open-flame-svg'), - 'the hero must offer opening the interactive SVG externally', - ); - assert.ok(html.includes('openFlamegraphSvg'), 'the open action must post a message the extension handles'); - }); - - // [PROFILE-NATIVE] The built-in `.cpuprofile` viewer is on-demand, not the - // landing view — so the panel itself must carry the way into it. Without - // this button the raw trace is only reachable through the (dismissable) - // completion toast. - test('the results panel offers the raw trace via an "Open Trace in VS Code Viewer" button', () => { - const withTrace = buildFlamegraphHtml( - profileResult({ cpuProfilePath: '/tmp/basilisk-s-1.cpuprofile' }), - ); - assert.ok( - withTrace.includes('Open Trace in VS Code Viewer'), - 'the panel must offer opening the native .cpuprofile viewer', - ); - assert.ok( - withTrace.includes('openCpuProfile'), - 'the trace button must post a message the extension handles', - ); - - const withoutTrace = buildFlamegraphHtml(profileResult()); - assert.ok( - !withoutTrace.includes('/tmp/basilisk-s-1.cpuprofile'), - 'no stale trace path may be embedded when no .cpuprofile was produced', - ); - }); - - test('a missing or unreadable SVG degrades to the tables, never a broken image', () => { - const withoutPath = buildFlamegraphHtml(profileResult()); - const withDeadPath = buildFlamegraphHtml( - profileResult({ flamegraphPath: path.join(tmpDir, 'nope.svg') }), - ); - for (const html of [withoutPath, withDeadPath]) { - assert.ok(!html.includes('data:image/svg+xml'), 'no hero image without a readable SVG'); - assert.ok(!html.includes('<img'), 'no broken <img> element'); - assert.ok(html.includes('fn-body'), 'the hot-functions table must still render'); - } - }); - - test('loadFlamegraphSvgDataUri refuses empty and oversized artifacts', () => { - assert.strictEqual(loadFlamegraphSvgDataUri(undefined), undefined); - assert.strictEqual(loadFlamegraphSvgDataUri(''), undefined); - const empty = writeSvg('empty.svg', ''); - assert.strictEqual(loadFlamegraphSvgDataUri(empty), undefined, 'an empty artifact is not a flame graph'); - const oversized = writeSvg('huge.svg', `<svg>${'x'.repeat(5 * 1024 * 1024)}</svg>`); - assert.strictEqual( - loadFlamegraphSvgDataUri(oversized), undefined, - 'an oversized artifact must not be inlined (it still opens externally)', - ); - }); -}); - -// Implements [PROFILE-WEBVIEW-HOST] (once-bound message handler). -suite('Profiler webviews — singleton panel message handler', () => { - teardown(async () => { - await closeAllEditors(); - }); - - test('re-opening a panel re-renders but never stacks a second message handler', async function () { - this.timeout(30_000); - const received: WebviewMessage[] = []; - const panel = new SingletonWebviewPanel('basilisk.test.handlerOnce', (msg) => { - received.push(msg); - }); - try { - // Each render posts exactly one 'ready' message. With the pre-host - // bug (a handler re-registered per open), the second render's single - // post would be delivered TWICE — 3 messages total instead of 2. - function doc(marker: string): string { - return buildWebviewDocument({ - title: 'handler-once probe', - css: '', - body: `<p>${marker}</p>`, - script: `acquireVsCodeApi().postMessage({ type: 'ready', file: '${marker}' });`, - }); - } - panel.show('probe', doc('first')); - await pollUntilResult( - async () => received.length, - (count) => count >= 1, - ); - panel.show('probe', doc('second')); - await pollUntilResult( - async () => received.length, - (count) => count >= 2, - ); - // Give a stacked handler time to double-deliver before asserting. - await delay(1_000); - assert.strictEqual( - received.length, - 2, - `each render must deliver its message exactly once, got ${received.length} ` + - `(${received.map((m) => m.file ?? '?').join(', ')}) — a third delivery means stacked handlers`, - ); - assert.ok(panel.isOpen(), 'the singleton panel must stay open across re-renders'); - } finally { - panel.dispose(); - } - assert.ok(!panel.isOpen(), 'dispose must settle the panel closed'); - }); -}); diff --git a/vscode-extension/src/test/suite/profiler.test.ts b/vscode-extension/src/test/suite/profiler.test.ts deleted file mode 100644 index 095a59440..000000000 --- a/vscode-extension/src/test/suite/profiler.test.ts +++ /dev/null @@ -1,357 +0,0 @@ -// Implements [LSPPROF]. See docs/specs/LSP-PROFILING-SPEC.md#LSPPROF -/** - * Profiler E2E Tests — Command Registration, Configuration, Status Bar, Keybindings. - * - * Validates the profiling command registration and configuration: - * - CPU profiler commands are registered and callable - * - Profiler settings are read from configuration - * - Status bar item appears and responds to profiling state - * - Keybindings are declared - * - * These tests require the Basilisk LSP server binary to be built. - * They exercise the real LSP protocol, not mocks. - */ - -import * as assert from 'assert'; -import * as vscode from 'vscode'; -import { getStore } from '../../extension'; -import { arrayField, isRecord, rawField } from '../../unknown-shape'; -import { - EXTENSION_ID, - setupLspTestSuite, - teardownLspTestSuite, - closeAllEditors, -} from "./test-helpers"; - -import { - PROFILER_CLIENT_COMMANDS, - PROFILER_SERVER_COMMANDS, - PROFILER_SETTINGS, - PROFILER_EXTRA_SETTINGS -} from './profiler-test-constants'; -import { - manifestCommands, - manifestConfigurationProperties, - manifestKeybindings -} from "./extension-manifest"; - -let tmpDir = ''; - -function assertCommandRegistered(commandId: string, label: string): void { - let threw = false; - let disposable: vscode.Disposable | undefined; - try { - disposable = vscode.commands.registerCommand(commandId, () => { /* probe */ }); - } catch { - threw = true; - } finally { - disposable?.dispose(); - } - assert.ok(threw, `${label} "${commandId}" should be registered after activation`); -} - -suite('Profiler — Command Registration', () => { - suiteSetup(async function () { - const result = await setupLspTestSuite('basilisk-profiler-test-'); - tmpDir = result.tmpDir; - }); - - suiteTeardown(() => { - teardownLspTestSuite(tmpDir); - }); - - teardown(async () => { - await closeAllEditors(); - }); - - test('all profiler client commands are registered', () => { - for (const cmd of PROFILER_CLIENT_COMMANDS) { - assertCommandRegistered(cmd, 'Client command'); - } - }); - - test('profiler server commands are advertised by LSP', () => { - const store = getStore(); - assert.ok(store, 'Store should be initialized'); - - for (const cmd of PROFILER_SERVER_COMMANDS) { - assert.ok( - store.isServerCommandAdvertised(cmd), - `Server command "${cmd}" should be advertised by LSP`, - ); - } - }); - - test('profiler server commands are tracked in store.serverCommands', () => { - const store = getStore(); - assert.ok(store, 'Store should be initialized'); - assert.ok(store.serverCommands.value.size > 0, 'Server should advertise commands'); - - for (const cmd of PROFILER_SERVER_COMMANDS) { - assert.ok( - store.isServerCommandAdvertised(cmd), - `Server command "${cmd}" should be in store.serverCommands`, - ); - } - }); - - test('profiler client commands are NOT in store.serverCommands', () => { - const store = getStore(); - assert.ok(store, 'Store should be initialized'); - - for (const cmd of PROFILER_CLIENT_COMMANDS) { - assert.ok( - !store.isServerCommandAdvertised(cmd), - `Client command "${cmd}" must NOT appear in store.serverCommands`, - ); - } - }); - - test('profiler.list returns empty sessions initially', async () => { - const result = await vscode.commands.executeCommand( - 'basilisk.profiler.list', - ); - assert.ok(result !== undefined, 'profiler.list should return a result'); - assert.ok(result !== null, 'profiler.list should not return null'); - - assert.ok(Array.isArray(rawField(result, 'sessions')), 'sessions should be an array'); - assert.strictEqual( - arrayField(result, 'sessions').length, - 0, - 'no sessions should be active initially', - ); - }); - - test('profiler.list result has correct shape', async () => { - const result = await vscode.commands.executeCommand( - 'basilisk.profiler.list', - ); - assert.ok(result !== undefined, 'profiler.list must return a value'); - - assert.ok(isRecord(result), 'result must be an object'); - assert.ok('sessions' in result, 'result must have sessions key'); - assert.ok(Array.isArray(rawField(result, 'sessions')), 'sessions must be an array'); - }); -}); - -suite('Profiler — Configuration', () => { - suiteSetup(async function () { - const result = await setupLspTestSuite('basilisk-profiler-cfg-'); - tmpDir = result.tmpDir; - }); - - suiteTeardown(() => { - teardownLspTestSuite(tmpDir); - }); - - test('profiler settings have correct defaults', () => { - const config = vscode.workspace.getConfiguration('basilisk.profiler'); - - assert.strictEqual( - config.get<number>('sampleRate'), - 100, - 'default sample rate should be 100', - ); - assert.strictEqual( - config.get<boolean>('includeNative'), - false, - 'includeNative should default to false', - ); - assert.strictEqual( - config.get<number>('lineThreshold'), - 1.0, - 'lineThreshold should default to 1.0', - ); - assert.strictEqual( - config.get<number>('functionThreshold'), - 2.0, - 'functionThreshold should default to 2.0', - ); - assert.strictEqual( - config.get<number>('maxDiagnosticsPerFile'), - 20, - 'maxDiagnosticsPerFile should default to 20', - ); - assert.strictEqual( - config.get<boolean>('showInlineHeatMap'), - true, - 'showInlineHeatMap should default to true', - ); - }); - - test('profileOnLaunch defaults to false', () => { - const config = vscode.workspace.getConfiguration('basilisk.profiler'); - assert.strictEqual( - config.get<boolean>('profileOnLaunch'), - false, - 'profileOnLaunch should default to false', - ); - }); - - test('preset defaults to "default"', () => { - const config = vscode.workspace.getConfiguration('basilisk.profiler'); - assert.strictEqual( - config.get<string>('preset'), - 'default', - 'preset should default to "default"', - ); - }); - - test('profiler settings are declared in package.json', () => { - const properties = manifestConfigurationProperties(); - - for (const setting of PROFILER_SETTINGS) { - assert.ok( - properties[setting] !== undefined, - `Setting "${setting}" should be declared in package.json`, - ); - } - }); - - test('extra profiler settings are declared in package.json', () => { - const properties = manifestConfigurationProperties(); - - for (const setting of PROFILER_EXTRA_SETTINGS) { - assert.ok( - properties[setting] !== undefined, - `Setting "${setting}" should be declared in package.json`, - ); - } - }); - - test('profiler settings have correct types in package.json', () => { - const properties = manifestConfigurationProperties(); - - const expectedTypes: Record<string, string> = { - 'basilisk.profiler.sampleRate': 'number', - 'basilisk.profiler.includeNative': 'boolean', - 'basilisk.profiler.lineThreshold': 'number', - 'basilisk.profiler.functionThreshold': 'number', - 'basilisk.profiler.maxDiagnosticsPerFile': 'number', - 'basilisk.profiler.showInlineHeatMap': 'boolean', - 'basilisk.profiler.profileOnLaunch': 'boolean', - 'basilisk.profiler.preset': 'string', - }; - - for (const [key, expectedType] of Object.entries(expectedTypes)) { - const prop = properties[key] as { type?: string } | undefined; - assert.ok(prop !== undefined, `Property "${key}" must exist`); - assert.strictEqual( - prop.type, - expectedType, - `"${key}" should have type "${expectedType}", got "${String(prop.type)}"`, - ); - } - }); - - test('preset enum offers exactly the presets the server parses', () => { - const properties = manifestConfigurationProperties(); - const presetProp = properties['basilisk.profiler.preset'] as - { enum?: string[] } | undefined; - assert.ok(presetProp, 'preset property should exist'); - assert.ok(Array.isArray(presetProp.enum), 'preset should have enum values'); - - // Must mirror ProfilingPreset::parse_name (presets.rs) plus "default" - // (user-tuned sampleRate/includeNative). Advertising a name the server - // silently ignores is the preset:"memory" defect — never reintroduce one. - const expectedValues = ['default', 'quick', 'detailed', 'longRunning']; - assert.deepStrictEqual( - [...presetProp.enum].sort(), - [...expectedValues].sort(), - 'every advertised preset must be one the LSP actually parses', - ); - }); -}); - -suite('Profiler — Status Bar', () => { - suiteSetup(async function () { - const result = await setupLspTestSuite('basilisk-profiler-sb-'); - tmpDir = result.tmpDir; - }); - - suiteTeardown(() => { - teardownLspTestSuite(tmpDir); - }); - - teardown(async () => { - await closeAllEditors(); - }); - - test('profiler status bar item exists after activation', () => { - const store = getStore(); - assert.ok(store, 'Store should be initialized after activation'); - assert.ok( - store.lspState.value === 'running' || store.lspState.value === 'starting', - `LSP state should be running or starting, got: ${store.lspState.value}`, - ); - }); - - test('extension is active after profiler setup', () => { - const ext = vscode.extensions.getExtension(EXTENSION_ID); - assert.ok(ext, 'Extension should be found'); - assert.strictEqual(ext.isActive, true, 'Extension must be active'); - }); -}); - -suite('Profiler — Keybindings', () => { - suiteSetup(async function () { - const result = await setupLspTestSuite('basilisk-profiler-kb-'); - tmpDir = result.tmpDir; - }); - - suiteTeardown(() => { - teardownLspTestSuite(tmpDir); - }); - - test('profiler commands declared in keybindings', () => { - const extension = vscode.extensions.getExtension(EXTENSION_ID); - assert.ok(extension, 'Extension should be found'); - - const keybindings = manifestKeybindings(); - - const profilerKeybindings = keybindings.filter( - (kb) => kb.command.startsWith('basilisk.profile'), - ); - - assert.ok( - profilerKeybindings.length >= 2, - `Should have at least 2 profiler keybindings (start + stop), found ${profilerKeybindings.length}`, - ); - }); - - test('profiler commands appear in package.json commands section', () => { - const extension = vscode.extensions.getExtension(EXTENSION_ID); - assert.ok(extension, 'Extension should be found'); - - const commands = manifestCommands(); - const commandIds = commands.map((c) => c.command); - - for (const cmd of PROFILER_CLIENT_COMMANDS) { - assert.ok( - commandIds.includes(cmd), - `Command "${cmd}" should be in package.json contributes.commands`, - ); - } - }); - - test('profiler commands have titles and categories', () => { - const extension = vscode.extensions.getExtension(EXTENSION_ID); - assert.ok(extension, 'Extension should be found'); - - const commands = manifestCommands(); - - for (const cmd of PROFILER_CLIENT_COMMANDS) { - const entry = commands.find((c) => c.command === cmd); - assert.ok(entry, `Command entry for "${cmd}" should exist`); - assert.ok( - entry.title !== undefined && entry.title.length > 0, - `Command "${cmd}" should have a non-empty title`, - ); - assert.strictEqual( - entry.category, - 'Basilisk', - `Command "${cmd}" should have category "Basilisk"`, - ); - } - }); -}); diff --git a/vscode-extension/src/test/suite/screenshot.ts b/vscode-extension/src/test/suite/screenshot.ts deleted file mode 100644 index 8e9c63b4f..000000000 --- a/vscode-extension/src/test/suite/screenshot.ts +++ /dev/null @@ -1,131 +0,0 @@ -// Implements [VSIX]. See docs/specs/VSIX-SPEC.md#VSIX -/** - * Screenshot capture for Basilisk VS Code extension E2E tests. - * - * The integration suite runs in a *headed* Electron VS Code instance on the - * host (`@vscode/test-cli` / `@vscode/test-electron`). That lets us grab a - * picture of the editor — Python file open, Basilisk diagnostics squiggled, - * Problems panel — purely for *local* debugging. - * - * Hard rules (see CLAUDE.md → [GITHUB-NO-ARTIFACTS]): - * - Screenshots are written to a gitignored local folder ONLY. - * - They are NEVER committed and NEVER uploaded as CI artifacts. - * - * Capture is best-effort: a failure to screenshot must never fail a test, so - * every error is swallowed (and logged). On platforms without a screenshot - * tool, or in a headless CI run, capture is silently skipped. - */ - -import { delay } from '../../timeouts'; -import { execFile } from 'child_process'; -import * as fs from 'fs'; -import * as path from 'path'; - -/** - * Directory for the *committed* website editor screenshots. Resolves to - * `website/src/assets/images/` (`__dirname` is `out/test/suite`, four levels up - * is the repo root). - */ -function websiteImageDir(): string { - const configured = process.env.BASILISK_SCREENSHOT_OUTPUT_DIR; - if (configured !== undefined && configured.trim() !== '') { - return path.resolve(configured); - } - return path.resolve(__dirname, '..', '..', '..', '..', 'website', 'src', 'assets', 'images'); -} - -/** - * Capture the real VS Code window for the website via the CDP sidecar - * ([VSIX-EDITOR-SCREENSHOTS-PIPELINE], screenshot-watcher.mjs). Writes a `.signal` file - * into the website image dir and waits for the sidecar to produce the PNG, then - * renames it into place. - * - * No-op unless `BASILISK_SCREENSHOTS=1` — so normal test runs are unaffected and - * never write into the repo. Call after assertions prove the feature is visible. - * - * @param filename final PNG name, e.g. `vscode-diagnostics.png`. - */ -export async function takeWindowScreenshot(filename: string): Promise<void> { - if (process.env.BASILISK_SCREENSHOTS === undefined) { - return; - } - const dir = websiteImageDir(); - fs.mkdirSync(dir, { recursive: true }); - const tempFilename = `${filename}.tmp-${process.pid.toString()}.png`; - const tempPath = path.join(dir, tempFilename); - const signalPath = path.join(dir, `${tempFilename}.signal`); - const outPath = path.join(dir, filename); - if (fs.existsSync(tempPath)) { - fs.rmSync(tempPath, { force: true }); - } - fs.writeFileSync(signalPath, filename, 'utf8'); - - const deadline = Date.now() + 20_000; - while (Date.now() < deadline) { - if (fs.existsSync(tempPath)) { - fs.renameSync(tempPath, outPath); - // eslint-disable-next-line no-console - console.log(`[screenshot] wrote ${filename}`); - return; - } - await delay(100); - } - throw new Error(`screenshot sidecar did not produce ${filename} within 20s`); -} - -/** - * Directory where screenshots are written. Resolves to - * `vscode-extension/.screenshots/` (gitignored). `__dirname` is - * `out/test/suite`, so three levels up reaches the extension root. - */ -function screenshotDir(): string { - return path.resolve(__dirname, '..', '..', '..', '.screenshots'); -} - -/** Sanitise a label into a filesystem-safe filename stem. */ -function safeStem(label: string): string { - const cleaned = label.replace(/[^a-zA-Z0-9._-]+/g, '-').replace(/^-+|-+$/g, ''); - return cleaned.length > 0 ? cleaned : 'screenshot'; -} - -/** - * Capture a screenshot of the current screen to the gitignored - * `.screenshots/` folder. Best-effort: never throws, never fails a test. - * - * Only macOS is wired up (the dev/CI host for the VSIX suite). `screencapture` - * grabs the main display; the headed VS Code test window is frontmost. - * - * @param label human-readable stem for the file (timestamped to avoid clobber). - * @returns the written file path, or `undefined` if capture was skipped/failed. - */ -export async function captureScreenshot(label: string): Promise<string | undefined> { - if (process.platform !== 'darwin') { - return undefined; - } - - const dir = screenshotDir(); - try { - fs.mkdirSync(dir, { recursive: true }); - } catch { - return undefined; - } - - const stamp = new Date().toISOString().replace(/[:.]/g, '-'); - const outPath = path.join(dir, `${safeStem(label)}-${stamp}.png`); - - return new Promise<string | undefined>((resolve) => { - // -x: no capture sound. -o: omit window shadow. Full main-display grab - // is the most reliable mode for a headed test (no window-id lookup). - execFile('screencapture', ['-x', '-o', outPath], (error) => { - if (error !== null) { - // eslint-disable-next-line no-console - console.warn(`[screenshot] capture failed for "${label}": ${error.message}`); - resolve(undefined); - return; - } - // eslint-disable-next-line no-console - console.log(`[screenshot] wrote ${outPath}`); - resolve(outPath); - }); - }); -} diff --git a/vscode-extension/src/test/suite/screenshots-capture.test.ts b/vscode-extension/src/test/suite/screenshots-capture.test.ts deleted file mode 100644 index 9c346b92c..000000000 --- a/vscode-extension/src/test/suite/screenshots-capture.test.ts +++ /dev/null @@ -1,191 +0,0 @@ -// Implements [VSIX-EDITOR-SCREENSHOTS-SET]: the captured set — one test per -// committed vscode-*.png (diagnostics, hover, quick fix, module explorer, -// configuration editor). Each -// drives a Basilisk feature until it is visible, then asks the CDP sidecar -// (scripts/screenshot-watcher.mjs, [VSIX-EDITOR-SCREENSHOTS-PIPELINE]) to grab -// the window. -// -// The whole suite is a NO-OP unless BASILISK_SCREENSHOTS=1 (it skips in -// suiteSetup), so normal `npm test` runs are unaffected and nothing is written -// into the repo. Drive it with `npm run screenshots:editor`, which builds + -// stages the binary, copies shipwright.json, launches the sidecar, and runs only -// this file. See docs/specs/VSIX-EDITOR-SCREENSHOTS-SPEC.md. - -import { delay } from '../../timeouts'; -import * as vscode from 'vscode'; -import * as fs from 'fs'; -import * as os from 'os'; -import * as path from 'path'; - -import { - closeAllEditors, - findBasiliskBinary, - openPythonFile, - removeTestDir, - SUITE_SETUP_TIMEOUT_MS, - waitForDiagnostics, - waitForLspReady, -} from './test-helpers'; -import { takeWindowScreenshot } from './screenshot'; -import { ConfigurationEditorController } from '../../configuration-editor'; -import { getStore } from '../../extension'; - -// Strip transient chrome that clutters a marketing screenshot: the -// "--disable-extensions"/git toasts and the Chat auxiliary bar. -async function prepareWindow(): Promise<void> { - await vscode.commands.executeCommand('notifications.clearAll'); - await vscode.commands.executeCommand('workbench.action.closeAuxiliaryBar'); - await delay(400); -} - -// A file that triggers several distinct Basilisk diagnostics, so the editor and -// Problems panel look representative. -const DEMO_SOURCE = `def process(data): - return data.upper() - - -class User: - def __init__(self, name, age): - self.name = name - self.age = age -`; - -async function captureBookConfigurationPreview( - controller: ConfigurationEditorController, - store: NonNullable<ReturnType<typeof getStore>>, -): Promise<void> { - // [CONFIGEDITOR-MODEL]: one typed SetRule mutation — the only write shape. - await controller.receive({ - type: 'preview', - mutations: [{ kind: 'SetRule', code: 'BSK-0002', severity: { kind: 'Warning' } }], - }); - if (store.configurationEditor.value.preview === undefined) { - throw new Error('configuration editor did not render the real LSP preview'); - } - await prepareWindow(); - await delay(800); - await takeWindowScreenshot('09-configuration-preview-full.png'); -} - -suite('Editor screenshots', function () { - let tmpDir: string; - - suiteSetup(async function () { - this.timeout(SUITE_SETUP_TIMEOUT_MS); - if (process.env.BASILISK_SCREENSHOTS === undefined) { - this.skip(); - } - if (findBasiliskBinary() === undefined) { - throw new Error('Basilisk binary not found. Build with: cargo build -p basilisk-cli'); - } - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'basilisk-shots-')); - await waitForLspReady(); - await closeAllEditors(); - }); - - suiteTeardown(async () => { - await closeAllEditors(); - if (tmpDir !== undefined && tmpDir !== '' && fs.existsSync(tmpDir)) { - removeTestDir(tmpDir); - } - }); - - test('diagnostics + Problems panel', async function () { - this.timeout(60_000); - const { uri } = await openPythonFile(tmpDir, 'diagnostics.py', DEMO_SOURCE); - await waitForDiagnostics(uri); - // Surface the squiggles in the Problems panel for a complete picture. - await vscode.commands.executeCommand('workbench.actions.view.problems'); - await delay(1200); - await prepareWindow(); - await takeWindowScreenshot('vscode-diagnostics.png'); - await vscode.commands.executeCommand('workbench.action.closePanel'); - }); - - test('hover with type information', async function () { - this.timeout(60_000); - const { doc } = await openPythonFile( - tmpDir, - 'hover.py', - 'def greet(name: str) -> str:\n return f"Hello, {name}"\n', - ); - const editor = await vscode.window.showTextDocument(doc, { preview: false }); - // Position on the `greet` function name and request the hover popup. - const pos = new vscode.Position(0, 5); - editor.selection = new vscode.Selection(pos, pos); - editor.revealRange(new vscode.Range(pos, pos)); - await delay(500); - await vscode.commands.executeCommand('editor.action.showHover'); - await prepareWindow(); - await delay(300); - await vscode.commands.executeCommand('editor.action.showHover'); - await delay(1200); - await takeWindowScreenshot('vscode-hover.png'); - }); - - test('quick fix code actions', async function () { - this.timeout(60_000); - const { uri, doc } = await openPythonFile( - tmpDir, - 'quickfix.py', - 'def process(data):\n return data\n', - ); - await waitForDiagnostics(uri); - const editor = await vscode.window.showTextDocument(doc, { preview: false }); - const pos = new vscode.Position(0, 12); // on the unannotated `data` parameter - editor.selection = new vscode.Selection(pos, pos); - editor.revealRange(new vscode.Range(pos, pos)); - await delay(500); - await vscode.commands.executeCommand('editor.action.quickFix'); - await prepareWindow(); - await delay(300); - await vscode.commands.executeCommand('editor.action.quickFix'); - await delay(1200); - await takeWindowScreenshot('vscode-quickfix.png'); - await vscode.commands.executeCommand('hideSuggestWidget'); - }); - - test('module explorer activity panel', async function () { - this.timeout(60_000); - await openPythonFile(tmpDir, 'explorer.py', DEMO_SOURCE); - await vscode.commands.executeCommand('workbench.view.extension.basilisk-explorer'); - await delay(800); - await vscode.commands.executeCommand('basilisk.refreshModuleExplorer'); - await delay(1800); - await prepareWindow(); - await takeWindowScreenshot('vscode-module-explorer.png'); - }); - - test('configuration editor tag-first rules', async function () { - this.timeout(60_000); - await closeAllEditors(); - await vscode.commands.executeCommand('workbench.action.closeSidebar'); - const root = vscode.workspace.workspaceFolders?.[0]; - const store = getStore(); - if (root === undefined || store === undefined) { - throw new Error('real workspace and extension store are required for configuration capture'); - } - const controller = new ConfigurationEditorController(store); - try { - controller.open(root.uri.toString()); - const deadline = Date.now() + 10_000; - while (store.configurationEditor.value.phase !== 'ready' && Date.now() < deadline) { - await delay(50); - } - if (store.configurationEditor.value.phase !== 'ready') { - throw new Error('configuration editor did not receive a snapshot from the real LSP'); - } - await prepareWindow(); - await delay(1_200); - const bookCapture = process.env.BASILISK_BOOK_SCREENSHOTS !== undefined; - await takeWindowScreenshot( - bookCapture ? '09-configuration-editor-full.png' : 'vscode-configuration-editor.png', - ); - if (bookCapture) { - await captureBookConfigurationPreview(controller, store); - } - } finally { - controller.dispose(); - } - }); -}); diff --git a/vscode-extension/src/test/suite/settings-fixture.ts b/vscode-extension/src/test/suite/settings-fixture.ts deleted file mode 100644 index 43f8b8b9a..000000000 --- a/vscode-extension/src/test/suite/settings-fixture.ts +++ /dev/null @@ -1,82 +0,0 @@ -// Shared [LSPCFGED-TYPESHED] / [LSPCFGED-CACHE] wire fixtures for the -// configuration-editor suites — the Project view's two setting panels. -/** One source of truth for what the server sends, so no suite invents a shape. */ - -import type { - CacheConfigurationState, - TypeshedConfigurationState, - 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 storeFolder?: string | undefined; - readonly licenseAvailable?: boolean; - readonly downloading?: boolean; - readonly noSourceReason?: string; - readonly warnings?: TypeshedStatusState["warnings"]; -} - -function fixtureLifecycle(options: TypeshedFixtureOptions): TypeshedStatusState["lifecycle"] { - if (options.downloading === true) { return { kind: "Downloading" }; } - return options.noSourceReason === undefined ? { kind: "Ready" } : { kind: "NoSource" }; -} - -function fixtureStatus(options: TypeshedFixtureOptions): TypeshedStatusState { - const lifecycle = fixtureLifecycle(options); - const ready = lifecycle.kind === "Ready"; - return { - 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 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 source = options.source ?? { kind: "ExactCommit", commit: ACTIVE_COMMIT }; - return { - source, - // 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), - }; -} - -export interface CacheFixtureOptions { - readonly enabled?: boolean; - readonly folder?: string; - readonly folderConfigured?: boolean; - readonly trackedFiles?: number; -} - -/** - * The server's caching projection for one root ([LSPCFGED-CACHE]). The default - * is the out-of-the-box state: the persistent cache off at its default folder, - * with the in-session Salsa layer running as it always does. - */ -export function cacheFixture(options: CacheFixtureOptions = {}): CacheConfigurationState { - return { - persistent: { - enabled: options.enabled ?? false, - folder: options.folder ?? "/workspace/project/.basilisk/cache/check", - folderConfigured: options.folderConfigured ?? options.folder !== undefined, - }, - inSession: { trackedFiles: options.trackedFiles ?? 128 }, - }; -} diff --git a/vscode-extension/src/test/suite/store-reactivity.test.ts b/vscode-extension/src/test/suite/store-reactivity.test.ts deleted file mode 100644 index 258c9b552..000000000 --- a/vscode-extension/src/test/suite/store-reactivity.test.ts +++ /dev/null @@ -1,233 +0,0 @@ -// Tests for [EXTACT-REACTIVE-STATE]. See docs/specs/EXTENSION-ACTIVITY-PANEL-SPEC.md#EXTACT-REACTIVE-STATE -// -// Issue #58: the Modules panel must update automatically — no manual Refresh — -// when (a) the server reaches Running, (b) re-analysis completes -// (`basilisk/moduleChanged`), and (c) diagnostics change. Reactivity is -// centralized in the store as the `analysisRevision` signal; panels subscribe -// via a signals effect instead of hand-rolled polling. - -import { fakeLanguageClient } from "./test-helpers"; -import { delay } from "../../timeouts"; -import * as assert from "assert"; -import * as vscode from "vscode"; -import { State, type LanguageClient } from "vscode-languageclient/node"; -import { ModuleExplorerProvider, wireReactiveRefresh } from "../../module-explorer"; -import { createStore, type Store } from "../../store"; - -type StateListener = (event: { newState: State; oldState: State }) => void; -type NotificationListener = (value?: unknown) => void; - -/** A fake client that exposes its state + notification listeners to the test. */ -interface FakeClientHandles { - client: LanguageClient; - fireState: (state: State) => void; - fireNotification: (method: string, value?: unknown) => void; -} - -function fakeClient(typeshedStatuses: readonly unknown[] = []): FakeClientHandles { - const stateListeners: StateListener[] = []; - const notificationListeners = new Map<string, NotificationListener>(); - const client = fakeLanguageClient({ - isRunning: (): boolean => true, - onDidChangeState: (listener: StateListener): vscode.Disposable => { - stateListeners.push(listener); - return { dispose: (): undefined => undefined }; - }, - onNotification: (method: string, listener: NotificationListener): vscode.Disposable => { - notificationListeners.set(method, listener); - return { dispose: (): undefined => undefined }; - }, - sendRequest: async (): Promise<unknown> => ({ modules: [], workspace: undefined }), - initializeResult: { - capabilities: { experimental: { basilisk: { typeshedStatuses } } }, - }, - }); - return { - client, - fireState: (state: State): void => { - for (const listener of stateListeners) { - listener({ newState: state, oldState: State.Starting }); - } - }, - fireNotification: (method: string, value?: unknown): void => { - notificationListeners.get(method)?.(value); - }, - }; -} - -function storeWithFakeClient( - typeshedStatuses: readonly unknown[] = [], -): { store: Store; handles: FakeClientHandles } { - const store = createStore(); - const handles = fakeClient(typeshedStatuses); - store.setClient({ subscriptions: [] }, handles.client); - return { store, handles }; -} - -/** Avoid colliding with commands owned by the already-activated extension. */ -function withStubbedCommands(fn: () => void): void { - const original = vscode.commands.registerCommand; - (vscode.commands as { registerCommand: unknown }).registerCommand = (): vscode.Disposable => ({ - dispose: (): undefined => undefined, - }); - try { - fn(); - } finally { - (vscode.commands as { registerCommand: unknown }).registerCommand = original; - } -} - -suite("Centralized analysis reactivity (issue #58)", () => { - test("analysisRevision bumps when the server reaches Running", () => { - withStubbedCommands(() => { - const { store, handles } = storeWithFakeClient(); - const before = store.analysisRevision.value; - handles.fireState(State.Running); - assert.ok( - store.analysisRevision.value > before, - "reaching Running must bump analysisRevision so panels leave 'Waiting for analysis...'", - ); - }); - }); - - // Tests the client side of [EXTACT-LSP-COMMANDS-MODULE-CHANGED]: the - // `basilisk/moduleChanged` server notification bumps analysisRevision. - test("analysisRevision bumps on basilisk/moduleChanged", () => { - withStubbedCommands(() => { - const { store, handles } = storeWithFakeClient(); - handles.fireState(State.Running); - const before = store.analysisRevision.value; - handles.fireNotification("basilisk/moduleChanged"); - assert.ok( - store.analysisRevision.value > before, - "re-analysis completion must bump analysisRevision", - ); - }); - }); - - // [EXTACT-REACTIVE-STATE] / [LSPARCH-CONFIG]: applying a rule-severity change - // in the configuration editor rewrites every affected diagnostic's severity, - // so the Modules panel's health rollup is stale the moment the server - // confirms the change. `basilisk/configurationChanged` must therefore bump - // analysisRevision — relying on the debounced diagnostics listener alone - // leaves the panel rendering the PREVIOUS configuration's severities whenever - // the recheck republishes nothing the client can observe. - test("analysisRevision bumps on basilisk/configurationChanged", () => { - withStubbedCommands(() => { - const { store, handles } = storeWithFakeClient(); - handles.fireState(State.Running); - const before = store.analysisRevision.value; - handles.fireNotification("basilisk/configurationChanged", { - rootUri: "file:///workspace", - revision: "fnv1a64:0000000000000001", - }); - assert.ok( - store.analysisRevision.value > before, - "an applied configuration change must bump analysisRevision so the " - + "Modules panel stops showing the previous configuration's severities", - ); - }); - }); - - test("Modules panel refreshes automatically when analysisRevision bumps", async () => { - const { store } = storeWithFakeClient(); - const provider = new ModuleExplorerProvider(store); - try { - wireReactiveRefresh(store, provider); - - const fired = new Promise<void>((resolve) => { - const sub = provider.onDidChangeTreeData(() => { - sub.dispose(); - resolve(); - }); - }); - store.bumpAnalysisRevision(); - await fired; - } finally { - provider.dispose(); - } - }); - - test("diagnostics changes bump analysisRevision (debounced)", async function () { - this.timeout(10_000); - const { store } = storeWithFakeClient(); - const before = store.analysisRevision.value; - - // Drive a REAL diagnostics change through the VS Code API. - const collection = vscode.languages.createDiagnosticCollection("bsk-issue58-test"); - try { - collection.set(vscode.Uri.parse("untitled:issue58-test.py"), [ - new vscode.Diagnostic(new vscode.Range(0, 0, 0, 1), "issue58 probe"), - ]); - // The bump is debounced — poll briefly. - const deadline = Date.now() + 5000; - while (store.analysisRevision.value === before && Date.now() < deadline) { - await delay(100); - } - assert.ok( - store.analysisRevision.value > before, - "a diagnostics change must bump analysisRevision per EXTACT-HEALTH-REFRESH", - ); - } finally { - collection.dispose(); - } - }); -}); - -// Typeshed status is a distinct reactive channel from analysisRevision: it -// targets a single root rather than repainting every panel, so it lives in its -// own suite ([EXTACT-REACTIVE-STATE]). -suite("Typeshed status reactivity (issue #58)", () => { - test("Typeshed status changes refresh only the matching open root", () => { - withStubbedCommands(() => { - const { store, handles } = storeWithFakeClient(); - handles.fireState(State.Running); - store.beginConfigurationLoad("file:///workspace"); - handles.fireNotification("basilisk/typeshedStatusChanged", { - rootUri: "file:///other", - status: { - lifecycle: { kind: "Ready" }, licenseStatus: { kind: "Approved" }, warnings: [], - }, - }); - assert.strictEqual(store.typeshedStatuses.value.has("file:///other"), true); - assert.strictEqual(store.configurationEditor.value.refreshRequested, false); - handles.fireNotification("basilisk/typeshedStatusChanged", { - rootUri: "file:///workspace", - status: { - lifecycle: { kind: "Ready" }, licenseStatus: { kind: "Approved" }, warnings: [], - }, - }); - assert.strictEqual( - store.typeshedStatuses.value.get("file:///workspace")?.licenseStatus.kind, - "Approved", - ); - assert.strictEqual(store.configurationEditor.value.refreshRequested, true); - }); - }); - - test("Typeshed statuses seed from initialize and invalid notifications cannot replace them", () => { - withStubbedCommands(() => { - const initial = { - rootUri: "file:///workspace", - status: { - lifecycle: { kind: "NoSource" }, noSourceReason: "exact unavailable", - licenseStatus: { kind: "Changed" }, warnings: [], - }, - }; - const { store, handles } = storeWithFakeClient([initial]); - handles.fireState(State.Running); - assert.strictEqual( - store.typeshedStatuses.value.get("file:///workspace")?.licenseStatus.kind, - "Changed", - ); - handles.fireNotification("basilisk/typeshedStatusChanged", { - rootUri: "file:///workspace", status: { lifecycle: { kind: "invented" } }, - }); - assert.strictEqual( - store.typeshedStatuses.value.get("file:///workspace")?.noSourceReason, - "exact unavailable", - ); - }); - }); -}); - diff --git a/vscode-extension/src/test/suite/subprocess-mode.test.ts b/vscode-extension/src/test/suite/subprocess-mode.test.ts deleted file mode 100644 index 1fd0285d1..000000000 --- a/vscode-extension/src/test/suite/subprocess-mode.test.ts +++ /dev/null @@ -1,157 +0,0 @@ -// Tests for [VSIX]. See docs/specs/VSIX-SPEC.md#VSIX -// Consumes [CHKARCH-CLI-OUTPUT-FAILURES]. See -// docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-CLI-OUTPUT-FAILURES -/** - * Contract tests for the subprocess-mode parse boundary. - * - * Subprocess mode publishes whatever `basilisk check --output json` reports, so - * this boundary decides what the editor shows when the LSP is off. It had no - * tests, and two blind spots lived in the gap: an entry the CLI emits for a - * file it could not parse was dropped for lacking a rule code, and a run that - * exited 3 published nothing at all. Both are pinned here. - */ - -import * as assert from "assert"; -import * as fs from "fs"; -import * as os from "os"; -import * as path from "path"; -import * as vscode from "vscode"; -import { parseDiagnostics } from "../../subprocess-mode"; - -/** The docs host every coded diagnostic links to. */ -const DOCS_HOST = "https://www.basilisk-python.dev/errors/"; - -/** A document on disk, so `uri.fsPath` matches what the CLI reports. */ -async function scratchDocument(): Promise<vscode.TextDocument> { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), "basilisk-subproc-")); - const file = path.join(dir, "src.py"); - fs.writeFileSync(file, "x: int = 1\n"); - return vscode.workspace.openTextDocument(vscode.Uri.file(file)); -} - -/** One CLI JSON entry for `doc`, with `overrides` applied. */ -function entry(doc: vscode.TextDocument, overrides: Record<string, unknown>): string { - return JSON.stringify([ - { - code: "BSK-0001", - severity: "error", - message: "Missing parameter type annotation for `x`", - path: doc.uri.fsPath, - line: 2, - col: 3, - end_line: 2, - end_col: 7, - ...overrides, - }, - ]); -} - -suite("Subprocess mode report parsing [VSIX]", () => { - let doc: vscode.TextDocument; - - suiteSetup(async () => { - doc = await scratchDocument(); - }); - - test("a coded diagnostic keeps its code, label and docs link", () => { - const [diag] = parseDiagnostics(entry(doc, {}), doc); - assert.ok(diag, "a matching entry must produce a diagnostic"); - assert.strictEqual( - diag.message, - "Missing parameter type annotation for `x` [BSK-0001]", - "the label carries the code in brackets", - ); - assert.strictEqual(diag.severity, vscode.DiagnosticSeverity.Error, "error maps to Error"); - assert.strictEqual(diag.source, "basilisk", "the diagnostic is attributed to Basilisk"); - assert.strictEqual(diag.range.start.line, 1, "line is converted to 0-based"); - assert.strictEqual(diag.range.start.character, 2, "column is converted to 0-based"); - assert.strictEqual(diag.range.end.line, 1, "the end line is converted too"); - assert.strictEqual(diag.range.end.character, 6, "the end column is converted too"); - assert.ok(typeof diag.code === "object", "a coded diagnostic links to its docs page"); - assert.strictEqual(diag.code.value, "BSK-0001", "the code is carried verbatim"); - assert.strictEqual( - diag.code.target.toString(), - `${DOCS_HOST}BSK-0001`, - "the target is that code's page", - ); - }); - - // The CLI reports a file it could not parse with a null code, because no rule - // produced the entry. Requiring a code dropped it, so the editor stayed clean - // for a file that never got checked. - test("a file the CLI could not parse is published, not dropped", () => { - const report = entry(doc, { - code: null, - message: `syntax error in ${doc.uri.fsPath}: Expected \`:\`, found newline`, - line: 1, - col: 1, - end_line: 1, - end_col: 1, - }); - const diagnostics = parseDiagnostics(report, doc); - assert.strictEqual(diagnostics.length, 1, "the parse failure must reach the editor"); - const [diag] = diagnostics; - assert.ok(diag, "the parse failure must produce a diagnostic"); - assert.ok(diag.message.includes("syntax error"), "the message says why the file failed"); - assert.ok(!diag.message.includes("[null]"), "a missing code must not render as [null]"); - assert.ok(!diag.message.includes("undefined"), "a missing code must not render as undefined"); - assert.strictEqual(diag.severity, vscode.DiagnosticSeverity.Error, "a failure is an error"); - assert.strictEqual(diag.source, "basilisk", "the failure is attributed to Basilisk"); - assert.strictEqual(diag.code, undefined, "no rule ran, so no code is claimed"); - assert.strictEqual(diag.range.start.line, 0, "the failure anchors at the first line"); - assert.strictEqual(diag.range.start.character, 0, "the failure anchors at the first column"); - }); - - test("severity that is not error is reported as a warning", () => { - const [diag] = parseDiagnostics(entry(doc, { severity: "warning" }), doc); - assert.ok(diag, "a warning entry must produce a diagnostic"); - assert.strictEqual(diag.severity, vscode.DiagnosticSeverity.Warning, "warning maps to Warning"); - }); - - test("entries for other files never leak into this document", () => { - const report = entry(doc, { path: path.join(os.tmpdir(), "someone-elses.py") }); - assert.deepStrictEqual(parseDiagnostics(report, doc), [], "another file's entry is filtered"); - }); - - test("a malformed payload degrades to nothing, never to a throw", () => { - assert.deepStrictEqual(parseDiagnostics("", doc), [], "empty output yields no diagnostics"); - assert.deepStrictEqual(parseDiagnostics("not json", doc), [], "garbage yields no diagnostics"); - assert.deepStrictEqual(parseDiagnostics("[]", doc), [], "a clean run yields no diagnostics"); - assert.deepStrictEqual(parseDiagnostics("{}", doc), [], "a non-array payload is rejected"); - assert.deepStrictEqual(parseDiagnostics("null", doc), [], "a null payload is rejected"); - assert.deepStrictEqual(parseDiagnostics("[1, 2, 3]", doc), [], "scalar entries are rejected"); - }); - - test("an entry missing a required field is dropped rather than guessed", () => { - for (const field of ["message", "path", "line", "col", "end_line", "end_col"]) { - const report = entry(doc, { [field]: undefined }); - assert.deepStrictEqual( - parseDiagnostics(report, doc), - [], - `an entry without "${field}" must be dropped`, - ); - } - }); - - test("a field of the wrong type is dropped rather than coerced", () => { - assert.deepStrictEqual(parseDiagnostics(entry(doc, { line: "2" }), doc), [], "line must be a number"); - assert.deepStrictEqual(parseDiagnostics(entry(doc, { message: 7 }), doc), [], "message must be a string"); - assert.deepStrictEqual(parseDiagnostics(entry(doc, { path: 7 }), doc), [], "path must be a string"); - }); - - test("several entries are published in the order the CLI reported them", () => { - const report = JSON.stringify([ - { code: "BSK-0001", severity: "error", message: "first", path: doc.uri.fsPath, line: 1, col: 1, end_line: 1, end_col: 2 }, - { code: null, severity: "error", message: "syntax error in src.py", path: doc.uri.fsPath, line: 1, col: 1, end_line: 1, end_col: 1 }, - { code: "BSK-0002", severity: "warning", message: "third", path: doc.uri.fsPath, line: 3, col: 1, end_line: 3, end_col: 2 }, - ]); - const diagnostics = parseDiagnostics(report, doc); - assert.strictEqual(diagnostics.length, 3, "every entry for this file is published"); - assert.ok(diagnostics[0]?.message.startsWith("first"), "the first entry stays first"); - assert.ok(diagnostics[1]?.message.includes("syntax error"), "the failure keeps its place"); - assert.ok(diagnostics[2]?.message.startsWith("third"), "the last entry stays last"); - assert.strictEqual(diagnostics[1]?.code, undefined, "only the failure lacks a code"); - assert.ok(diagnostics[0]?.code !== undefined, "coded entries keep their code"); - assert.ok(diagnostics[2]?.code !== undefined, "coded entries keep their code"); - }); -}); diff --git a/vscode-extension/src/test/suite/test-explorer.test.ts b/vscode-extension/src/test/suite/test-explorer.test.ts deleted file mode 100644 index f45f0b035..000000000 --- a/vscode-extension/src/test/suite/test-explorer.test.ts +++ /dev/null @@ -1,945 +0,0 @@ -// Implements [LSPTEST]. See docs/specs/LSP-TEST-INTEGRATION-SPEC.md#LSPTEST -/** - * Test Explorer E2E Tests for the Basilisk VS Code Extension. - * - * Validates: - * - Test commands are advertised by the LSP server - * - testExplorer settings are contributed in package.json - * - Test discovery populates TestController items from LSP - * - Discovery returns correct test structure (file > class > method) - * - Scoped single-file discovery returns exact items - * - Multiple test files are discovered independently - * - Settings forwarding and enum validation work correctly - * - Test execution returns structured per-test results - * - Server-advertised vs client-registered command distinction - */ - -import { delay } from "../../timeouts"; -import * as assert from "assert"; -import * as vscode from "vscode"; -import * as path from "path"; -import * as fs from "fs"; -import { type LanguageClient } from "vscode-languageclient/node"; -import { getStore } from "../../extension"; -import { - WAIT_MS, - setupLspTestSuite, - teardownLspTestSuite, - pollUntilResult, - closeAllEditors, -} from "./test-helpers"; -import { arrayField, booleanField, numberField, rawField, stringField } from "../../unknown-shape"; - -/** Shape of a test item received from the LSP server. */ -interface LspTestItem { - name: string; - id: string; - file: string; - line: number; - kind: string; - children: LspTestItem[]; -} - -/** Whether `value` is an array, without claiming anything about its elements. */ -function isUnknownArray(value: unknown): value is unknown[] { - return Array.isArray(value); -} - -/** - * Read one discovered test item off the wire. - * - * The reply is checked field by field rather than asserted into `LspTestItem`: - * an item missing `id` or `line` fails here, naming what the server left out, - * instead of reaching the assertions below as an `undefined` they compare away. - */ -function narrowTestItem(raw: unknown): LspTestItem { - const name = stringField(raw, "name"); - const id = stringField(raw, "id"); - const file = stringField(raw, "file"); - const line = numberField(raw, "line"); - const kind = stringField(raw, "kind"); - assert.ok( - name !== undefined && id !== undefined && file !== undefined - && line !== undefined && kind !== undefined, - "a discovered test item carries name, id, file, line and kind", - ); - return { name, id, file, line, kind, children: arrayField(raw, "children").map(narrowTestItem) }; -} - -/** - * Assert the two fields every `runTests` reply must carry. - * - * The `typeof` checks at the call sites were the only thing proving the server - * sent them, so they move here and the reply is never cast. - */ -function assertRunResultShape(result: unknown): void { - assert.ok(booleanField(result, "passed") !== undefined, "Result should have passed boolean"); - assert.ok(numberField(result, "exitCode") !== undefined, "Result should have exitCode number"); -} - -/** Helper: write a test file and return its path. */ -function writeTestFile(dir: string, name: string, content: string): string { - const filePath = path.join(dir, name); - fs.writeFileSync(filePath, content, "utf8"); - return filePath; -} - -/** Helper: clean up test files, ignoring errors. */ -function cleanupFiles(...paths: string[]): void { - for (const p of paths) { - try { fs.unlinkSync(p); } catch { /* ignore */ } - } -} - -/** Helper: get the LSP client, asserting it exists. */ -function requireClient(): LanguageClient { - const store = getStore(); - assert.ok(store, "Store should exist"); - const client = store.client.value; - assert.ok(client, "LSP client should be running"); - return client; -} - -/** Helper: get the workspace root path, asserting it exists. */ -function requireWorkspaceRoot(): string { - const wsRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; - assert.ok(wsRoot, "Workspace root should exist"); - return wsRoot; -} - -/** Helper: discover tests via the LSP command. */ -async function discoverTests( - args: unknown[] = [] -): Promise<{ items: LspTestItem[] }> { - const client = requireClient(); - const result = await client.sendRequest("workspace/executeCommand", { - command: "basilisk.discoverTests", - arguments: args, - }); - assert.ok(result, "discoverTests should return a result"); - const items = rawField(result, "items"); - assert.ok(isUnknownArray(items), "result should have items array"); - return { items: items.map(narrowTestItem) }; -} - -// Tests [VSIX-TEST-EXPLORER-INTEGRATION] — the VS Code Test Explorer wiring -// (TestController, discovery, execution) over the real LSP. -// eslint-disable-next-line max-lines-per-function -suite("Basilisk Test Explorer E2E Tests", function () { - - let context: { tmpDir: string; basiliskBinary: string }; - - suiteSetup(async function () { - context = await setupLspTestSuite("test-explorer"); - - const store = getStore(); - assert.ok(store, "Store should exist after activation"); - const result = await store.ensureLspReadyPromise(WAIT_MS); - assert.ok(result.ok, "LSP should be running"); - }); - - suiteTeardown(async function () { - await teardownLspTestSuite(context?.tmpDir); - }); - - teardown(async () => { - await closeAllEditors(); - }); - - // ── Command Advertisement ────────────────────────────────────────── - // Exercises [LSPTEST-LSP-PROTOCOL-COMMANDS] — discoverTests/runTests/runTestFile/debugTest advertised. - - test("LSP server advertises basilisk.discoverTests command", () => { - const store = getStore(); - assert.ok(store, "Store should exist"); - assert.ok( - store.isServerCommandAdvertised("basilisk.discoverTests"), - "Server should advertise basilisk.discoverTests" - ); - }); - - test("LSP server advertises basilisk.runTests command", () => { - const store = getStore(); - assert.ok(store, "Store should exist"); - assert.ok( - store.isServerCommandAdvertised("basilisk.runTests"), - "Server should advertise basilisk.runTests" - ); - }); - - test("LSP server advertises basilisk.runTestFile command", () => { - const store = getStore(); - assert.ok(store, "Store should exist"); - assert.ok( - store.isServerCommandAdvertised("basilisk.runTestFile"), - "Server should advertise basilisk.runTestFile" - ); - }); - - test("LSP server advertises basilisk.debugTest command", () => { - const store = getStore(); - assert.ok(store, "Store should exist"); - assert.ok( - store.isServerCommandAdvertised("basilisk.debugTest"), - "Server should advertise basilisk.debugTest" - ); - }); - - // ── All Test Commands Are Distinct from Client Commands ──────────── - - test("Test commands are server-advertised, not client-registered", () => { - const store = getStore(); - assert.ok(store, "Store should exist"); - - const testCommands = [ - "basilisk.discoverTests", - "basilisk.runTests", - "basilisk.runTestFile", - "basilisk.debugTest", - ]; - - for (const cmd of testCommands) { - assert.ok( - store.isServerCommandAdvertised(cmd), - `${cmd} should be server-advertised` - ); - assert.ok( - !store.isClientCommandRegistered(cmd), - `${cmd} should NOT be client-registered (server commands are never pre-registered)` - ); - } - }); - - // ── Settings Defaults ────────────────────────────────────────────── - // Exercises [LSPTEST-CONFIGURATION-SETTINGS] — default values for the testExplorer.* settings. - - test("testExplorer.enabled setting defaults to true", () => { - const cfg = vscode.workspace.getConfiguration("basilisk"); - assert.strictEqual(cfg.get<boolean>("testExplorer.enabled"), true); - }); - - test("testExplorer.framework setting defaults to auto", () => { - const cfg = vscode.workspace.getConfiguration("basilisk"); - assert.strictEqual(cfg.get<string>("testExplorer.framework"), "auto"); - }); - - test("testExplorer.autoDiscoverOnSave setting defaults to true", () => { - const cfg = vscode.workspace.getConfiguration("basilisk"); - assert.strictEqual(cfg.get<boolean>("testExplorer.autoDiscoverOnSave"), true); - }); - - test("testExplorer.pytestPath setting defaults to pytest", () => { - const cfg = vscode.workspace.getConfiguration("basilisk"); - assert.strictEqual(cfg.get<string>("testExplorer.pytestPath"), "pytest"); - }); - - test("testExplorer.args setting defaults to empty array", () => { - const cfg = vscode.workspace.getConfiguration("basilisk"); - const args = cfg.get<string[]>("testExplorer.args"); - assert.ok(Array.isArray(args), "testExplorer.args should be an array"); - assert.strictEqual(args?.length, 0, "testExplorer.args should default to empty"); - }); - - test("testExplorer.useUvRun setting defaults to true", () => { - const cfg = vscode.workspace.getConfiguration("basilisk"); - assert.strictEqual(cfg.get<boolean>("testExplorer.useUvRun"), true); - }); - - // ── Settings Enum Validation ─────────────────────────────────────── - - test("testExplorer.framework accepts pytest value", async function () { - const cfg = vscode.workspace.getConfiguration("basilisk"); - await cfg.update("testExplorer.framework", "pytest", vscode.ConfigurationTarget.Workspace); - assert.strictEqual(vscode.workspace.getConfiguration("basilisk").get<string>("testExplorer.framework"), "pytest"); - await cfg.update("testExplorer.framework", undefined, vscode.ConfigurationTarget.Workspace); - }); - - test("testExplorer.framework accepts unittest value", async function () { - const cfg = vscode.workspace.getConfiguration("basilisk"); - await cfg.update("testExplorer.framework", "unittest", vscode.ConfigurationTarget.Workspace); - assert.strictEqual(vscode.workspace.getConfiguration("basilisk").get<string>("testExplorer.framework"), "unittest"); - await cfg.update("testExplorer.framework", undefined, vscode.ConfigurationTarget.Workspace); - }); - - test("testExplorer.pytestPath can be overridden", async function () { - const cfg = vscode.workspace.getConfiguration("basilisk"); - await cfg.update("testExplorer.pytestPath", "/custom/pytest", vscode.ConfigurationTarget.Workspace); - assert.strictEqual(vscode.workspace.getConfiguration("basilisk").get<string>("testExplorer.pytestPath"), "/custom/pytest"); - await cfg.update("testExplorer.pytestPath", undefined, vscode.ConfigurationTarget.Workspace); - }); - - test("testExplorer.args can be set to custom arguments", async function () { - const cfg = vscode.workspace.getConfiguration("basilisk"); - await cfg.update("testExplorer.args", ["-v", "--tb=long"], vscode.ConfigurationTarget.Workspace); - const args = vscode.workspace.getConfiguration("basilisk").get<string[]>("testExplorer.args"); - assert.deepStrictEqual(args, ["-v", "--tb=long"]); - await cfg.update("testExplorer.args", undefined, vscode.ConfigurationTarget.Workspace); - }); - - test("testExplorer.useUvRun can be disabled", async function () { - const cfg = vscode.workspace.getConfiguration("basilisk"); - await cfg.update("testExplorer.useUvRun", false, vscode.ConfigurationTarget.Workspace); - assert.strictEqual(vscode.workspace.getConfiguration("basilisk").get<boolean>("testExplorer.useUvRun"), false); - await cfg.update("testExplorer.useUvRun", undefined, vscode.ConfigurationTarget.Workspace); - }); - - test("testExplorer.enabled can be disabled", async function () { - const cfg = vscode.workspace.getConfiguration("basilisk"); - await cfg.update("testExplorer.enabled", false, vscode.ConfigurationTarget.Workspace); - assert.strictEqual(vscode.workspace.getConfiguration("basilisk").get<boolean>("testExplorer.enabled"), false); - await cfg.update("testExplorer.enabled", undefined, vscode.ConfigurationTarget.Workspace); - }); - - test("testExplorer.autoDiscoverOnSave can be disabled", async function () { - const cfg = vscode.workspace.getConfiguration("basilisk"); - await cfg.update("testExplorer.autoDiscoverOnSave", false, vscode.ConfigurationTarget.Workspace); - assert.strictEqual(vscode.workspace.getConfiguration("basilisk").get<boolean>("testExplorer.autoDiscoverOnSave"), false); - await cfg.update("testExplorer.autoDiscoverOnSave", undefined, vscode.ConfigurationTarget.Workspace); - }); - - // ── Test Discovery: Workspace ────────────────────────────────────── - // Exercises [LSPTEST-TEST-DISCOVERY] + [LSPTEST-SUPPORTED-FRAMEWORKS] end-to-end through the LSP. - - test("discoverTests returns items for workspace with test files", async function () { - const wsRoot = requireWorkspaceRoot(); - const testFilePath = writeTestFile( - wsRoot, - "test_discovery_e2e.py", - "def test_hello() -> None:\n assert True\n\ndef test_world() -> None:\n assert True\n" - ); - - try { - const result = await discoverTests(); - assert.ok(result.items.length >= 0, "discoverTests should succeed"); - } finally { - cleanupFiles(testFilePath); - } - }); - - // ── Test Discovery: Single File Scoped ───────────────────────────── - - test("discoverTests with URI scopes to single file", async function () { - const wsRoot = requireWorkspaceRoot(); - const testFilePath = writeTestFile( - wsRoot, - "test_scoped_e2e.py", - "def test_scoped() -> None:\n pass\n" - ); - - const doc = await vscode.workspace.openTextDocument(testFilePath); - await vscode.window.showTextDocument(doc); - - try { - const uri = vscode.Uri.file(testFilePath).toString(); - const result = await discoverTests([{ uri }]); - assert.ok(Array.isArray(result.items), "result should have items array"); - } finally { - cleanupFiles(testFilePath); - } - }); - - // ── Test Discovery: Pytest Functions ─────────────────────────────── - - test("discovery finds pytest functions with correct structure", async function () { - const wsRoot = requireWorkspaceRoot(); - const testFilePath = writeTestFile( - wsRoot, - "test_func_structure.py", - [ - "def test_alpha() -> None:", - " assert True", - "", - "def test_beta() -> None:", - " assert 1 + 1 == 2", - "", - "def helper_not_a_test() -> None:", - " pass", - "", - ].join("\n") - ); - - const doc = await vscode.workspace.openTextDocument(testFilePath); - await vscode.window.showTextDocument(doc); - - try { - const uri = vscode.Uri.file(testFilePath).toString(); - const result = await discoverTests([{ uri }]); - - // Should find exactly 2 test functions (helper_not_a_test is not a test). - const testNames = result.items.map((item) => item.name); - assert.ok( - testNames.includes("test_alpha"), - `Should find test_alpha, got: ${testNames.join(", ")}` - ); - assert.ok( - testNames.includes("test_beta"), - `Should find test_beta, got: ${testNames.join(", ")}` - ); - assert.ok( - !testNames.includes("helper_not_a_test"), - "Should NOT include helper_not_a_test" - ); - - // Verify item structure. - for (const item of result.items) { - assert.ok(item.id, "Each item should have an id"); - assert.ok(item.file, "Each item should have a file path"); - assert.ok(typeof item.line === "number", "Each item should have a line number"); - assert.ok(item.kind, "Each item should have a kind"); - } - } finally { - cleanupFiles(testFilePath); - } - }); - - // ── Test Discovery: Test Class with Methods ──────────────────────── - // Exercises [LSPTEST-TEST-ITEM-DATA-MODEL-HIERARCHY] — File > Class > Method nesting and kinds. - - test("discovery finds test class with child methods", async function () { - const wsRoot = requireWorkspaceRoot(); - const testFilePath = writeTestFile( - wsRoot, - "test_class_structure.py", - [ - "class TestCalculator:", - " def test_add(self) -> None:", - " assert 1 + 1 == 2", - "", - " def test_subtract(self) -> None:", - " assert 3 - 1 == 2", - "", - " def helper_setup(self) -> None:", - " pass", - "", - ].join("\n") - ); - - const doc = await vscode.workspace.openTextDocument(testFilePath); - await vscode.window.showTextDocument(doc); - - try { - const uri = vscode.Uri.file(testFilePath).toString(); - const result = await discoverTests([{ uri }]); - - // Find the class item. - const classItem = result.items.find((item) => item.name === "TestCalculator"); - assert.ok(classItem, "Should find TestCalculator class"); - assert.strictEqual(classItem.kind, "class", "TestCalculator should be kind 'class'"); - - // Verify child methods. - const childNames = classItem.children.map((c) => c.name); - assert.ok(childNames.includes("test_add"), "Should find test_add method"); - assert.ok(childNames.includes("test_subtract"), "Should find test_subtract method"); - assert.ok(!childNames.includes("helper_setup"), "Should NOT include helper_setup"); - - // Verify method items have correct kind. - for (const child of classItem.children) { - assert.strictEqual(child.kind, "method", `${child.name} should be kind 'method'`); - } - } finally { - cleanupFiles(testFilePath); - } - }); - - // ── Test Discovery: unittest.TestCase ────────────────────────────── - // Exercises [LSPTEST-SUPPORTED-FRAMEWORKS] — unittest.TestCase subclass detection. - - test("discovery finds unittest.TestCase subclass with methods", async function () { - const wsRoot = requireWorkspaceRoot(); - const testFilePath = writeTestFile( - wsRoot, - "test_unittest_class.py", - [ - "import unittest", - "", - "class TestStringMethods(unittest.TestCase):", - " def test_upper(self) -> None:", - " self.assertEqual('foo'.upper(), 'FOO')", - "", - " def test_isupper(self) -> None:", - " self.assertTrue('FOO'.isupper())", - "", - ].join("\n") - ); - - const doc = await vscode.workspace.openTextDocument(testFilePath); - await vscode.window.showTextDocument(doc); - - try { - const uri = vscode.Uri.file(testFilePath).toString(); - const result = await discoverTests([{ uri }]); - - const classItem = result.items.find((item) => item.name === "TestStringMethods"); - assert.ok(classItem, "Should find TestStringMethods class"); - assert.ok( - classItem.children.length >= 2, - `Should have at least 2 test methods, got ${classItem.children.length}` - ); - - const methodNames = classItem.children.map((c) => c.name); - assert.ok(methodNames.includes("test_upper"), "Should find test_upper"); - assert.ok(methodNames.includes("test_isupper"), "Should find test_isupper"); - } finally { - cleanupFiles(testFilePath); - } - }); - - // ── Test Discovery: Mixed Functions and Classes ──────────────────── - - test("discovery finds both free functions and class methods", async function () { - const wsRoot = requireWorkspaceRoot(); - const testFilePath = writeTestFile( - wsRoot, - "test_mixed_e2e.py", - [ - "def test_standalone() -> None:", - " pass", - "", - "class TestGroup:", - " def test_in_class(self) -> None:", - " pass", - "", - ].join("\n") - ); - - const doc = await vscode.workspace.openTextDocument(testFilePath); - await vscode.window.showTextDocument(doc); - - try { - const uri = vscode.Uri.file(testFilePath).toString(); - const result = await discoverTests([{ uri }]); - - const names = result.items.map((item) => item.name); - assert.ok(names.includes("test_standalone"), "Should find standalone function"); - assert.ok(names.includes("TestGroup"), "Should find TestGroup class"); - - const classItem = result.items.find((item) => item.name === "TestGroup"); - assert.ok(classItem, "TestGroup should exist"); - const childNames = classItem.children.map((c) => c.name); - assert.ok(childNames.includes("test_in_class"), "Should find test_in_class method"); - } finally { - cleanupFiles(testFilePath); - } - }); - - // ── Test Discovery: Line Numbers ─────────────────────────────────── - - test("discovery reports correct line numbers for test items", async function () { - const wsRoot = requireWorkspaceRoot(); - const testFilePath = writeTestFile( - wsRoot, - "test_line_numbers.py", - [ - "def test_first() -> None:", // line 0 - " pass", // line 1 - "", // line 2 - "def test_second() -> None:", // line 3 - " pass", // line 4 - "", // line 5 - "def test_third() -> None:", // line 6 - " pass", // line 7 - ].join("\n") - ); - - const doc = await vscode.workspace.openTextDocument(testFilePath); - await vscode.window.showTextDocument(doc); - - try { - const uri = vscode.Uri.file(testFilePath).toString(); - const result = await discoverTests([{ uri }]); - - const first = result.items.find((item) => item.name === "test_first"); - const second = result.items.find((item) => item.name === "test_second"); - const third = result.items.find((item) => item.name === "test_third"); - - assert.ok(first, "Should find test_first"); - assert.ok(second, "Should find test_second"); - assert.ok(third, "Should find test_third"); - - // Lines are 0-based. - assert.strictEqual(first.line, 0, "test_first should be on line 0"); - assert.strictEqual(second.line, 3, "test_second should be on line 3"); - assert.strictEqual(third.line, 6, "test_third should be on line 6"); - } finally { - cleanupFiles(testFilePath); - } - }); - - // ── Test Discovery: Empty File ───────────────────────────────────── - - test("discovery returns empty items for file with no tests", async function () { - const wsRoot = requireWorkspaceRoot(); - const testFilePath = writeTestFile( - wsRoot, - "test_empty_e2e.py", - "# This file has no test functions\nx = 42\n" - ); - - const doc = await vscode.workspace.openTextDocument(testFilePath); - await vscode.window.showTextDocument(doc); - - try { - const uri = vscode.Uri.file(testFilePath).toString(); - const result = await discoverTests([{ uri }]); - assert.strictEqual(result.items.length, 0, "File with no tests should return empty items"); - } finally { - cleanupFiles(testFilePath); - } - }); - - // ── Test Discovery: Non-test File ────────────────────────────────── - - test("discovery returns empty for non-test file pattern", async function () { - const wsRoot = requireWorkspaceRoot(); - // File does not match test_*.py or *_test.py. - const filePath = writeTestFile( - wsRoot, - "helper_utils.py", - "def test_lookalike() -> None:\n pass\n" - ); - - const doc = await vscode.workspace.openTextDocument(filePath); - await vscode.window.showTextDocument(doc); - - try { - const uri = vscode.Uri.file(filePath).toString(); - const result = await discoverTests([{ uri }]); - // The LSP discovers based on AST content when given a URI, but the file - // won't be picked up by workspace-level discovery since it doesn't - // match the test file naming pattern. - // With explicit URI it may still parse, which is correct behavior. - assert.ok(Array.isArray(result.items), "Should return items array"); - } finally { - cleanupFiles(filePath); - } - }); - - // ── Test Discovery: *_test.py Naming Convention ──────────────────── - - test("discovery finds tests in files matching *_test.py convention", async function () { - const wsRoot = requireWorkspaceRoot(); - const testFilePath = writeTestFile( - wsRoot, - "calculator_test.py", - "def test_addition() -> None:\n assert 1 + 1 == 2\n" - ); - - const doc = await vscode.workspace.openTextDocument(testFilePath); - await vscode.window.showTextDocument(doc); - - try { - const uri = vscode.Uri.file(testFilePath).toString(); - const result = await discoverTests([{ uri }]); - const names = result.items.map((item) => item.name); - assert.ok(names.includes("test_addition"), "Should find test_addition in *_test.py file"); - } finally { - cleanupFiles(testFilePath); - } - }); - - // ── Test Discovery: Test IDs ─────────────────────────────────────── - // Exercises [LSPTEST-TEST-ITEM-DATA-MODEL] — `<file>::<name>` / `<file>::<Class>::<method>` ids. - - test("discovery generates correct test IDs with :: separator", async function () { - const wsRoot = requireWorkspaceRoot(); - const testFilePath = writeTestFile( - wsRoot, - "test_ids_e2e.py", - [ - "def test_simple() -> None:", - " pass", - "", - "class TestSuite:", - " def test_method(self) -> None:", - " pass", - "", - ].join("\n") - ); - - const doc = await vscode.workspace.openTextDocument(testFilePath); - await vscode.window.showTextDocument(doc); - - try { - const uri = vscode.Uri.file(testFilePath).toString(); - const result = await discoverTests([{ uri }]); - - // Function ID should contain the function name. - const func = result.items.find((item) => item.name === "test_simple"); - assert.ok(func, "Should find test_simple"); - assert.ok(func.id.includes("test_simple"), `ID should contain test_simple: ${func.id}`); - - // Class method ID should use :: separator. - const cls = result.items.find((item) => item.name === "TestSuite"); - assert.ok(cls, "Should find TestSuite"); - if (cls.children.length > 0) { - const method = cls.children.find((c) => c.name === "test_method"); - assert.ok(method, "Should find test_method in TestSuite"); - assert.ok( - method.id.includes("::"), - `Method ID should use :: separator: ${method.id}` - ); - } - } finally { - cleanupFiles(testFilePath); - } - }); - - // ── Test Discovery: Multiple Files ───────────────────────────────── - - test("workspace discovery finds tests across multiple files", async function () { - const wsRoot = requireWorkspaceRoot(); - const file1 = writeTestFile(wsRoot, "test_multi_a.py", "def test_a() -> None:\n pass\n"); - const file2 = writeTestFile(wsRoot, "test_multi_b.py", "def test_b() -> None:\n pass\n"); - - try { - const result = await discoverTests(); - // Both files should appear somewhere in the workspace results. - const allIds = result.items.flatMap((item) => [ - item.id, - ...item.children.map((c) => c.id), - ]); - const idStr = allIds.join(", "); - // At minimum, the workspace scan should succeed (files may or may not - // appear if the workspace root differs from tmpDir). - assert.ok(result.items.length >= 0, `Workspace discovery should succeed, ids: ${idStr}`); - } finally { - cleanupFiles(file1, file2); - } - }); - - // ── Test Run: runTests Command ───────────────────────────────────── - // Exercises [LSPTEST-TEST-EXECUTION] + [LSPTEST-LSP-PROTOCOL-COMMANDS] — runTests/runTestFile/debugTest. - - test("runTests command returns structured result", async function () { - const client = requireClient(); - - // Run with empty test IDs — should return a result (possibly an error). - try { - const result = await client.sendRequest("workspace/executeCommand", { - command: "basilisk.runTests", - arguments: [{ testIds: [] }], - }); - - // Even with empty IDs, the command should return a structured result. - if (result !== null) { - assertRunResultShape(result); - assert.ok(isUnknownArray(rawField(result, "perTest")), "Result should have perTest array"); - } - } catch { - // pytest may not be installed — the command returning an error is acceptable. - } - }); - - // ── Test Run: runTestFile Command ────────────────────────────────── - - test("runTestFile command accepts a URI argument", async function () { - const client = requireClient(); - const wsRoot = requireWorkspaceRoot(); - const testFilePath = writeTestFile( - wsRoot, - "test_run_file_e2e.py", - "def test_trivial() -> None:\n assert True\n" - ); - - try { - const uri = vscode.Uri.file(testFilePath).toString(); - const result = await client.sendRequest("workspace/executeCommand", { - command: "basilisk.runTestFile", - arguments: [uri], - }); - - if (result !== null) { - assertRunResultShape(result); - } - } catch { - // pytest may not be installed — command error is acceptable. - } finally { - cleanupFiles(testFilePath); - } - }); - - // ── Test Run: debugTest Command Validates Input ──────────────────── - - test("debugTest command with empty testId returns null", async function () { - const client = requireClient(); - - const result = await client.sendRequest("workspace/executeCommand", { - command: "basilisk.debugTest", - arguments: [{ testId: "" }], - }); - - // Empty testId should return null (no debug session started). - assert.strictEqual(result, null, "debugTest with empty testId should return null"); - }); - - // ── Discovery Result Shape Validation ────────────────────────────── - - test("discovered items have all required fields", async function () { - const wsRoot = requireWorkspaceRoot(); - const testFilePath = writeTestFile( - wsRoot, - "test_shape_e2e.py", - "def test_shape_check() -> None:\n pass\n" - ); - - const doc = await vscode.workspace.openTextDocument(testFilePath); - await vscode.window.showTextDocument(doc); - - try { - const uri = vscode.Uri.file(testFilePath).toString(); - const result = await discoverTests([{ uri }]); - - for (const item of result.items) { - assert.ok(typeof item.name === "string" && item.name.length > 0, "name must be non-empty string"); - assert.ok(typeof item.id === "string" && item.id.length > 0, "id must be non-empty string"); - assert.ok(typeof item.file === "string" && item.file.length > 0, "file must be non-empty string"); - assert.ok(typeof item.line === "number" && item.line >= 0, "line must be non-negative number"); - assert.ok( - ["file", "function", "class", "method"].includes(item.kind), - `kind must be a valid TestItemKind, got: ${item.kind}` - ); - assert.ok(Array.isArray(item.children), "children must be an array"); - } - } finally { - cleanupFiles(testFilePath); - } - }); - - // ── Discovery Notification ───────────────────────────────────────── - // Exercises [LSPTEST-LSP-PROTOCOL-CUSTOM-NOTIFICATIONS] — `basilisk/testDiscoveryResult`. - - test("basilisk/testDiscoveryResult notification is received on open", async function () { - const client = requireClient(); - - // The notification is sent on workspace init. We can verify the client - // has the notification handler wired (it doesn't throw). - let notificationReceived = false; - const disposable = client.onNotification( - "basilisk/testDiscoveryResult", - () => { notificationReceived = true; } - ); - - // Trigger a fresh discovery. - await client.sendRequest("workspace/executeCommand", { - command: "basilisk.discoverTests", - arguments: [], - }); - - // Give the notification a moment to arrive. - await delay(500); - disposable.dispose(); - - // The notification may or may not fire depending on whether the server - // sends it for explicit command requests. The test verifies the handler - // can be registered without error. - assert.ok(typeof notificationReceived === "boolean", "Notification handler should work"); - }); - - // ── Discovery: Deeply Nested Class ───────────────────────────────── - - test("discovery handles class with many test methods", async function () { - const wsRoot = requireWorkspaceRoot(); - const methods = Array.from({ length: 10 }, (_, i) => - ` def test_method_${i}(self) -> None:\n pass` - ).join("\n\n"); - const testFilePath = writeTestFile( - wsRoot, - "test_many_methods.py", - `class TestLargeClass:\n${methods}\n` - ); - - const doc = await vscode.workspace.openTextDocument(testFilePath); - await vscode.window.showTextDocument(doc); - - try { - const uri = vscode.Uri.file(testFilePath).toString(); - const result = await discoverTests([{ uri }]); - - const classItem = result.items.find((item) => item.name === "TestLargeClass"); - assert.ok(classItem, "Should find TestLargeClass"); - assert.strictEqual( - classItem.children.length, 10, - `Should find all 10 test methods, got ${classItem.children.length}` - ); - } finally { - cleanupFiles(testFilePath); - } - }); - - // ── Coverage Settings ────────────────────────────────────────────── - - test("testExplorer.coverageEnabled setting defaults to false", () => { - const cfg = vscode.workspace.getConfiguration("basilisk"); - assert.strictEqual(cfg.get<boolean>("testExplorer.coverageEnabled"), false); - }); - - test("testExplorer.coverageEnabled can be enabled", async function () { - const cfg = vscode.workspace.getConfiguration("basilisk"); - await cfg.update("testExplorer.coverageEnabled", true, vscode.ConfigurationTarget.Workspace); - assert.strictEqual(vscode.workspace.getConfiguration("basilisk").get<boolean>("testExplorer.coverageEnabled"), true); - await cfg.update("testExplorer.coverageEnabled", undefined, vscode.ConfigurationTarget.Workspace); - }); - - // ── Coverage Command Advertisement ───────────────────────────────── - - test("LSP server advertises basilisk.runTestsCoverage command", () => { - const store = getStore(); - assert.ok(store, "Store should exist"); - assert.ok( - store.isServerCommandAdvertised("basilisk.runTestsCoverage"), - "Server should advertise basilisk.runTestsCoverage" - ); - }); - - test("runTestsCoverage command is server-advertised, not client-registered", () => { - const store = getStore(); - assert.ok(store, "Store should exist"); - assert.ok( - store.isServerCommandAdvertised("basilisk.runTestsCoverage"), - "basilisk.runTestsCoverage should be server-advertised" - ); - assert.ok( - !store.isClientCommandRegistered("basilisk.runTestsCoverage"), - "basilisk.runTestsCoverage should NOT be client-registered" - ); - }); - - // ── Coverage Command Execution ───────────────────────────────────── - // Exercises [LSPTEST-UV-INTEGRATION-COVERAGE] — coverage run command + `basilisk/coverageResult`. - - test("runTestsCoverage command returns structured result", async function () { - const client = requireClient(); - - try { - const result = await client.sendRequest("workspace/executeCommand", { - command: "basilisk.runTestsCoverage", - arguments: [{ testIds: [] }], - }); - - if (result !== null) { - assertRunResultShape(result); - } - } catch { - // pytest-cov may not be installed — command error is acceptable. - } - }); - - // ── Coverage Notification Handler ────────────────────────────────── - - test("basilisk/coverageResult notification handler can be registered", async function () { - const client = requireClient(); - - let notificationReceived = false; - const disposable = client.onNotification( - "basilisk/coverageResult", - () => { notificationReceived = true; } - ); - - // Give it a moment. - await delay(200); - disposable.dispose(); - - // Handler registration should not throw. - assert.ok(typeof notificationReceived === "boolean", "Notification handler should work"); - }); -}); diff --git a/vscode-extension/src/test/suite/test-helpers.ts b/vscode-extension/src/test/suite/test-helpers.ts deleted file mode 100644 index 3f33c7404..000000000 --- a/vscode-extension/src/test/suite/test-helpers.ts +++ /dev/null @@ -1,668 +0,0 @@ -// Implements [VSIX]. See docs/specs/VSIX-SPEC.md#VSIX -/** - * Shared test helpers for Basilisk VS Code extension E2E tests. - * - * Centralises LSP test utilities that were previously duplicated across - * every test file: binary discovery, diagnostic polling, file management. - */ - -import { delay } from '../../timeouts'; -import * as vscode from 'vscode'; -import * as path from 'path'; -import * as fs from 'fs'; -import * as os from 'os'; -import { editorPathKey } from '../../editor-path-key'; -import { type Store } from '../../store-types'; -import type { ReadonlySignal, Signal } from '@preact/signals-core'; -import type { LanguageClient } from 'vscode-languageclient/node'; - - -export { POLL_INTERVAL_MS, WAIT_MS } from '../../timeouts'; - -/** - * Put a Store signal the public interface exposes as read-only into `value`. - * - * The Store deliberately hands out `ReadonlySignal` so production code cannot - * write to it, but they are the same `Signal` objects underneath. A test that - * needs the store in a particular state says so directly here rather than - * driving several unrelated code paths to arrive at it — and rather than each - * call site inventing its own `as unknown as { value: X }`, which describes a - * type the object does not have and would keep compiling if the real shape - * changed. The one assertion the runtime genuinely requires lives here, once. - */ -export function seedSignal<T>(signal: ReadonlySignal<T>, value: T): void { - (signal as Signal<T>).value = value; -} - -/** - * A `LanguageClient` double carrying only the members a test actually drives. - * - * `sendRequest<R>(…): Promise<R>` cannot be honestly implemented by a double: - * satisfying it means producing a caller-chosen `R` out of canned data, and no - * runtime check narrows `unknown` to a type parameter. That one unavoidable - * assertion lives here rather than being copied into every fixture — the - * payloads such a client hands back are still checked wherever production code - * reads them, which is the thing `no-unsafe-type-assertion` exists to protect. - */ -export function fakeLanguageClient(members: Record<string, unknown>): LanguageClient { - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- generic sendRequest<R> cannot be satisfied by a double; see above - return members as unknown as LanguageClient; -} - -export const EXTENSION_ID = 'Nimblesite.basilisk'; - -/** Maximum time (ms) to wait for diagnostics from the LSP server. */ -export const DIAGNOSTIC_TIMEOUT_MS = 15_000; - -/** Time (ms) to wait for "no diagnostics" assertions. */ -export const NO_DIAGNOSTIC_WAIT_MS = 5_000; - -/** Time (ms) to wait for the LSP server to fully start. - * CI runners need up to 2 minutes for a cold start (cargo build + LSP init). */ -export const SERVER_START_WAIT_MS = 60_000; - -/** Mocha timeout (ms) for suiteSetup hooks that wait for the LSP. - * Must exceed SERVER_START_WAIT_MS to avoid Mocha killing the hook early. */ -export const SUITE_SETUP_TIMEOUT_MS = 90_000; - -/** - * Time (ms) to allow an LSP client to come back up after a restart or a - * deactivate/activate cycle. - * - * Distinct from `WAIT_MS` (1s), which is the module's generic short wait: a - * restart respawns the server binary and replays initialize, which is not a - * one-second operation on a cold CI runner — spawning an .exe on win32 alone - * costs more than that. Tests that waited `WAIT_MS` for it read the - * still-starting client as a broken one and failed on a downstream assertion - * that never named the real cause ([VSIX-CI-PLATFORM-COVERAGE]). - * - * Kept under the suite's 45s Mocha timeout so a genuine hang is reported by - * the wait that understands it, not by Mocha. - */ -export const LSP_RESTART_WAIT_MS = 30_000; - -/** Maximum time (ms) to wait for a server-advertised command to appear. */ -export const COMMAND_WAIT_MS = 1_000; - - -/** Default interval (ms) for polling loops. */ -export const DEFAULT_POLL_INTERVAL_MS = 100; - -/** Interval (ms) between server readiness polls during setup. */ -const SERVER_READINESS_POLL_INTERVAL_MS = 200; - - -function detectShipwrightPlatform(): string { - if (process.platform === 'darwin' && process.arch === 'arm64') { return 'darwin-arm64'; } - if (process.platform === 'linux' && process.arch === 'arm64') { return 'linux-arm64'; } - if (process.platform === 'linux') { return 'linux-x64'; } - if (process.platform === 'win32' && process.arch === 'arm64') { return 'win32-arm64'; } - if (process.platform === 'win32') { return 'win32-x64'; } - return 'linux-x64'; -} - -/** Resolve the basilisk binary for tests: bundled VSIX binary first, then workspace build. */ -export function findBasiliskBinary(): string | undefined { - const extensionRoot = path.resolve(__dirname, '../../..'); - const exe = process.platform === 'win32' ? '.exe' : ''; - const bundled = path.join(extensionRoot, 'bin', detectShipwrightPlatform(), `basilisk${exe}`); - if (fs.existsSync(bundled)) { - return bundled; - } - - const workspaceRoot = path.resolve(__dirname, '../../../..'); - const releaseBinary = path.join(workspaceRoot, 'target', 'release', `basilisk${exe}`); - if (fs.existsSync(releaseBinary)) { - return releaseBinary; - } - - const debugBinary = path.join(workspaceRoot, 'target', 'debug', `basilisk${exe}`); - if (fs.existsSync(debugBinary)) { - return debugBinary; - } - - return undefined; -} - -/** - * Wait until at least one diagnostic appears for the given URI. - * Throws if no diagnostics arrive before the timeout elapses. - */ -export async function waitForDiagnostics( - uri: vscode.Uri, - timeoutMs: number = DIAGNOSTIC_TIMEOUT_MS -): Promise<vscode.Diagnostic[]> { - return new Promise((resolve, reject) => { - const existing = vscode.languages.getDiagnostics(uri); - if (existing.length > 0) { - resolve(existing); - return; - } - - const timeout = setTimeout(() => { - disposable.dispose(); - const stale = vscode.languages.getDiagnostics(uri); - if (stale.length > 0) { - resolve(stale); - } else { - reject(new Error( - `waitForDiagnostics timed out after ${timeoutMs}ms — ` + - `no diagnostics appeared for ${uri.fsPath}` - )); - } - }, timeoutMs); - - const disposable = vscode.languages.onDidChangeDiagnostics((event) => { - if (event.uris.some((u) => u.toString() === uri.toString())) { - const diags = vscode.languages.getDiagnostics(uri); - if (diags.length > 0) { - clearTimeout(timeout); - disposable.dispose(); - resolve(diags); - } - } - }); - }); -} - -/** - * Wait for diagnostics to clear (reach zero) for the given URI. - * Throws if diagnostics remain when the timeout elapses. - */ -export async function waitForDiagnosticsCleared( - uri: vscode.Uri, - timeoutMs: number = DIAGNOSTIC_TIMEOUT_MS -): Promise<vscode.Diagnostic[]> { - return new Promise((resolve, reject) => { - const existing = vscode.languages.getDiagnostics(uri); - if (existing.length === 0) { - resolve([]); - return; - } - - const timeout = setTimeout(() => { - disposable.dispose(); - const remaining = vscode.languages.getDiagnostics(uri); - if (remaining.length === 0) { - resolve([]); - } else { - reject(new Error( - `waitForDiagnosticsCleared timed out after ${timeoutMs}ms — ${remaining.length} diagnostic(s) still present for ${uri.fsPath}: ${remaining.map((d) => d.message).join('; ')}` - )); - } - }, timeoutMs); - - const disposable = vscode.languages.onDidChangeDiagnostics((event) => { - if (event.uris.some((u) => u.toString() === uri.toString())) { - const diags = vscode.languages.getDiagnostics(uri); - if (diags.length === 0) { - clearTimeout(timeout); - disposable.dispose(); - resolve([]); - } - } - }); - }); -} - -/** Options for polling an async function until a predicate is satisfied. */ -export interface PollOptions<T> { - fn: () => PromiseLike<T>; - predicate: (result: T) => boolean; - timeoutMs?: number; - intervalMs?: number; -} - -/** - * Poll an async function until it returns a result satisfying the predicate. - * Throws if the predicate is never satisfied before the timeout elapses. - * - * Supports two calling conventions: - * - `pollUntilResult({ fn, predicate, timeoutMs?, intervalMs? })` - * - `pollUntilResult(fn, predicate)` - */ -export async function pollUntilResult<T>( - optionsOrFn: PollOptions<T> | (() => PromiseLike<T>), - predicateArg?: (result: T) => boolean, -): Promise<T> { - const options: PollOptions<T> = typeof optionsOrFn === 'function' - ? { fn: optionsOrFn, predicate: predicateArg ?? (() => true) } - : optionsOrFn; - const { fn, predicate, timeoutMs = NO_DIAGNOSTIC_WAIT_MS, intervalMs = DEFAULT_POLL_INTERVAL_MS } = options; - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - const result = await fn(); - if (predicate(result)) {return result;} - await delay(intervalMs); - } - // One final attempt after deadline. - const last = await fn(); - if (predicate(last)) {return last;} - throw new Error( - `pollUntilResult timed out after ${timeoutMs}ms — ` + - `predicate never satisfied (last result: ${JSON.stringify(last)})` - ); -} - -/** - * Create a temporary Python file, open it in the editor, and return - * the document + URI. Caller is responsible for cleanup via tmpDir. - */ -export async function openPythonFile( - tmpDir: string, - filename: string, - content: string -): Promise<{ doc: vscode.TextDocument; uri: vscode.Uri }> { - const filePath = path.join(tmpDir, filename); - fs.writeFileSync(filePath, content, 'utf8'); - const uri = vscode.Uri.file(filePath); - const doc = await vscode.workspace.openTextDocument(uri); - await vscode.window.showTextDocument(doc, { preview: false }); - return { doc, uri }; -} - -/** Close all open editors to avoid cross-test pollution. */ -export async function closeAllEditors(): Promise<void> { - await vscode.commands.executeCommand('workbench.action.closeAllEditors'); -} - -/** - * Replace the entire contents of a document with new text. - * Uses WorkspaceEdit for reliability — editor.edit() can fail when - * the editor state is transitioning (e.g. after a server restart). - */ -export async function replaceDocumentContent( - doc: vscode.TextDocument, - newContent: string -): Promise<boolean> { - const edit = new vscode.WorkspaceEdit(); - const fullRange = new vscode.Range( - new vscode.Position(0, 0), - new vscode.Position(doc.lineCount, 0) - ); - edit.replace(doc.uri, fullRange, newContent); - return vscode.workspace.applyEdit(edit); -} - -/** - * Wait until the Basilisk LSP has fully initialized and advertised its commands. - * - * Use this in any suiteSetup that needs the LSP to be running before tests - * execute. It polls store.serverCommands rather than documentSymbol so - * Basilisk's own initialization — not VS Code's built-in Python extension - * — determines readiness. - * - * Handles the lazy re-init path after deactivate() cycles from earlier test - * suites: first calls getStore() to clear pendingReactivation (returns - * undefined), then calls again to trigger initExtension. - */ -export async function waitForLspReady(): Promise<void> { - const ext = vscode.extensions.getExtension(EXTENSION_ID); - if (ext && !ext.isActive) { - await ext.activate(); - } - - const { getStore: getStoreFromExtension } = await import('../../extension'); - if (getStoreFromExtension() === undefined) { - getStoreFromExtension(); - } - - const deadline = Date.now() + SERVER_START_WAIT_MS; - while (Date.now() < deadline) { - const store = getStoreFromExtension(); - if (store !== undefined && store.serverCommands.value.size > 0) { - return; - } - await delay(SERVER_READINESS_POLL_INTERVAL_MS); - } - throw new Error(describeLspStartTimeout(getStoreFromExtension())); -} - -/** - * Diagnostic message for an LSP start timeout: include the store's view of - * the world (which stage stalled — binary resolution, client start, or - * command advertisement) instead of a blind "not responsive". - */ -function describeLspStartTimeout(store: Store | undefined): string { - const resolution = store?.runtimeResolution.value; - return ( - `LSP server failed to become responsive within ${SERVER_START_WAIT_MS}ms ` + - `(lspState=${store?.lspState.value ?? 'no-store'}, ` + - `client=${store?.client.value !== undefined ? 'created' : 'missing'}, ` + - `binary=${resolution?.path ?? 'unresolved'}, source=${resolution?.source ?? 'n/a'}). ` + - 'Ensure the basilisk binary is built: cargo build -p basilisk-cli' - ); -} - -/** - * Standard suiteSetup body: find binary, create tmpDir, wait for the LSP - * to be ready, then close all editors. Returns tmpDir and binary path. - */ -export async function setupLspTestSuite( - tmpDirPrefix: string -): Promise<{ tmpDir: string; basiliskBinary: string }> { - const binary = findBasiliskBinary(); - if (binary === undefined) { - throw new Error( - 'Basilisk binary not found. Build with: cargo build -p basilisk-cli' - ); - } - - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), tmpDirPrefix)); - - await waitForLspReady(); - await vscode.commands.executeCommand('workbench.action.closeAllEditors'); - - return { tmpDir, basiliskBinary: binary }; -} - -/** - * Best-effort removal of a test directory tree. - * - * On Windows the server (or the editor) may hold a handle inside the dir - * well past the last test — a bare rmSync races it and fails the suite's - * after-all hook with EPERM/EBUSY over pure housekeeping. Cleanup is not - * an assertion: retry hard, then warn and move on (the OS temp dir is - * reaped eventually). ALL suite teardowns must use this, never raw rmSync. - */ -export function removeTestDir(dir: string): void { - if (dir === '' || !fs.existsSync(dir)) { - return; - } - try { - fs.rmSync(dir, { recursive: true, force: true, maxRetries: 20, retryDelay: 250 }); - } catch (error) { - // eslint-disable-next-line no-console -- test-harness stderr, surfaced in the runner log - console.warn(`removeTestDir: leaving ${dir} for the OS to reap: ${String(error)}`); - } -} - -/** Clean up a tmpDir created by setupLspTestSuite. */ -export function teardownLspTestSuite(tmpDir: string): void { - removeTestDir(tmpDir); -} - -// ── Hover & navigation helpers ─────────────────────────────────────── -// Shared so the hover (lsp-hover) and goto (lsp-goto) suites can HAMMER -// every symbol kind without re-implementing the poll/extract plumbing. - -/** Flatten VS Code hover results into a single searchable string. */ -export function extractHoverText(hovers: readonly vscode.Hover[]): string { - return hovers - .flatMap((h) => - h.contents.map((c) => { - if (typeof c === 'string') { return c; } - if (c instanceof vscode.MarkdownString) { return c.value; } - if ('value' in c) { return (c as { value: string }).value; } - return ''; - }) - ) - .join('\n'); -} - -/** - * Locate a token within multi-line source and return a Position pointing at - * the MIDDLE of that token (so the cursor sits firmly inside the identifier, - * the way a user hovering/clicking would land). `occurrence` selects which - * match (0-based) when the token appears more than once — essential for - * distinguishing a definition site from its later reference sites. - * - * Throws if the token (at the requested occurrence) is absent: a missing - * token means the test fixture drifted, which must fail loudly, not silently - * hover at (0,0). - */ -export function locate(content: string, token: string, occurrence = 0): vscode.Position { - const lines = content.split('\n'); - let seen = 0; - for (let line = 0; line < lines.length; line++) { - let from = 0; - for (; ;) { - const col = lines[line].indexOf(token, from); - if (col === -1) { break; } - if (seen === occurrence) { - return new vscode.Position(line, col + Math.floor(token.length / 2)); - } - seen += 1; - from = col + token.length; - } - } - throw new Error( - `locate: token "${token}" (occurrence ${occurrence}) not found in source` - ); -} - -/** - * Poll the hover provider until it returns content, then return the flattened - * hover text. Returns '' (never throws) when no hover ever materialises, so a - * test can assert presence with a clear message instead of an opaque timeout. - */ -export async function getHoverText( - uri: vscode.Uri, - position: vscode.Position, - timeoutMs: number = DIAGNOSTIC_TIMEOUT_MS, -): Promise<string> { - // Poll until the hover has non-empty CONTENT, not merely a non-empty array. - // During the analysis window the provider can transiently return a Hover with - // empty contents (the intermittent "no content" regression, #200); resolving - // on array length alone yields '' and a spurious failure. Gating on extracted - // text makes the wait deterministic — it resolves only once real content - // materialises, bounded by timeoutMs. - const hovers = await pollUntilResult({ - fn: async () => vscode.commands.executeCommand<vscode.Hover[]>( - 'vscode.executeHoverProvider', uri, position - ).then((r) => r ?? [], () => [] as vscode.Hover[]), - predicate: (r) => Array.isArray(r) && extractHoverText(r).trim().length > 0, - timeoutMs, - }).catch(() => [] as vscode.Hover[]); - return extractHoverText(hovers); -} - -// ── Inlay-hint helpers ─────────────────────────────────────────────── -// Shared so any suite can assert that Basilisk surfaces inferred types -// INLINE (via `textDocument/inlayHint`) without the user hovering. See -// [LSPARCH-FEATURES-INLAYHINTS]. - -/** Flatten a VS Code inlay hint's label (string or label-parts) into one string. */ -export function inlayHintLabel(hint: vscode.InlayHint): string { - return typeof hint.label === 'string' - ? hint.label - : hint.label.map((part) => part.value).join(''); -} - -/** - * Whitespace-insensitive inlay-hint label so assertions are immune to padding - * differences: `": int"` → `":int"`, `" -> str"` → `"->str"`, `"name="` stays. - * Splits on spaces (no regex — see CLAUDE.md) which is all these labels contain. - */ -export function normalizedInlayLabel(hint: vscode.InlayHint): string { - return inlayHintLabel(hint).split(' ').join(''); -} - -/** The full-document range for a provider request. */ -function fullDocumentRange(doc: vscode.TextDocument): vscode.Range { - const lastLine = doc.lineCount - 1; - return new vscode.Range( - new vscode.Position(0, 0), - new vscode.Position(lastLine, doc.lineAt(lastLine).text.length), - ); -} - -/** - * Poll the whole-document inlay-hint provider until `predicate` holds. Returns - * `[]` on timeout — never throws — so callers assert with a descriptive message - * rather than an opaque poll failure. Analysis is async, so the first request - * can legitimately be empty. - */ -async function pollInlayHints( - doc: vscode.TextDocument, - predicate: (hints: vscode.InlayHint[]) => boolean, - timeoutMs: number, -): Promise<vscode.InlayHint[]> { - const range = fullDocumentRange(doc); - return pollUntilResult({ - fn: async () => vscode.commands.executeCommand<vscode.InlayHint[]>( - 'vscode.executeInlayHintProvider', doc.uri, range, - ).then((r) => r ?? [], () => [] as vscode.InlayHint[]), - predicate, - timeoutMs, - }).catch(() => [] as vscode.InlayHint[]); -} - -/** Poll until at least `minCount` inlay hints materialise over the document. */ -export async function getInlayHints( - doc: vscode.TextDocument, - minCount: number, - timeoutMs: number = DIAGNOSTIC_TIMEOUT_MS, -): Promise<vscode.InlayHint[]> { - return pollInlayHints(doc, (r) => r.length >= minCount, timeoutMs); -} - -/** Normalised inlay-hint labels present on `line` (0-based) of the document. */ -export function inlayLabelsOnLine( - hints: readonly vscode.InlayHint[], - line: number, -): string[] { - return hints - .filter((hint) => hint.position.line === line) - .map(normalizedInlayLabel); -} - -/** Arguments for {@link waitForInlayLabel}. */ -export interface InlayLabelWait { - doc: vscode.TextDocument; - line: number; - /** Normalised label to wait for, e.g. `":str"` (see {@link normalizedInlayLabel}). */ - label: string; - timeoutMs?: number; -} - -/** - * Poll the inlay-hint provider until a hint whose normalised label equals - * `label` appears on `line`. Returns the full hint list once satisfied, or `[]` - * on timeout. Used to assert that inline types stay CORRECT and LIVE after the - * document text changes. - */ -export async function waitForInlayLabel(opts: InlayLabelWait): Promise<vscode.InlayHint[]> { - const { doc, line, label, timeoutMs = DIAGNOSTIC_TIMEOUT_MS } = opts; - return pollInlayHints(doc, (r) => inlayLabelsOnLine(r, line).includes(label), timeoutMs); -} - -/** - * Filter diagnostics to only those produced by the Basilisk LSP server — - * `source: "basilisk"` or a `BSK`-prefixed code. Shared by the integration - * suite and the real-world journey suites ([VSIX-REALWORLD-JOURNEY]). - */ -export function filterBasiliskDiagnostics(diags: readonly vscode.Diagnostic[]): vscode.Diagnostic[] { - return diags.filter( - (d) => - d.source === 'basilisk' || - (typeof d.code === 'object' && - d.code !== null && - 'value' in d.code && - typeof d.code.value === 'string' && - d.code.value.startsWith('BSK')) - ); -} - -/** - * Recursively flatten document symbol names so callers can search through - * nested symbols (e.g. methods inside classes). Shared by the integration - * suite and the real-world journey suites ([VSIX-REALWORLD-JOURNEY]). - */ -export function flattenSymbolNames(symbols: readonly vscode.DocumentSymbol[]): string[] { - const names: string[] = []; - for (const sym of symbols) { - names.push(sym.name); - if (sym.children.length > 0) { - names.push(...flattenSymbolNames(sym.children)); - } - } - return names; -} - -/** - * Poll the document-symbol provider until `predicate` holds. Returns `[]` on - * timeout — never throws — so callers assert with a descriptive message. - */ -export async function getDocumentSymbols( - uri: vscode.Uri, - predicate: (symbols: vscode.DocumentSymbol[]) => boolean = (s) => s.length > 0, - timeoutMs: number = DIAGNOSTIC_TIMEOUT_MS, -): Promise<vscode.DocumentSymbol[]> { - return pollUntilResult({ - fn: async () => vscode.commands.executeCommand<vscode.DocumentSymbol[]>( - 'vscode.executeDocumentSymbolProvider', uri, - ).then((r) => r ?? [], () => [] as vscode.DocumentSymbol[]), - predicate, - timeoutMs, - }).catch(() => [] as vscode.DocumentSymbol[]); -} - -/** Definition-family providers usable with {@link getNavLocations}. */ -export type NavProvider = - | 'vscode.executeDefinitionProvider' - | 'vscode.executeDeclarationProvider' - | 'vscode.executeTypeDefinitionProvider'; - -/** Normalise the `(Location | LocationLink)[]` a provider may return to `Location[]`. */ -export function normalizeLocations( - raw: readonly (vscode.Location | vscode.LocationLink)[] -): vscode.Location[] { - return raw.map((l) => - 'targetUri' in l ? new vscode.Location(l.targetUri, l.targetRange) : l - ); -} - -/** - * Poll a definition-family provider until it returns at least one location, - * normalising LocationLink results. Returns [] (never throws) on timeout so - * the caller can assert with a descriptive message. - */ -export async function getNavLocations( - command: NavProvider, - uri: vscode.Uri, - position: vscode.Position, -): Promise<vscode.Location[]> { - const raw = await pollUntilResult({ - fn: async () => vscode.commands.executeCommand<(vscode.Location | vscode.LocationLink)[]>( - command, uri, position - ).then((r) => r ?? [], () => [] as vscode.Location[]), - predicate: (r) => Array.isArray(r) && r.length > 0, - timeoutMs: DIAGNOSTIC_TIMEOUT_MS, - }).catch(() => [] as (vscode.Location | vscode.LocationLink)[]); - return normalizeLocations(raw); -} - -/** - * Do two paths name the same file, as the editor and a Python runtime spell it? - * - * A test's expected path comes from `path.resolve`, while the paths under - * assertion come from `Uri.fsPath` (what the extension records for a visible - * editor) or from the interpreter itself (what tracemalloc and the profiler - * report). A raw `===` between those is not WRONG so much as UNDER-SPECIFIED: it - * holds only while both producers happen to spell the path identically. - * - * Inside the extension host they usually do, which is why a raw compare passes - * on win32 today: `__dirname` is itself a path VS Code resolved, so it already - * carries the drive-letter casing `Uri.fsPath` produces. Nothing guarantees that - * for a path from a DIFFERENT producer — a filename the interpreter reports, or - * an `os.tmpdir()` path carrying 8.3 short components (`RUNNER~1`) where the - * editor has the long form. Those differ by more than case, and a `===` filter - * then yields `[]`, so the assertion reports "nothing was painted" for a feature - * that painted correctly. - * - * Delegates to the production keyer ([VSIX-CI-PLATFORM-COVERAGE]) rather than - * case-folding here, so the comparison the tests make is the SAME one the - * decorations make — the test cannot pass on a coincidence the shipped overlay - * does not share. A test that hand-rolled `toLowerCase()` would pass while the - * shipped overlay stayed blank; this also keeps an empty result meaning "nothing - * was painted" instead of "the two spellings disagreed". - */ -export function isSamePath(left: string, right: string): boolean { - return editorPathKey(left) === editorPathKey(right); -} - -/** `isSamePath` as a predicate over a record carrying a `file` path. */ -export function sameFile(expected: string): (entry: { readonly file: string }) => boolean { - return (entry) => isSamePath(entry.file, expected); -} diff --git a/vscode-extension/src/test/suite/type-checking-toggle.test.ts b/vscode-extension/src/test/suite/type-checking-toggle.test.ts deleted file mode 100644 index 2194cc2c9..000000000 --- a/vscode-extension/src/test/suite/type-checking-toggle.test.ts +++ /dev/null @@ -1,400 +0,0 @@ -// Tests for [VSIX]. See docs/specs/VSIX-SPEC.md#VSIX -/** - * End-to-end regression for the "Type Checking" toggle (`basilisk.enabled`). - * - * GitHub #65 / #119. This drives a REAL VS Code window and a REAL Basilisk LSP - * (not a mock, not a direct `executeCommand("basilisk.toggleFeature")` poke): - * it flips the actual `basilisk.enabled` setting the toggle writes, then asserts - * the observable downstream effect a user sees — Basilisk diagnostics clear from - * the editor when type checking is disabled and return when it is re-enabled. - * - * The toggle kept getting reported as broken because previous "fixes" were - * validated by static code reads / mock-level tests that only checked the row - * label flipped to "Disabled" (issue #65 comment). Those never proved the - * diagnostics actually cleared. This test does. Implements the - * [EXTACT-INFO-FEATURE-STATUS] "Type Checking" effect and the client half of - * [ANALYSIS-ENABLED]. - */ - -import { delay } from '../../timeouts'; -import * as assert from 'assert'; -import * as fs from 'fs'; -import * as path from 'path'; -import * as vscode from 'vscode'; -import { getStore } from '../../extension'; -import { - ModuleTreeItem, - workspaceHealthBadge, - workspaceHealthMessage, -} from '../../module-explorer'; -import type { HealthStats, ModuleNode } from '../../module-explorer-render'; -import { - closeAllEditors, - DIAGNOSTIC_TIMEOUT_MS, - openPythonFile, - removeTestDir, - waitForDiagnostics, - waitForDiagnosticsCleared, -} from './test-helpers'; - -/** A snippet that produces Basilisk diagnostics in the test workspace. */ -const ERRORING_SOURCE = 'def greet(name):\n return f"Hello, {name}!"\n'; - -/** Buffer (ms) added on top of the multiple diagnostic waits this suite makes. */ -const TIMEOUT_BUFFER_MS = 20_000; - -// ── Panel-payload shapes, loosely typed on purpose ───────────────────────── -// The test asserts on raw wire JSON so it pins what the server actually serves, -// independent of the client-side interface declarations under test. - -interface LooseHealthStats { - readonly typeCheckingEnabled?: boolean; - readonly coveragePercent?: number; - readonly errors?: number; - readonly warnings?: number; - readonly totalFiles?: number; -} - -interface LooseModuleNode { - readonly name: string; - readonly path: string; - readonly symbols?: readonly unknown[]; - readonly coveragePercent?: number; - readonly errors?: number; - readonly warnings?: number; - readonly adopted?: boolean; -} - -interface LoosePanelResponse { - readonly modules: readonly LooseModuleNode[]; - readonly workspace: LooseHealthStats; -} - -// The wire shapes above stay independent of the client interfaces on purpose. -// Where a payload is handed to the production renderers, it is CONVERTED here -// rather than asserted into their types: an `as never` would let the wire drift -// away from what those renderers actually require and still compile, which is -// the very drift this suite exists to catch. - -/** Supply the one field the renderer requires that the wire may omit. */ -function asHealthStats(wire: LooseHealthStats): HealthStats { - return { ...wire, totalFiles: wire.totalFiles ?? 0 }; -} - -/** - * Build the node `ModuleTreeItem` needs from a wire node. - * - * `symbols` is emptied and `kind` fixed: the row rendering these assertions - * inspect (the coverage tint on `iconPath`) is derived from `coveragePercent` - * alone, so neither field can affect the outcome. - */ -function asModuleNode(wire: LooseModuleNode): ModuleNode { - return { ...wire, kind: 'module', symbols: [] }; -} - -/** The icon a row resolved to, proven to be a themed icon rather than assumed. */ -function themeIcon(item: vscode.TreeItem): vscode.ThemeIcon { - assert.ok( - item.iconPath instanceof vscode.ThemeIcon, - 'a module row renders a ThemeIcon, which is what carries the coverage tint', - ); - return item.iconPath; -} - -/** Fetch a panel payload from the REAL running LSP via executeCommand. */ -async function fetchPanelPayload(command: string): Promise<LoosePanelResponse> { - const client = getStore()?.client.value; - assert.ok(client, 'LSP client must exist to fetch panel data'); - assert.ok(client.isRunning(), 'LSP client must be running to fetch panel data'); - const result = await client.sendRequest<LoosePanelResponse>( - 'workspace/executeCommand', - { command, arguments: [{}] }, - ); - assert.ok(result, `${command} must return a payload`); - return result; -} - -/** Poll until `probe()` is true or the timeout elapses; returns the final value. */ -async function pollUntil(probe: () => boolean, timeoutMs: number): Promise<boolean> { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - if (probe()) { return true; } - await delay(200); - } - return probe(); -} - -/** Enabled state: grading is served, flagged, and renders "% typed" + a red tint. */ -function assertGradingServed(payload: LoosePanelResponse, moduleNeedle: string): void { - assert.strictEqual( - payload.workspace.typeCheckingEnabled, true, - 'enabled payload must stamp typeCheckingEnabled=true', - ); - assert.strictEqual( - typeof payload.workspace.coveragePercent, 'number', - 'enabled payload must carry the workspace coverage rollup', - ); - const module = payload.modules.find((m) => m.path.includes(moduleNeedle)); - assert.ok(module, 'the opened module must appear in the panel payload'); - assert.strictEqual( - typeof module.coveragePercent, 'number', - 'enabled module nodes carry coverage', - ); - assert.match( - workspaceHealthMessage(asHealthStats(payload.workspace)), - /% typed/, - 'enabled header renders "NN% typed"', - ); - const item = new ModuleTreeItem(asModuleNode(module)); - assert.ok( - themeIcon(item).color !== undefined, - 'enabled low-coverage module row is coverage-tinted (red)', - ); -} - -/** Disabled state (#119): payload, header chrome, and row tint all neutral. */ -function assertDisabledStateNeutral(payload: LoosePanelResponse, moduleNeedle: string): void { - assert.strictEqual( - payload.workspace.typeCheckingEnabled, false, - 'disabled payload must stamp typeCheckingEnabled=false (#119)', - ); - assert.strictEqual( - payload.workspace.coveragePercent, undefined, - 'disabled workspace rollup must not carry a coverage % — the "63% typed" header source (#119)', - ); - assert.strictEqual( - payload.workspace.errors, undefined, - 'disabled workspace rollup must not carry error tallies (#119)', - ); - const module = payload.modules.find((m) => m.path.includes(moduleNeedle)); - assert.ok(module, 'modules stay listed for navigation while disabled'); - for (const field of ['coveragePercent', 'errors', 'warnings', 'adopted'] as const) { - assert.strictEqual( - module[field], undefined, - `disabled module nodes must omit grading field '${field}' (#119)`, - ); - } - - // Header chrome: no "% typed", explicit disabled wording, no badge. - const message = workspaceHealthMessage(asHealthStats(payload.workspace)); - assert.doesNotMatch( - message, /% typed/, - 'disabled header must NOT display "NN% typed" (#119)', - ); - assert.match( - message, /disabled/i, - 'disabled header must say type checking is off', - ); - assert.strictEqual( - workspaceHealthBadge(asHealthStats(payload.workspace)), undefined, - 'disabled view must carry no diagnostics badge (#119)', - ); - - // Row rendering: no coverage tint — the "red rows" from the report. - const item = new ModuleTreeItem(asModuleNode(module)); - assert.strictEqual( - themeIcon(item).color, undefined, - 'disabled module rows must not be coverage-tinted red (#119)', - ); -} - -/** - * The `basilisk.enabled` value the fixture workspace actually COMMITS — not the - * effective value. - * - * `get()` folds in the schema default, so an unset setting reads back as - * `true`; restoring that writes `"basilisk.enabled": true` into the fixture's - * settings.json, and the platforms disagree about what that means. On Linux - * VS Code answers a write-the-default by DELETING the key, so the fixture came - * back clean by luck; on win32 it writes the key out, and every run left the - * repository dirty. - * - * `inspect().workspaceValue` is `undefined` for a setting the fixture never - * committed, and `update(..., undefined)` removes the key — so the fixture is - * restored to its committed state on both platforms rather than to whatever - * the current default happens to be ([VSIX-CI-PLATFORM-COVERAGE-CLASSES]). - */ -function committedWorkspaceEnabled( - cfg: vscode.WorkspaceConfiguration, -): boolean | undefined { - return cfg.inspect<boolean>('enabled')?.workspaceValue; -} - -// eslint-disable-next-line max-lines-per-function -- suite callback contains all tests -suite('Type Checking Toggle (basilisk.enabled)', function () { - let tmpDir: string; - - suiteSetup(() => { - const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; - assert.ok(workspaceRoot, 'toggle integration tests require the fixture workspace'); - // BSK-0001 is intentionally opt-in. Keep the fixture under the real - // workspace so its pyproject.toml enables the diagnostic this suite - // toggles; an OS-temp file correctly receives the default rule policy. - tmpDir = fs.mkdtempSync(path.join(workspaceRoot, '.bsk-enabled-test-')); - }); - - suiteTeardown(async () => { - await closeAllEditors(); - if (tmpDir !== undefined && tmpDir !== '' && fs.existsSync(tmpDir)) { - removeTestDir(tmpDir); - } - }); - - teardown(async () => { - await closeAllEditors(); - }); - - test('disabling clears Basilisk diagnostics; re-enabling restores them', async function () { - this.timeout(DIAGNOSTIC_TIMEOUT_MS * 3 + TIMEOUT_BUFFER_MS); - - const cfg = vscode.workspace.getConfiguration('basilisk'); - const originalEnabled = committedWorkspaceEnabled(cfg); - - try { - // Start from a known-enabled state. - await cfg.update('enabled', true, vscode.ConfigurationTarget.Workspace); - - // Open an erroring file — diagnostics must appear while enabled. - const { uri } = await openPythonFile(tmpDir, 'type_checking_toggle.py', ERRORING_SOURCE); - const openDiags = await waitForDiagnostics(uri, DIAGNOSTIC_TIMEOUT_MS); - assert.ok( - openDiags.length > 0, - 'precondition: Basilisk diagnostics must be present while type checking is enabled' - ); - - // Flip the Type Checking toggle OFF (the setting the panel writes). - await cfg.update('enabled', false, vscode.ConfigurationTarget.Workspace); - - // The whole point of the toggle (#119): diagnostics must clear. - const cleared = await waitForDiagnosticsCleared(uri, DIAGNOSTIC_TIMEOUT_MS); - assert.strictEqual( - cleared.length, - 0, - 'disabling Type Checking must clear Basilisk diagnostics from the editor (#119)' - ); - - // Flip it back ON — diagnostics must return (the toggle is reversible). - await cfg.update('enabled', true, vscode.ConfigurationTarget.Workspace); - const restored = await waitForDiagnostics(uri, DIAGNOSTIC_TIMEOUT_MS); - assert.ok( - restored.length > 0, - 're-enabling Type Checking must restore Basilisk diagnostics' - ); - } finally { - await cfg.update('enabled', originalEnabled, vscode.ConfigurationTarget.Workspace); - await closeAllEditors(); - } - }); - - // GitHub #119 showstopper reopen (v0.25.0): the diagnostics gate alone is not - // enough — the MODULES / Type Health surfaces kept serving "% typed", red - // rows, and error tallies while Type Checking was disabled. This pins the - // whole grading pipeline against the REAL LSP: payloads, header chrome, and - // row tinting must all go neutral on disable and recompute on re-enable. - test('disabling hides all grading (payloads, header, red rows); re-enabling recomputes', async function () { - this.timeout(DIAGNOSTIC_TIMEOUT_MS * 3 + TIMEOUT_BUFFER_MS); - - const cfg = vscode.workspace.getConfiguration('basilisk'); - const originalEnabled = committedWorkspaceEnabled(cfg); - - try { - await cfg.update('enabled', true, vscode.ConfigurationTarget.Workspace); - - // An unannotated function → low coverage that renders a red row while enabled. - const { uri } = await openPythonFile(tmpDir, 'toggle_modules_panel.py', ERRORING_SOURCE); - await waitForDiagnostics(uri, DIAGNOSTIC_TIMEOUT_MS); - - // ── Enabled: grading is served and flagged ────────────────────── - assertGradingServed( - await fetchPanelPayload('basilisk.workspaceModules'), - 'toggle_modules_panel', - ); - - // ── Disable: every grading surface must go neutral ────────────── - await cfg.update('enabled', false, vscode.ConfigurationTarget.Workspace); - await waitForDiagnosticsCleared(uri, DIAGNOSTIC_TIMEOUT_MS); - - assertDisabledStateNeutral( - await fetchPanelPayload('basilisk.workspaceModules'), - 'toggle_modules_panel', - ); - - // Sibling surface: basilisk.typeHealth must be gated the same way. - const disabledHealth = await fetchPanelPayload('basilisk.typeHealth'); - assert.strictEqual( - disabledHealth.workspace.typeCheckingEnabled, false, - 'typeHealth must stamp typeCheckingEnabled=false while disabled (#119)', - ); - assert.strictEqual( - disabledHealth.modules.length, 0, - 'typeHealth must serve no per-module grading while disabled (#119)', - ); - - // ── Re-enable: the panel recomputes, not merely un-hides ──────── - await cfg.update('enabled', true, vscode.ConfigurationTarget.Workspace); - await waitForDiagnostics(uri, DIAGNOSTIC_TIMEOUT_MS); - - const restored = await fetchPanelPayload('basilisk.workspaceModules'); - assert.strictEqual(restored.workspace.typeCheckingEnabled, true); - assert.strictEqual( - typeof restored.workspace.coveragePercent, 'number', - 're-enabling must recompute the coverage rollup', - ); - assert.match( - workspaceHealthMessage(asHealthStats(restored.workspace)), - /% typed/, - 're-enabled header renders "NN% typed" again', - ); - } finally { - await cfg.update('enabled', originalEnabled, vscode.ConfigurationTarget.Workspace); - await closeAllEditors(); - } - }); - - // #119 reopen, refresh half: the panel must repaint IMMEDIATELY on the toggle - // transition. In a diagnostics-free workspace no publishDiagnostics event - // fires, so the refresh must come from the server's own toggle notification - // (basilisk/moduleChanged → analysisRevision bump), not as a side effect of - // diagnostics clearing. - test('toggle transition refreshes the panel even with zero diagnostics', async function () { - this.timeout(DIAGNOSTIC_TIMEOUT_MS * 2 + TIMEOUT_BUFFER_MS); - - const cfg = vscode.workspace.getConfiguration('basilisk'); - const originalEnabled = committedWorkspaceEnabled(cfg); - const store = getStore(); - assert.ok(store, 'store must exist'); - - try { - await cfg.update('enabled', true, vscode.ConfigurationTarget.Workspace); - // A fully-annotated, diagnostic-free file: nothing to clear on disable. - await openPythonFile(tmpDir, 'toggle_clean_refresh.py', 'x: int = 1\n'); - // Let the open/analysis settle so later bumps are toggle-driven. - await delay(2_000); - - const before = store.analysisRevision.value; - await cfg.update('enabled', false, vscode.ConfigurationTarget.Workspace); - const bumped = await pollUntil( - () => store.analysisRevision.value > before, - DIAGNOSTIC_TIMEOUT_MS, - ); - assert.ok( - bumped, - 'disabling must bump analysisRevision (panel refresh) even with no diagnostics to clear (#119)', - ); - - const afterDisable = store.analysisRevision.value; - await cfg.update('enabled', true, vscode.ConfigurationTarget.Workspace); - const bumpedAgain = await pollUntil( - () => store.analysisRevision.value > afterDisable, - DIAGNOSTIC_TIMEOUT_MS, - ); - assert.ok( - bumpedAgain, - 're-enabling must bump analysisRevision so the panel recomputes immediately (#119)', - ); - } finally { - await cfg.update('enabled', originalEnabled, vscode.ConfigurationTarget.Workspace); - await closeAllEditors(); - } - }); -}); diff --git a/vscode-extension/src/test/suite/uv-integration.test.ts b/vscode-extension/src/test/suite/uv-integration.test.ts deleted file mode 100644 index 012850dcd..000000000 --- a/vscode-extension/src/test/suite/uv-integration.test.ts +++ /dev/null @@ -1,209 +0,0 @@ -// Implements [LSPUV]. See docs/specs/LSP-UV-SPEC.md#LSPUV -import * as assert from 'assert'; -import * as vscode from 'vscode'; -import * as path from 'path'; -import { getStore } from '../../extension'; -import { createServerCommandHandler } from '../../lsp-client'; -import { - SUITE_SETUP_TIMEOUT_MS, - setupLspTestSuite, - teardownLspTestSuite, - fakeLanguageClient, -} from './test-helpers'; - -/** Run `body`, capturing any `showInformationMessage` toasts, then restore. */ -async function captureInfoToasts(body: () => Promise<unknown>): Promise<string[]> { - const messages: string[] = []; - const win = vscode.window as { - showInformationMessage: typeof vscode.window.showInformationMessage; - }; - const original = win.showInformationMessage; - win.showInformationMessage = (async (msg: string) => { - messages.push(msg); - return undefined; - }); - try { - await body(); - } finally { - win.showInformationMessage = original; - } - return messages; -} - -/** - * Drive a server command through `createServerCommandHandler` with a fake LSP - * client that returns `result`, capturing the info toasts it produces. Lets a - * test assert on the toast behaviour for a given LSP outcome without a live - * server. - */ -async function uvToastsForResult( - result: unknown, - command: string, - arg: unknown -): Promise<string[]> { - const fakeClient = fakeLanguageClient({ - sendRequest: async () => result, - }); - const handler = createServerCommandHandler(fakeClient, command); - return captureInfoToasts(async () => handler(arg)); -} - -suite('Basilisk uv Integration Tests', () => { - let tmpDir: string; - - suiteSetup(async function () { - this.timeout(SUITE_SETUP_TIMEOUT_MS); - const result = await setupLspTestSuite('basilisk-uv-test-'); - tmpDir = result.tmpDir; - }); - - suiteTeardown(async () => { - const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? __dirname; - const pyUri = vscode.Uri.file(path.join(workspaceRoot, '__basilisk_uv_test__.py')); - try { - await vscode.workspace.fs.delete(pyUri); - } catch { - // File may not exist — ignore. - } - teardownLspTestSuite(tmpDir); - }); - - // ---------------------------------------------------------------- - // uv commands are advertised by the LSP server - // ---------------------------------------------------------------- - - test('LSP server advertises basilisk.uv.sync command', () => { - const store = getStore(); - assert.ok(store, 'Store should be available after activation'); - assert.ok(store.isServerCommandAdvertised('basilisk.uv.sync'), 'basilisk.uv.sync should be advertised by the LSP server'); - }); - - test('LSP server advertises basilisk.uv.add command', () => { - const store = getStore(); - assert.ok(store, 'Store should be available after activation'); - assert.ok(store.isServerCommandAdvertised('basilisk.uv.add'), 'basilisk.uv.add should be advertised by the LSP server'); - }); - - test('LSP server advertises basilisk.uv.addDev command', () => { - const store = getStore(); - assert.ok(store, 'Store should be available after activation'); - assert.ok(store.isServerCommandAdvertised('basilisk.uv.addDev'), 'basilisk.uv.addDev should be advertised by the LSP server'); - }); - - test('LSP server advertises basilisk.uv.remove command', () => { - const store = getStore(); - assert.ok(store, 'Store should be available after activation'); - assert.ok(store.isServerCommandAdvertised('basilisk.uv.remove'), 'basilisk.uv.remove should be advertised by the LSP server'); - }); - - test('LSP server advertises basilisk.uv.lock command', () => { - const store = getStore(); - assert.ok(store, 'Store should be available after activation'); - assert.ok(store.isServerCommandAdvertised('basilisk.uv.lock'), 'basilisk.uv.lock should be advertised by the LSP server'); - }); - - test('LSP server advertises basilisk.uv.createEnv command', () => { - const store = getStore(); - assert.ok(store, 'Store should be available after activation'); - assert.ok(store.isServerCommandAdvertised('basilisk.uv.createEnv'), 'basilisk.uv.createEnv should be advertised by the LSP server'); - }); - - // ---------------------------------------------------------------- - // uv settings exist in the configuration - // ---------------------------------------------------------------- - - test('Extension contributes basilisk.uv.enabled setting', () => { - const cfg = vscode.workspace.getConfiguration('basilisk'); - const inspected = cfg.inspect<boolean>('uv.enabled'); - assert.ok(inspected, 'basilisk.uv.enabled should be a contributed setting'); - assert.strictEqual( - inspected.defaultValue, - true, - 'Default uv.enabled should be true' - ); - }); - - test('Extension contributes basilisk.uv.executablePath setting', () => { - const cfg = vscode.workspace.getConfiguration('basilisk'); - const inspected = cfg.inspect<string>('uv.executablePath'); - assert.ok(inspected, 'basilisk.uv.executablePath should be a contributed setting'); - assert.strictEqual( - inspected.defaultValue, - '', - 'Default uv.executablePath should be empty string' - ); - }); - - test('Extension contributes basilisk.uv.autoSync setting', () => { - const cfg = vscode.workspace.getConfiguration('basilisk'); - const inspected = cfg.inspect<boolean>('uv.autoSync'); - assert.ok(inspected, 'basilisk.uv.autoSync should be a contributed setting'); - assert.strictEqual( - inspected.defaultValue, - false, - 'Default uv.autoSync should be false' - ); - }); - - test('Rule-family policy is not exposed as VS Code settings', () => { - const cfg = vscode.workspace.getConfiguration('basilisk'); - // A key that is NOT contributed as a setting has no declared default. - // (Recent VS Code returns a shaped inspect() object with every value - // `undefined` for an unknown key under a known section rather than a - // literal `undefined`, so assert on the absence of a `defaultValue` — - // a contributed boolean setting would report its declared default.) - assert.strictEqual( - cfg.inspect<boolean>('uv.stubSuggestions')?.defaultValue, - undefined, - 'stub suggestions must be configured through BSK-0152 severity, not a VS Code setting' - ); - assert.strictEqual( - cfg.inspect<boolean>('uv.dependencyDiagnostics')?.defaultValue, - undefined, - 'dependency diagnostics must be configured through explicit rule severities, not a VS Code setting' - ); - }); - - // ---------------------------------------------------------------- - // Quick-fix success toast names the package (regression) - // ---------------------------------------------------------------- - - // A code action (e.g. the BSK-0152 "install stubs" fix) invokes - // basilisk.uv.addDev with a BARE STRING argument, not a `{ package }` - // object. The success toast must read the package name from either shape; - // previously it only handled the object form and showed "undefined". - test('uv.addDev success toast names the package from a bare-string arg', async () => { - // A code action sends the package as a bare string, not { package }. - const messages = await uvToastsForResult({ success: true }, 'basilisk.uv.addDev', 'types-six'); - - assert.ok( - messages.some((m) => m.includes('types-six')), - `toast should name the package; got: ${JSON.stringify(messages)}` - ); - assert.ok( - !messages.some((m) => m.includes('undefined')), - `toast must not say "undefined"; got: ${JSON.stringify(messages)}` - ); - }); - - // Regression for issue #84: the optimistic "Added X" success toast fired - // unconditionally, never inspecting the LSP result. When `uv add` failed - // (e.g. on a non-package internal module like `_pydevd_bundle`), the UI - // showed BOTH the green "Added X" toast and the LSP's own error toast for - // the same operation. The success toast must be gated on result.success. - test('uv.add success toast is suppressed when the command fails', async () => { - const failure = { - success: false, - stdout: '', - stderr: 'error: Failed to parse: `_pydevd_bundle`', - }; - const messages = await uvToastsForResult(failure, 'basilisk.uv.add', '_pydevd_bundle'); - - assert.ok( - !messages.some((m) => m.includes('Added')), - `must not show a success toast when uv add failed; got: ${JSON.stringify(messages)}` - ); - }); - -}); - diff --git a/vscode-extension/src/test/suite/webview-dom-driver.ts b/vscode-extension/src/test/suite/webview-dom-driver.ts deleted file mode 100644 index b56015035..000000000 --- a/vscode-extension/src/test/suite/webview-dom-driver.ts +++ /dev/null @@ -1,130 +0,0 @@ -// The page-side driver prelude every configuration-editor DOM scenario shares. -/** - * Helpers the injected driver script runs INSIDE the webview: waiting on - * observable consequences rather than fixed sleeps, and one `probe()` that - * reads every observable fact about the Project view's setting panels - * ([LSPCFGED-TYPESHED], [LSPCFGED-CACHE]) at an instant. - * - * Split from `webview-dom-harness.ts` (which owns the extension-host half) to - * keep both files under the repository size ceiling. - */ - -export const DRIVER_PRELUDE = String.raw` - const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); - const settleDelay = 120; - const report = (result) => window.__realApi.postMessage(Object.assign({ type: 'domTestResult' }, result)); - const el = (selector) => document.querySelector(selector); - const all = (selector) => Array.from(document.querySelectorAll(selector)); - const text = (node) => (node && node.textContent ? node.textContent.trim() : null); - const waitFor = async (selector, tries) => { - for (let attempt = 0; attempt < (tries || 100); attempt += 1) { - if (el(selector)) return true; - await sleep(25); - } - return false; - }; - // Wait for an OBSERVABLE consequence instead of guessing how long the - // extension host will take. Every webview interaction costs two IPC round - // trips through the host's single event loop plus a full re-render, and that - // loop is shared with every other suite in the run — a language client - // pumping messages, the test-explorer poller, live panel effects. A fixed - // sleep encodes "the host is idle", which is true only when this file runs - // alone; in full-suite order the reply lands late and the driver samples a - // DOM that has not reacted yet. Returns false on timeout so the caller's - // assertion still fails loudly rather than the whole scenario hanging. - const waitUntil = async (predicate, tries) => { - for (let attempt = 0; attempt < (tries || 400); attempt += 1) { - try { if (predicate()) return true; } catch (ignored) { /* not rendered yet */ } - await sleep(25); - } - return false; - }; - const dialog = () => document.getElementById('preview-dialog'); - // Read every observable fact about the Project view's setting panels at - // this instant — Typeshed and Caching. - const probe = () => { - const commit = el('[data-typeshed-commit]'); - const commitError = document.getElementById('typeshed-commit-error'); - const pkg = el('[data-typeshed-package]'); - const packageError = document.getElementById('typeshed-package-error'); - const path = el('[data-typeshed-path="TypeshedPath"]'); - 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]); }); - return { - sources: all('[data-typeshed-source]').map((radio) => ({ - mode: radio.dataset.typeshedSource, - checked: radio.checked, - disabled: radio.disabled, - hint: text(radio.parentElement.querySelector('small')), - })), - commitPresent: commit !== null, - commitValue: commit ? commit.value : null, - commitDisabled: commit ? commit.disabled : null, - commitInvalid: commit ? commit.getAttribute('aria-invalid') : null, - commitError: commitError && !commitError.hidden ? text(commitError) : null, - packagePresent: pkg !== null, - packageValue: pkg ? pkg.value : null, - packageInvalid: pkg ? pkg.getAttribute('aria-invalid') : null, - packageError: packageError && !packageError.hidden ? text(packageError) : null, - pathPresent: path !== null, - pathValue: path ? path.value : 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, - 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 caching panel ([LSPCFGED-CACHE]): the persistent cache's two - // controls, plus the read-only in-session rows that keep the Salsa - // layer from looking like an omission in the config file. - cacheEnabledPresent: el('[data-cache-enabled]') !== null, - cacheEnabled: el('[data-cache-enabled]') ? el('[data-cache-enabled]').checked : null, - cacheFolderValue: el('[data-cache-folder]') ? el('[data-cache-folder]').value : null, - cacheResetPresent: el('[data-action="reset-cache-folder"]') !== null, - cachePickerDisabled: el('[data-pick-cache-folder]') - ? el('[data-pick-cache-folder]').disabled : null, - inSession: (() => { - const rows = {}; - all('#cache-in-session dt').forEach((dt, index) => { - rows[text(dt)] = text(all('#cache-in-session dd')[index]); - }); - return rows; - })(), - // 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')), - }; - }; - const steps = []; - const record = (label) => { steps.push(Object.assign({ label }, probe())); return steps[steps.length - 1]; }; - const click = async (node) => { node.click(); await sleep(settleDelay); }; - const change = async (node, value) => { - if (typeof value === 'boolean') { node.checked = value; } else { node.value = value; } - node.dispatchEvent(new Event('change', { bubbles: true })); - await sleep(settleDelay); - }; - // A real click: a disabled radio does nothing, exactly as for a user. - const chooseSource = async (mode) => { - el('[data-typeshed-source="' + mode + '"]').click(); - await sleep(settleDelay); - }; -`; diff --git a/vscode-extension/src/test/suite/webview-dom-harness.ts b/vscode-extension/src/test/suite/webview-dom-harness.ts deleted file mode 100644 index aa8ed9747..000000000 --- a/vscode-extension/src/test/suite/webview-dom-harness.ts +++ /dev/null @@ -1,505 +0,0 @@ -// Harness for driving the REAL configuration-editor webview runtime in a real -// webview DOM ([CONFIGEDITOR-VSIX-EXPERIENCE], [LSPCFGED-TYPESHED]). -// -// The page gets the production document and script; every intent it posts is -// answered on the EXTENSION side by ScenarioHost, which reproduces the real -// host/server pair — including that a Typeshed edit applies immediately and a -// dismissed preview returns to the snapshot. Drivers therefore observe exactly -// what a user would: interact, wait for the state push, read the DOM back. - -import * as assert from "assert"; -import * as vscode from "vscode"; -import { buildConfigurationEditorDocument } from "../../configuration-editor-document"; -import { DRIVER_PRELUDE } from "./webview-dom-driver"; -import type { - CacheConfigurationState, - ConfigurationSnapshot, - EditorMutation, - RuleSeverity, - TypeshedConfigurationState, -} from "../../configuration-editor-model"; -import { ACTIVE_COMMIT, cacheFixture, LATEST_COMMIT, typeshedFixture } from "./settings-fixture"; - -export { DRIVER_PRELUDE }; -import { asRecord, isRecord, stringArrayField } from "../../unknown-shape"; - -export const RESULT_TIMEOUT_MS = 30_000; -const PEP_RULE_COUNT = 40; -const BASILISK_RULE_COUNT = 5; - -export interface DomStep { - readonly label: string; - readonly [observation: string]: unknown; -} - -export interface DomTestResult { - readonly ok: boolean; - readonly reason?: string; - readonly steps?: DomStep[]; - readonly [observation: string]: unknown; -} - -/** Every mutation kind the configuration editor can post ([LSPCFGED-EDITOR]). */ -const EDITOR_MUTATION_KINDS: ReadonlySet<string> = new Set([ - "SetRule", "RemoveRule", "SetTag", "RemoveTag", - "SetTypeshedSetting", "RemoveTypeshedSetting", - "SetCacheSetting", "RemoveCacheSetting", -]); - -/** Whether a posted value carries one of the recognised mutation kinds. */ -function isEditorMutation(value: unknown): value is EditorMutation { - return isRecord(value) && typeof value.kind === "string" && EDITOR_MUTATION_KINDS.has(value.kind); -} - -/** - * The `mutations` the webview posted, minus anything unrecognised. - * - * The webview is a separate context, so its payload is checked rather than - * assumed: a mutation kind this harness does not know is dropped here, where - * the resulting assertion failure names the mutation, instead of flowing on as - * a value the compiler has been told is an `EditorMutation`. - */ -function editorMutations(value: unknown): EditorMutation[] { - return (Array.isArray(value) ? value : []).filter(isEditorMutation); -} - -/** - * The webview's `domTestResult` post, read field by field. - * - * The webview is a separate JavaScript context: what arrives is whatever it - * chose to post, so `ok` is derived from the value rather than asserted — a - * post that forgets it reads as a failed scenario, which is the truthful - * reading, instead of an `undefined` that every `assert.ok` would wave through. - */ -export function domTestResult(message: Record<string, unknown>): DomTestResult { - const { ok, reason, steps, ...observations } = message; - return { - ...observations, - ok: ok === true, - reason: typeof reason === "string" ? reason : undefined, - steps: Array.isArray(steps) ? steps.filter(isDomStep) : undefined, - }; -} - -/** Whether one posted step carries the `label` every step is required to have. */ -function isDomStep(value: unknown): value is DomStep { - return typeof value === "object" && value !== null && typeof (value as { label?: unknown }).label === "string"; -} - -export interface ScenarioOutcome { - readonly result: DomTestResult; - /** Every intent the runtime posted, in order. */ - readonly intents: readonly Record<string, unknown>[]; -} - -/** The persisted Typeshed and caching configuration the fake server holds. */ -export interface HostConfig { - commit?: string; - path?: string; - /** The `name@sha256:<64-hex>` pin spec ([STUBRES-TYPESHED-PYPI]). */ - packageSpec?: string; - storeFolder?: string; - cacheEnabled?: boolean; - cacheDir?: string; -} - -/** The default persistent-cache folder, as the server would resolve it. */ -const DEFAULT_CACHE_DIR = "/workspace/project/.basilisk/cache/check"; - -/** The lifecycle facts the fake server holds beside the configuration. */ -interface HostLifecycle { - readonly downloading: boolean; - readonly noSourceReason: string | undefined; -} - -/** - * The server describes a source by the VALUE that defines it, in the same - * precedence the real projection uses: a folder, else a package pin, else the - * commit — and an unset commit IS the bundled one - * ([LSPCFGED-TYPESHED], [STUBRES-TYPESHED-PYPI]). A malformed pin is no source - * at all and falls through, exactly as the server's `source()` does, so the - * editor can never render a half-formed package identity. - */ -function packageSource(spec: string): { kind: "PyPIPackage"; name: string; sha256: string } | undefined { - const separator = spec.indexOf("@sha256:"); - if (separator <= 0) { return undefined; } - const name = spec.slice(0, separator); - const sha256 = spec.slice(separator + "@sha256:".length).toLowerCase(); - return /^[0-9a-f]{64}$/.test(sha256) ? { kind: "PyPIPackage", name, sha256 } : undefined; -} - -// With no source 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 pinned = config.packageSpec === undefined ? undefined : packageSource(config.packageSpec); - const source = config.path !== undefined - ? ({ kind: "CustomFolder", path: config.path } as const) - : pinned ?? ({ kind: "ExactCommit", commit: config.commit ?? ACTIVE_COMMIT } as const); - return typeshedFixture({ - source, - storeFolder: config.storeFolder, - downloading: lifecycle.downloading, - noSourceReason: lifecycle.downloading ? undefined : lifecycle.noSourceReason, - }); -} - -// [LSPCFGED-CACHE]: the server always resolves the effective folder, so the -// panel shows a real location whether or not `cache-dir` is written. -function cacheFor(config: HostConfig): CacheConfigurationState { - return cacheFixture({ - enabled: config.cacheEnabled ?? false, - folder: config.cacheDir ?? DEFAULT_CACHE_DIR, - folderConfigured: config.cacheDir !== undefined, - }); -} - -/** A realistic rule catalog: pep rules first, basilisk rules at the bottom. */ -function fixtureRules(): ConfigurationSnapshot["rules"] { - const pep = Array.from({ length: PEP_RULE_COUNT }, (_ignored, index) => ({ - descriptor: { - code: `pep_rule_${String(index).padStart(3, "0")}`, - title: `PEP rule ${index}`, - summary: `Summary for pep rule ${index}`, - tags: ["pep", "generics"], - docsUrl: `https://www.basilisk-python.dev/errors/pep-${index}`, - }, - entry: undefined, - effectiveSeverity: { kind: "Error" } as const, - diagnosticCount: index, - })); - const basilisk = Array.from({ length: BASILISK_RULE_COUNT }, (_ignored, index) => ({ - descriptor: { - code: `BSK-${String(index + 1).padStart(4, "0")}`, - title: `Basilisk rule ${index + 1}`, - summary: `Summary for basilisk rule ${index + 1}`, - tags: ["basilisk", "strictness"], - docsUrl: `https://www.basilisk-python.dev/errors/BSK-${String(index + 1).padStart(4, "0")}`, - }, - entry: undefined, - // The last analyze rule resolves to Disabled ([CHKARCH-CONFIG-MODEL] step 3). - effectiveSeverity: index === BASILISK_RULE_COUNT - 1 - ? ({ kind: "Disabled" } as const) - : ({ kind: "Error" } as const), - diagnosticCount: index + 1, - })); - return [...pep, ...basilisk]; -} - -function fixtureSnapshot( - typeshed: TypeshedConfigurationState, - cache: CacheConfigurationState, - revision: string, -): ConfigurationSnapshot { - return { - rootUri: "file:///workspace/project", - configUri: "file:///workspace/project/pyproject.toml", - revision, - rules: fixtureRules(), - tags: [ - { name: "basilisk", kind: { kind: "Provenance" }, entry: undefined, ruleCount: BASILISK_RULE_COUNT, diagnosticCount: 15 }, - { name: "pep", kind: { kind: "Provenance" }, entry: undefined, ruleCount: PEP_RULE_COUNT, diagnosticCount: 780 }, - ], - source: { uri: "file:///workspace/project/pyproject.toml", exists: true, readOnly: false }, - pathOverrides: [{ - path: "legacy", - configUri: "file:///workspace/project/legacy/pyproject.toml", - rules: [{ code: "BSK-0001", severity: { kind: "Warning" } }], - tags: [], - }], - debt: { - remainingDiagnostics: 795, - errorDiagnostics: 780, - warningDiagnostics: 15, - infoDiagnostics: 0, - adoptedRules: 0, - disabledRules: 1, - }, - problems: [], - typeshed, - cache, - }; -} - -/** - * Typeshed and cache settings are direct writes with no severity impact, so - * the server applies them at once ([LSPCFGED-TYPESHED], [LSPCFGED-CACHE]). - */ -function isDirectSettingMutation(mutation: EditorMutation): boolean { - return mutation.kind === "SetTypeshedSetting" || mutation.kind === "RemoveTypeshedSetting" - || mutation.kind === "SetCacheSetting" || mutation.kind === "RemoveCacheSetting"; -} - -/** - * The real host/server pair, reduced to its observable contract. Typeshed - * mutations are written and re-projected at once; rule mutations open the - * impact dialog and land only on apply. - */ -export class ScenarioHost { - public readonly intents: Record<string, unknown>[] = []; - private readonly config: HostConfig; - private downloading: boolean; - private noSourceReason: string | undefined; - private pendingDownload: "DownloadLatest" | "DownloadPinned" | undefined; - private revision = 0; - private pending: EditorMutation[] = []; - private ruleEntries = new Map<string, RuleSeverity>(); - /** Folders the picker returns, in order; `undefined` means the user cancelled. */ - private readonly folders: (string | undefined)[]; - private readonly focusRule: string | null; - - constructor(options: { - config?: HostConfig; - downloading?: boolean; - noSourceReason?: string; - folders?: (string | undefined)[]; - focusRule?: string | null; - } = {}) { - this.config = { ...options.config }; - this.downloading = options.downloading === true; - this.noSourceReason = options.noSourceReason; - this.folders = [...(options.folders ?? [])]; - this.focusRule = options.focusRule ?? null; - } - - /** Complete an in-flight download, as the server's status notification does. */ - public settle(): Record<string, unknown> { - 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, { downloading: this.downloading, noSourceReason: this.noSourceReason }), - cacheFor(this.config), - `fnv1a64:${this.revision}`, - ); - return { - ...snapshot, - rules: snapshot.rules.map((rule) => { - const entry = this.ruleEntries.get(rule.descriptor.code); - return entry === undefined ? rule : { ...rule, entry }; - }), - }; - } - - /** Answer one intent exactly as the production host would. */ - public receive(message: Record<string, unknown>): Record<string, unknown> | undefined { - this.intents.push(message); - switch (message.type) { - case "ready": return this.readyState(); - case "preview": return this.preview(editorMutations(message.mutations)); - case "apply": return this.applyPending(); - case "cancelPreview": return this.readyState("Change discarded; configuration is unchanged"); - case "typeshedAction": return this.typeshedAction(String(message.action)); - case "pickTypeshedFolder": return this.pickFolder(String(message.key)); - case "pickCacheFolder": return this.pickFolder("CacheDir"); - case "occurrences": return this.occurrences(message); - default: return undefined; - } - } - - private readyState(message = "Configuration is up to date"): Record<string, unknown> { - return { - phase: "ready", - rootUri: "file:///workspace/project", - snapshot: this.snapshot(), - preview: undefined, - occurrences: undefined, - occurrencesLoading: false, - repairUri: undefined, - message, - refreshRequested: false, - focusRule: this.focusRule, - }; - } - - private preview(mutations: EditorMutation[]): Record<string, unknown> { - if (mutations.every(isDirectSettingMutation)) { - mutations.forEach((mutation) => { this.write(mutation); }); - return this.readyState("Applied"); - } - this.pending = mutations; - return { - ...this.readyState("Preview ready"), - phase: "preview", - preview: { - previewId: "preview-1", - baseRevision: `fnv1a64:${this.revision}`, - changes: mutations.flatMap((mutation) => mutation.kind === "SetRule" - ? [{ code: mutation.code, before: { kind: "Error" }, after: mutation.severity }] - : []), - typeshedChanges: [], - cacheChanges: [], - impact: { - errorsBefore: 780, errorsAfter: 779, - warningsBefore: 15, warningsAfter: 16, - infosBefore: 0, infosAfter: 0, - }, - }, - }; - } - - private applyPending(): Record<string, unknown> { - this.pending.forEach((mutation) => { - if (mutation.kind === "SetRule") { this.ruleEntries.set(mutation.code, mutation.severity); } - if (mutation.kind === "RemoveRule") { this.ruleEntries.delete(mutation.code); } - this.write(mutation); - }); - this.pending = []; - return this.readyState("Applied"); - } - - /** The writer's closed allowlist, in the same shape the TOML holds. */ - private write(mutation: EditorMutation): void { - if (mutation.kind === "SetCacheSetting" || mutation.kind === "RemoveCacheSetting") { - this.writeCache(mutation); - return; - } - if (mutation.kind !== "SetTypeshedSetting" && mutation.kind !== "RemoveTypeshedSetting") { return; } - const text = mutation.kind === "SetTypeshedSetting" ? mutation.value : undefined; - const fields: Record<string, keyof HostConfig> = { - TypeshedCommit: "commit", - TypeshedPath: "path", - TypeshedPackage: "packageSpec", - TypeshedStorePath: "storeFolder", - }; - const field = fields[mutation.key.kind]; - if (field === undefined) { return; } - Object.assign(this.config, { [field]: text }); - } - - /** `cache` is a TOML boolean; the wire spells it "true"/"false" text. */ - private writeCache(mutation: EditorMutation): void { - const set = mutation.kind === "SetCacheSetting"; - if (!set && mutation.kind !== "RemoveCacheSetting") { return; } - const key = mutation.key.kind; - if (key === "CacheEnabled") { - this.config.cacheEnabled = set && mutation.value === "true"; - return; - } - this.config.cacheDir = set ? mutation.value : undefined; - } - - // 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<string, unknown> | undefined { - 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<string, unknown> { - const folder = this.folders.shift(); - // A cancelled picker writes nothing — the host re-pushes the state so the - // controls snap back to the configuration that still holds. - if (folder === undefined) { return this.readyState(); } - if (key === "CacheDir") { - this.config.cacheDir = folder; - } else if (key === "TypeshedPath") { - this.config.path = folder; - this.config.commit = undefined; - } else { - this.config.storeFolder = folder; - } - return this.readyState("Applied"); - } - - private occurrences(message: Record<string, unknown>): Record<string, unknown> { - const codes = stringArrayField(message.selector, "codes"); - return { - ...this.readyState(), - occurrences: { - items: [{ - code: codes[0] ?? "", - uri: "file:///workspace/project/app.py", - range: { start: { line: 1, character: 0 }, end: { line: 1, character: 4 } }, - severity: { kind: "Error" }, - }], - nextCursor: undefined, - }, - }; - } -} - -/** Page-side bridge: forward every runtime intent to the extension host. */ -function bridgeScript(): string { - return ` - const __realApi = acquireVsCodeApi(); - window.__realApi = __realApi; - __realApi.postMessage({ type: 'domTestBoot' }); - window.addEventListener('error', (event) => { - __realApi.postMessage({ type: 'domTestResult', ok: false, reason: 'page error: ' + event.message }); - }); - window.acquireVsCodeApi = () => ({ - postMessage(message) { __realApi.postMessage({ type: 'domTestIntent', intent: message }); }, - getState() { return undefined; }, - setState() {}, - }); - `; -} - -/** Inject the bridge before and the driver after the real runtime, same nonce. */ -export function harnessDocument(driver: string): string { - const html = buildConfigurationEditorDocument(); - const openTag = /<script nonce="[^"]+">/.exec(html); - assert.ok(openTag, "the configuration editor document must carry one nonce-gated script"); - return html - .replace(openTag[0], `${openTag[0]}${bridgeScript()}\n;`) - .replace("</script>\n</body>", `;\n${driver}</script>\n</body>`); -} - -/** - * Run one driver against one host. The panel is created frontmost: a hidden - * webview throttles timers and pauses requestAnimationFrame, which would - * starve both the driver and the virtualized rule window. - */ -export async function runScenario(driver: string, host: ScenarioHost): Promise<ScenarioOutcome> { - await vscode.commands.executeCommand("workbench.action.closeAllEditors"); - const panel = vscode.window.createWebviewPanel( - "basilisk.configurationEditorDomTest", - "Configuration Editor DOM Test", - vscode.ViewColumn.One, - { enableScripts: true, retainContextWhenHidden: true, localResourceRoots: [] }, - ); - try { - const result = await new Promise<DomTestResult>((resolve, reject) => { - let booted = false; - const timer = setTimeout(() => { - reject(new Error( - "the webview driver never reported a result " - + `(boot beacon ${booted ? "received" : "missing"}; panel visible=${panel.visible})`, - )); - }, RESULT_TIMEOUT_MS); - panel.webview.onDidReceiveMessage((message: Record<string, unknown>) => { - if (message.type === "domTestBoot") { booted = true; return; } - if (message.type === "domTestResult") { - clearTimeout(timer); - resolve(domTestResult(message)); - return; - } - if (message.type === "domTestSettle") { - void panel.webview.postMessage({ type: "state", state: host.settle() }); - return; - } - if (message.type !== "domTestIntent") { return; } - const state = host.receive(asRecord(message.intent)); - if (state !== undefined) { void panel.webview.postMessage({ type: "state", state }); } - }); - panel.webview.html = harnessDocument(driver); - }); - return { result, intents: host.intents }; - } finally { - panel.dispose(); - } -} - -/** Shared driver preamble: waiting, reporting, and reading the DOM back. */ diff --git a/vscode-extension/src/test/suite/withdrawal.test.ts b/vscode-extension/src/test/suite/withdrawal.test.ts new file mode 100644 index 000000000..5011834d9 --- /dev/null +++ b/vscode-extension/src/test/suite/withdrawal.test.ts @@ -0,0 +1,185 @@ +// Tests for [WITHDRAWAL-SURFACES]. See +// docs/specs/DOCS-WITHDRAWAL-MESSAGING-SPEC.md#WITHDRAWAL-SURFACES +/** + * The extension's whole contract: it says the approved statement, and it + * contains no type checker. The second half matters more than the first — a + * setting, a command, or a bundled binary creeping back would put the checker + * that produced incorrect results in front of users again. + */ + +import * as assert from "assert"; +import * as fs from "fs"; +import * as path from "path"; +import * as vscode from "vscode"; +import { + ANNOUNCED_KEY, + ANNOUNCEMENT, + NOTICE_URI, + SHOW_STATEMENT_COMMAND, + STATEMENT_URL, + announce, + extensionVersion, + shouldAnnounce, + statementText, + type AnnouncementState, +} from "../../extension"; + +const EXTENSION_ID = "Nimblesite.basilisk"; + +function extension(): vscode.Extension<unknown> { + const found = vscode.extensions.getExtension(EXTENSION_ID); + assert.ok(found, `${EXTENSION_ID} must be installed in the test host`); + return found; +} + +function isRecord(value: unknown): value is Record<string, unknown> { + return typeof value === "object" && value !== null; +} + +/** The manifest as shipped, read from disk rather than from the `any` API. */ +function manifest(): Record<string, unknown> { + const file = path.join(extension().extensionPath, "package.json"); + const parsed: unknown = JSON.parse(fs.readFileSync(file, "utf8")); + assert.ok(isRecord(parsed), "package.json must parse to an object"); + return parsed; +} + +function contributes(): Record<string, unknown> { + const value = manifest().contributes; + assert.ok(isRecord(value), "the manifest must have a contributes block"); + return value; +} + +/** A memento standing in for `globalState`. */ +function state(initial: string | undefined): AnnouncementState { + let stored = initial; + return { + get: (): string | undefined => stored, + update: async (_key: string, value: string): Promise<void> => { + stored = value; + }, + }; +} + +suite("Basilisk is a notice", () => { + test("activates", async () => { + await extension().activate(); + assert.strictEqual(extension().isActive, true); + }); + + test("the statement is the approved notice plus a pointer to the full one", () => { + const text = statementText(); + assert.ok(text.startsWith("Basilisk is unlisted."), text); + assert.ok(text.includes("checks nothing"), text); + assert.ok(text.includes("https://github.com/python/typing/pull/2330"), text); + assert.ok(text.includes("basilisk-conformance-apology"), text); + assert.ok(text.includes(STATEMENT_URL), text); + }); + + test("the announcement names the fault and asks for removal", () => { + assert.ok(ANNOUNCEMENT.includes("incorrect results"), ANNOUNCEMENT); + assert.ok(ANNOUNCEMENT.includes("Uninstall"), ANNOUNCEMENT); + }); + + test("showStatement opens the statement as a read-only document", async () => { + await extension().activate(); + await vscode.commands.executeCommand(SHOW_STATEMENT_COMMAND); + const opened = vscode.window.visibleTextEditors.find( + (editor) => editor.document.uri.scheme === NOTICE_URI.scheme, + ); + assert.ok(opened, "the statement must be visible in an editor"); + assert.strictEqual(opened.document.getText(), statementText()); + }); +}); + +suite("The announcement fires once per version", () => { + test("shouldAnnounce is true only for an unseen version", () => { + assert.strictEqual(shouldAnnounce(undefined, "1.0.0"), true); + assert.strictEqual(shouldAnnounce("0.9.0", "1.0.0"), true); + assert.strictEqual(shouldAnnounce("1.0.0", "1.0.0"), false); + }); + + test("extensionVersion falls back rather than throwing", () => { + assert.strictEqual(extensionVersion(undefined), "unknown"); + assert.strictEqual(extensionVersion({}), "unknown"); + assert.strictEqual(extensionVersion({ version: 7 }), "unknown"); + assert.strictEqual(extensionVersion({ version: "2.3.4" }), "2.3.4"); + }); + + test("choosing the action opens the statement, and the next activation is silent", async () => { + const seen: string[] = []; + const memento = state(undefined); + await announce(memento, "9.9.9", async (message, action) => { + seen.push(message); + return action; + }); + assert.deepStrictEqual(seen, [ANNOUNCEMENT]); + assert.strictEqual(memento.get(ANNOUNCED_KEY), "9.9.9"); + + await announce(memento, "9.9.9", async (message) => { + seen.push(message); + return undefined; + }); + assert.strictEqual(seen.length, 1, "an already-announced version must stay silent"); + + const opened = vscode.window.visibleTextEditors.find( + (editor) => editor.document.uri.scheme === NOTICE_URI.scheme, + ); + assert.ok(opened, "choosing the action must open the statement"); + }); + + test("dismissing the notification does not open the statement", async () => { + let prompted = 0; + await announce(state(undefined), "9.9.9", async () => { + prompted += 1; + return undefined; + }); + assert.strictEqual(prompted, 1); + }); + + test("a fresh version announces again", async () => { + let prompted = 0; + await announce(state("1.0.0"), "1.0.1", async () => { + prompted += 1; + return undefined; + }); + assert.strictEqual(prompted, 1); + }); +}); + +suite("No type checker ships in the VSIX", () => { + test("the manifest contributes nothing but the statement command", () => { + assert.deepStrictEqual(Object.keys(contributes()), ["commands"]); + const commands = contributes().commands; + assert.ok(Array.isArray(commands), "commands must be an array"); + const names = commands.map((entry) => (isRecord(entry) ? entry.command : undefined)); + assert.deepStrictEqual(names, [SHOW_STATEMENT_COMMAND]); + }); + + test("no setting, view, debugger, keybinding or walkthrough survives", () => { + for (const key of [ + "configuration", + "views", + "viewsContainers", + "viewsWelcome", + "debuggers", + "breakpoints", + "keybindings", + "menus", + "walkthroughs", + ]) { + assert.strictEqual(contributes()[key], undefined, `contributes.${key} must be gone`); + } + }); + + test("the package carries no runtime dependency and no bundled binary", () => { + assert.strictEqual(manifest().dependencies, undefined, "the notice needs no dependency"); + for (const directory of ["bin", "bundled"]) { + assert.strictEqual( + fs.existsSync(path.join(extension().extensionPath, directory)), + false, + `${directory}/ must not be packaged — the type checker must not ship`, + ); + } + }); +}); diff --git a/vscode-extension/src/timeouts.ts b/vscode-extension/src/timeouts.ts deleted file mode 100644 index 558761649..000000000 --- a/vscode-extension/src/timeouts.ts +++ /dev/null @@ -1,31 +0,0 @@ -// Implements [VSIX-ARCHITECTURE]. See docs/specs/VSIX-SPEC.md#VSIX-ARCHITECTURE -/** - * Canonical timeout constants for the Basilisk VS Code extension. - * - * Three knobs. No others. Anything that feels like it needs a fourth is a - * design smell — fix the underlying slowness, do not invent a new bucket. - */ - -/** Interval between polls. */ -export const POLL_INTERVAL_MS = 10; - -/** Max time to wait for a single command/event to settle at runtime. - * If a wait exceeds this, the operation is broken. */ -export const WAIT_MS = 1_000; - -/** Startup / cold-init timeout. Anything slower than this is a bug. */ -export const STARTUP_TIMEOUT_MS = 10_000; - -/** - * Resolve after `ms` milliseconds. - * - * The one place that wraps `setTimeout` in a promise. Written inline, the - * executor arrow returns the timer handle to a caller that can never see it — - * so every such site was a discarded value. Keeping it here also means a - * timing change happens once rather than in fifty copies. - */ -export async function delay(ms: number): Promise<void> { - return new Promise<void>((resolve) => { - setTimeout(resolve, ms); - }); -} diff --git a/vscode-extension/src/unknown-shape.ts b/vscode-extension/src/unknown-shape.ts deleted file mode 100644 index a8078812f..000000000 --- a/vscode-extension/src/unknown-shape.ts +++ /dev/null @@ -1,116 +0,0 @@ -// Implements [VSIX-ARCHITECTURE]. See docs/specs/VSIX-SPEC.md#VSIX-ARCHITECTURE -/** - * Runtime narrowing for values the extension does not own. - * - * DAP messages, LSP `experimental` capabilities, webview posts and - * `JSON.parse` results all arrive as `unknown`. Writing `payload as { id?: - * number }` at each read site *asserts* a shape the compiler then trusts - * forever — one protocol change and every downstream read is silently wrong - * with no error anywhere. These accessors check the shape at the moment of - * reading and return `undefined` when it does not hold, so a protocol drift - * surfaces as a missing value rather than as a lie the type system repeats. - * - * Read one field at a time. There is deliberately no `as SomeInterface` - * shortcut here — that is the very construct this module exists to replace. - */ - -/** Whether `value` is a non-null, non-array object with string keys. */ -export function isRecord(value: unknown): value is Record<string, unknown> { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -/** - * `value` as a keyed record, or an empty one when it is not an object. - * - * The empty fallback keeps callers free of null checks: every field read on it - * yields `undefined`, which is what an absent payload means anyway. - */ -export function asRecord(value: unknown): Record<string, unknown> { - return isRecord(value) ? value : {}; -} - -/** - * The raw `key` field of `value`, still `unknown`. - * - * For fields handed straight to another `unknown`-taking function, where - * narrowing here would only be undone at the other end. - */ -export function rawField(value: unknown, key: string): unknown { - return asRecord(value)[key]; -} - -/** The `key` field of `value` when it is a string, else `undefined`. */ -export function stringField(value: unknown, key: string): string | undefined { - const field = asRecord(value)[key]; - return typeof field === "string" ? field : undefined; -} - -/** The `key` field of `value` when it is a finite number, else `undefined`. */ -export function numberField(value: unknown, key: string): number | undefined { - const field = asRecord(value)[key]; - return typeof field === "number" && Number.isFinite(field) ? field : undefined; -} - -/** The `key` field of `value` when it is a boolean, else `undefined`. */ -export function booleanField(value: unknown, key: string): boolean | undefined { - const field = asRecord(value)[key]; - return typeof field === "boolean" ? field : undefined; -} - -/** The `key` field of `value` when it is itself a record, else `undefined`. */ -export function recordField( - value: unknown, - key: string, -): Record<string, unknown> | undefined { - const field = asRecord(value)[key]; - return isRecord(field) ? field : undefined; -} - -/** The `key` field of `value` as an array of unknowns; `[]` when absent. */ -export function arrayField(value: unknown, key: string): unknown[] { - const field = asRecord(value)[key]; - return Array.isArray(field) ? field : []; -} - -/** - * The `key` field of `value` as an array of records. - * - * Non-object elements are dropped rather than passed on as holes, so callers - * can read fields off every element without re-checking. - */ -export function recordArrayField( - value: unknown, - key: string, -): Record<string, unknown>[] { - return arrayField(value, key).filter(isRecord); -} - -/** The `key` field of `value` as an array of finite numbers; `[]` when absent. */ -export function numberArrayField(value: unknown, key: string): number[] { - return arrayField(value, key).filter( - (item): item is number => typeof item === "number" && Number.isFinite(item), - ); -} - -/** The `key` field of `value` as an array of strings; `[]` when absent. */ -export function stringArrayField(value: unknown, key: string): string[] { - return arrayField(value, key).filter( - (item): item is string => typeof item === "string", - ); -} - -/** - * Walk a chain of record keys, stopping at the first link that is not a record. - * - * `nested(message, "body", "source")` replaces `(message as { body?: { source?: - * X } }).body?.source` without asserting either level. - */ -export function nested( - value: unknown, - ...keys: string[] -): Record<string, unknown> | undefined { - return keys.reduce<Record<string, unknown> | undefined>( - (current, key) => (current === undefined ? undefined : recordField(current, key)), - asRecord(value), - ); -} diff --git a/vscode-extension/src/withdrawal-notice.ts b/vscode-extension/src/withdrawal-notice.ts new file mode 100644 index 000000000..f7250a54e --- /dev/null +++ b/vscode-extension/src/withdrawal-notice.ts @@ -0,0 +1,5 @@ +// GENERATED FILE — DO NOT EDIT. +// Source: docs/specs/DOCS-WITHDRAWAL-MESSAGING-SPEC.md [WITHDRAWAL-INERT-TEXT] +// Regenerate: python3 scripts/gen_withdrawal_copy.py +/** The approved notice, verbatim. */ +export const WITHDRAWAL_NOTICE = "Basilisk is unlisted. Its type checker is inert and checks nothing.\n\nBasilisk's type checker was producing incorrect results. The code responsible is not isolated to a known set of rules, so we cannot say how many rules are affected. We asked for Basilisk to be removed from the python/typing conformance results, and it has been removed: https://github.com/python/typing/pull/2330\n\nA code-quality tool that does not produce correct results is worse than useless. Remove Basilisk from your pipeline, your pre-commit hooks, and your editor. This command failed on purpose. It is not a finding about your code.\n\nWe are not fixing this code. We are rebuilding from the ground up as a new product, shipping only what can be trusted. If type checking ever returns, it will be externally audited before release.\n\nA full public account: https://www.christianfindlay.com/blog/basilisk-conformance-apology\n"; diff --git a/vscode-extension/test-fixtures/real-world-corpus.json b/vscode-extension/test-fixtures/real-world-corpus.json deleted file mode 100644 index 7b5cdc56b..000000000 --- a/vscode-extension/test-fixtures/real-world-corpus.json +++ /dev/null @@ -1,651 +0,0 @@ -{ - "$comment": "Implements [VSIX-REALWORLD-CORPUS]. See docs/specs/VSIX-REAL-WORLD-SPEC.md#VSIX-REALWORLD-CORPUS. Single source of truth for the real-world e2e corpus: consumed by scripts/fetch-real-world-repos.mjs (download+pin), .vscode-test.mjs (per-repo test configs), and src/test/real-world/ (the journey suites). Repos are pinned to exact commit SHAs (resolved from the named release tag) so every probe token below is verified against immutable content. Budgets are regression tripwires ([VSIX-REALWORLD-RESOURCES]): calibrated from measured runs, they only ratchet DOWN.", - "repos": [ - { - "name": "flask", - "org": "pallets", - "repo": "flask", - "tag": "3.1.1", - "commit": "7fff56f5172c48b6f3aedf17ee14ef5c2533dfd1", - "sentinel": "src/flask/app.py", - "minPythonFiles": 70, - "budgets": { - "maxServerRssMb": 200, - "maxServerLeakMb": 100, - "maxExtHostRssMb": 600, - "maxIdleCpuPercent": 25, - "cpuSettleTimeoutMs": 120000 - }, - "workspaceSymbols": [ - { - "query": "Flask", - "expectName": "Flask", - "expectFile": "src/flask/app.py" - }, - { - "query": "Blueprint", - "expectName": "Blueprint", - "expectFile": "src/flask/blueprints.py" - }, - { - "query": "RequestContext", - "expectName": "RequestContext", - "expectFile": "src/flask/ctx.py" - } - ], - "editChurn": { - "path": "src/flask/config.py", - "cycles": 3 - }, - "openBlitz": { - "dir": "src/flask", - "count": 12 - }, - "files": [ - { - "path": "src/flask/app.py", - "minDocumentSymbols": 2, - "expectSymbols": [ - "Flask", - "_make_timedelta", - "make_response", - "url_for", - "wsgi_app", - "test_client" - ], - "hovers": [ - { - "token": "class Flask(App)", - "at": "Flask", - "expect": [ - "Flask" - ] - }, - { - "token": "def make_response(self, rv: ft.ResponseReturnValue)", - "at": "make_response", - "expect": [ - "make_response" - ] - }, - { - "token": "response = self.make_response(rv)", - "at": "make_response", - "expect": [ - "make_response" - ] - }, - { - "token": "self.debug = get_debug_flag()", - "at": "get_debug_flag", - "expect": [ - "get_debug_flag" - ] - } - ], - "definitions": [ - { - "token": "class Flask(App)", - "at": "App", - "expectFile": "src/flask/sansio/app.py" - }, - { - "token": "self.debug = get_debug_flag()", - "at": "get_debug_flag", - "expectFile": "src/flask/helpers.py" - }, - { - "token": "from .ctx import AppContext", - "at": "AppContext", - "expectFile": "src/flask/ctx.py" - }, - { - "token": "response = self.make_response(rv)", - "at": "make_response", - "expectFile": "src/flask/app.py" - } - ], - "completions": [ - { - "token": "auto_reload = self.config[", - "afterDot": "self.", - "expect": [ - "make_response", - "url_for", - "dispatch_request" - ] - }, - { - "token": "url_for=self.url_for", - "afterDot": "self.", - "expect": [ - "url_for", - "make_response" - ] - } - ], - "references": [ - { - "token": "def make_response(self, rv: ft.ResponseReturnValue)", - "at": "make_response", - "minLocations": 2 - } - ] - }, - { - "path": "src/flask/helpers.py", - "minDocumentSymbols": 8, - "expectSymbols": [ - "url_for", - "redirect", - "abort", - "flash", - "send_file", - "get_debug_flag" - ], - "hovers": [ - { - "token": "def flash(message: str", - "at": "flash", - "expect": [ - "flash" - ] - }, - { - "token": "def get_debug_flag() -> bool", - "at": "get_debug_flag", - "expect": [ - "get_debug_flag" - ] - } - ], - "definitions": [ - { - "token": "from .globals import current_app", - "at": "current_app", - "expectFile": "src/flask/globals.py" - } - ], - "completions": [], - "references": [ - { - "token": "def redirect(", - "at": "redirect", - "minLocations": 1 - } - ] - }, - { - "path": "src/flask/wrappers.py", - "minDocumentSymbols": 2, - "expectSymbols": [ - "Request", - "Response", - "max_content_length", - "on_json_loading_failed" - ], - "hovers": [ - { - "token": "class Request(RequestBase)", - "at": "Request", - "expect": [ - "Request" - ] - }, - { - "token": "class Response(ResponseBase)", - "at": "Response", - "expect": [ - "Response" - ] - } - ], - "definitions": [ - { - "token": "from .globals import current_app", - "at": "current_app", - "expectFile": "src/flask/globals.py" - } - ], - "completions": [], - "references": [] - }, - { - "path": "src/flask/ctx.py", - "minDocumentSymbols": 3, - "expectSymbols": [ - "_AppCtxGlobals", - "AppContext", - "RequestContext" - ], - "hovers": [ - { - "token": "class AppContext", - "at": "AppContext", - "expect": [ - "AppContext" - ] - } - ], - "definitions": [], - "completions": [], - "references": [] - } - ] - }, - { - "name": "rich", - "org": "Textualize", - "repo": "rich", - "tag": "v14.3.4", - "commit": "ee8378c3bbbd7c75abc2f55c6c19e83b218ae81d", - "sentinel": "rich/console.py", - "minPythonFiles": 180, - "budgets": { - "maxServerRssMb": 300, - "maxServerLeakMb": 100, - "maxExtHostRssMb": 600, - "maxIdleCpuPercent": 25, - "cpuSettleTimeoutMs": 150000 - }, - "workspaceSymbols": [ - { - "query": "Console", - "expectName": "Console", - "expectFile": "rich/console.py" - }, - { - "query": "Segment", - "expectName": "Segment", - "expectFile": "rich/segment.py" - }, - { - "query": "StyleStack", - "expectName": "StyleStack", - "expectFile": "rich/style.py" - } - ], - "editChurn": { - "path": "rich/measure.py", - "cycles": 3 - }, - "openBlitz": { - "dir": "rich", - "count": 12 - }, - "files": [ - { - "path": "rich/console.py", - "minDocumentSymbols": 6, - "expectSymbols": [ - "Console", - "ConsoleOptions", - "Capture", - "ThemeContext", - "ScreenUpdate", - "print", - "log" - ], - "hovers": [ - { - "token": "class Console:", - "at": "Console", - "expect": [ - "Console" - ] - }, - { - "token": "self.print(NewLine(count))", - "at": "print", - "expect": [ - "print" - ] - }, - { - "token": "yield Segment(\"\\n\" * self.count)", - "at": "Segment", - "expect": [ - "Segment" - ] - } - ], - "definitions": [ - { - "token": "from .segment import Segment", - "at": "Segment", - "expectFile": "rich/segment.py" - }, - { - "token": "from .style import Style, StyleType", - "at": "Style", - "expectFile": "rich/style.py" - }, - { - "token": "from .text import Text, TextType", - "at": "Text", - "expectFile": "rich/text.py" - }, - { - "token": "yield Segment(\"\\n\" * self.count)", - "at": "Segment", - "expectFile": "rich/segment.py" - } - ], - "completions": [ - { - "token": "self.print(NewLine(count))", - "afterDot": "self.", - "expect": [ - "print", - "log", - "file" - ] - } - ], - "references": [ - { - "token": "def log(", - "at": "log", - "minLocations": 1 - } - ] - }, - { - "path": "rich/text.py", - "minDocumentSymbols": 2, - "expectSymbols": [ - "Text", - "Span", - "plain" - ], - "hovers": [ - { - "token": "class Text(JupyterMixin)", - "at": "Text", - "expect": [ - "Text" - ] - }, - { - "token": "class Span(NamedTuple)", - "at": "Span", - "expect": [ - "Span" - ] - } - ], - "definitions": [ - { - "token": "from .style import Style, StyleType", - "at": "Style", - "expectFile": "rich/style.py" - } - ], - "completions": [ - { - "token": "return self.plain == other.plain", - "afterDot": "self.", - "expect": [ - "plain" - ] - } - ], - "references": [] - }, - { - "path": "rich/table.py", - "minDocumentSymbols": 3, - "expectSymbols": [ - "Table", - "Column", - "Row" - ], - "hovers": [ - { - "token": "class Table(JupyterMixin)", - "at": "Table", - "expect": [ - "Table" - ] - }, - { - "token": "class Column:", - "at": "Column", - "expect": [ - "Column" - ] - } - ], - "definitions": [ - { - "token": "from .text import Text, TextType", - "at": "Text", - "expectFile": "rich/text.py" - } - ], - "completions": [], - "references": [] - }, - { - "path": "rich/style.py", - "minDocumentSymbols": 3, - "expectSymbols": [ - "Style", - "StyleStack", - "_Bit" - ], - "hovers": [ - { - "token": "class Style:", - "at": "Style", - "expect": [ - "Style" - ] - } - ], - "definitions": [], - "completions": [], - "references": [] - } - ] - }, - { - "name": "fastapi", - "org": "fastapi", - "repo": "fastapi", - "tag": "0.116.1", - "commit": "313723494be79d4b24ccaa60e4f6d1f96c150fed", - "sentinel": "fastapi/applications.py", - "minPythonFiles": 1000, - "budgets": { - "maxServerRssMb": 400, - "maxServerLeakMb": 150, - "maxExtHostRssMb": 600, - "maxIdleCpuPercent": 25, - "cpuSettleTimeoutMs": 240000 - }, - "workspaceSymbols": [ - { - "query": "APIRouter", - "expectName": "APIRouter", - "expectFile": "fastapi/routing.py" - }, - { - "query": "jsonable_encoder", - "expectName": "jsonable_encoder", - "expectFile": "fastapi/encoders.py" - }, - { - "query": "SolvedDependency", - "expectName": "SolvedDependency", - "expectFile": "fastapi/dependencies/utils.py" - } - ], - "editChurn": { - "path": "fastapi/exceptions.py", - "cycles": 3 - }, - "openBlitz": { - "dir": "fastapi", - "count": 12 - }, - "files": [ - { - "path": "fastapi/applications.py", - "minDocumentSymbols": 1, - "expectSymbols": [ - "FastAPI" - ], - "hovers": [ - { - "token": "class FastAPI(Starlette)", - "at": "FastAPI", - "expect": [ - "FastAPI" - ] - }, - { - "token": "self.openapi_schema = get_openapi(", - "at": "get_openapi", - "expect": [ - "get_openapi" - ] - } - ], - "definitions": [ - { - "token": "from fastapi.openapi.utils import get_openapi", - "at": "get_openapi", - "expectFile": "fastapi/openapi/utils.py" - }, - { - "token": "RequestValidationError, request_validation_exception_handler", - "at": "RequestValidationError", - "expectFile": "fastapi/exceptions.py" - }, - { - "token": "from fastapi.params import Depends", - "at": "Depends", - "expectFile": "fastapi/params.py" - } - ], - "completions": [ - { - "token": "if not self.openapi_schema", - "afterDot": "self.", - "expect": [ - "openapi", - "setup", - "add_api_route" - ] - } - ], - "references": [ - { - "token": "self.openapi_url = openapi_url", - "at": "openapi_url", - "minLocations": 2 - } - ] - }, - { - "path": "fastapi/routing.py", - "minDocumentSymbols": 3, - "expectSymbols": [ - "APIRoute", - "APIRouter", - "APIWebSocketRoute", - "get_request_handler" - ], - "hovers": [ - { - "token": "class APIRouter(routing.Router)", - "at": "APIRouter", - "expect": [ - "APIRouter" - ] - }, - { - "token": "class APIRoute(routing.Route)", - "at": "APIRoute", - "expect": [ - "APIRoute" - ] - } - ], - "definitions": [ - { - "token": "from fastapi.encoders import jsonable_encoder", - "at": "jsonable_encoder", - "expectFile": "fastapi/encoders.py" - } - ], - "completions": [], - "references": [ - { - "token": "from fastapi.encoders import jsonable_encoder", - "at": "jsonable_encoder", - "minLocations": 2 - } - ] - }, - { - "path": "fastapi/encoders.py", - "minDocumentSymbols": 3, - "expectSymbols": [ - "jsonable_encoder", - "decimal_encoder", - "isoformat" - ], - "hovers": [ - { - "token": "def jsonable_encoder(", - "at": "jsonable_encoder", - "expect": [ - "jsonable_encoder" - ] - } - ], - "definitions": [], - "completions": [], - "references": [] - }, - { - "path": "fastapi/params.py", - "minDocumentSymbols": 8, - "expectSymbols": [ - "Param", - "Path", - "Query", - "Header", - "Cookie", - "Body", - "Form", - "File", - "Depends", - "Security" - ], - "hovers": [ - { - "token": "class Query(Param)", - "at": "Query", - "expect": [ - "Query" - ] - }, - { - "token": "class Body(FieldInfo)", - "at": "Body", - "expect": [ - "Body" - ] - } - ], - "definitions": [], - "completions": [], - "references": [] - } - ] - } - ] -} diff --git a/vscode-license-manifest.json b/vscode-license-manifest.json index d57451768..9176ffa4e 100644 --- a/vscode-license-manifest.json +++ b/vscode-license-manifest.json @@ -1,206 +1,5 @@ { - "carrier_sha256": "f826cd08aed485527db45a2394ed34fb0083db2b514471e94958f36f6619d8e7", - "dependencies": [ - { - "name": "@nimblesite/shipwright-core", - "version": "0.10.0", - "license": "MIT", - "repository": "https://github.com/Nimblesite/Shipwright.git", - "resolved": "https://registry.npmjs.org/@nimblesite/shipwright-core/-/shipwright-core-0.10.0.tgz", - "integrity": "sha512-vVbQ2K5VmOwkZ9zsiG0jGsVACJJ79R44vk0iUMR+4a8/8e+oYB330lv3pAQDFSwUDnH/3SGA7LXLDH8xB5LHCQ==", - "legal_files": [ - { - "label": "shared Shipwright repository LICENSE", - "source": "node_modules/@nimblesite/shipwright-vscode/LICENSE", - "sha256": "032c14bd0ff61c4ea546e23e8849b74a68770e9b91375d25a772b20587dc3159" - } - ] - }, - { - "name": "@nimblesite/shipwright-vscode", - "version": "0.10.0", - "license": "MIT", - "repository": "https://github.com/Nimblesite/Shipwright.git", - "resolved": "https://registry.npmjs.org/@nimblesite/shipwright-vscode/-/shipwright-vscode-0.10.0.tgz", - "integrity": "sha512-rdTYt+jetNtxcEhAwJFYcyPlvnzhzCJxSNTrmMuXYLoL7ES38xbErJW36GfxWliUgvOVsL7XI3KtUZAewptdpA==", - "legal_files": [ - { - "label": "LICENSE", - "source": "node_modules/@nimblesite/shipwright-vscode/LICENSE", - "sha256": "032c14bd0ff61c4ea546e23e8849b74a68770e9b91375d25a772b20587dc3159" - } - ] - }, - { - "name": "@preact/signals-core", - "version": "1.14.4", - "license": "MIT", - "repository": "https://github.com/preactjs/signals", - "resolved": "https://registry.npmjs.org/@preact/signals-core/-/signals-core-1.14.4.tgz", - "integrity": "sha512-HNB6HYeYKhQbJ1aKl+YRjrS4+QWHLKX6qKoUsfS/m0vqzsVaEBiZiaKbG/e+NKk2ch5ALQr/ihWaMHxiCuuWHA==", - "legal_files": [ - { - "label": "LICENSE", - "source": "node_modules/@preact/signals-core/LICENSE", - "sha256": "a11fc89e4c6b118854c7a667734a0b2e6bf2af5e45c6686de31adbccc8f3ae8d" - } - ] - }, - { - "name": "balanced-match", - "version": "4.0.4", - "license": "MIT", - "repository": "git://github.com/juliangruber/balanced-match.git", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "legal_files": [ - { - "label": "LICENSE.md", - "source": "node_modules/brace-expansion/node_modules/balanced-match/LICENSE.md", - "sha256": "d408f38ffa3355c5faec517153295338892eb0f1ea43f57874bb23c6075979b5" - } - ] - }, - { - "name": "brace-expansion", - "version": "5.0.9", - "license": "MIT", - "repository": "git+https://github.com/juliangruber/brace-expansion.git", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", - "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", - "legal_files": [ - { - "label": "LICENSE", - "source": "node_modules/brace-expansion/LICENSE", - "sha256": "9c63a23124d68cd30cd316a94a1a0bca34f032786df6df69fc4b5f136bac8d2e" - } - ] - }, - { - "name": "minimatch", - "version": "10.2.5", - "license": "BlueOak-1.0.0", - "repository": "git@github.com:isaacs/minimatch", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", - "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", - "legal_files": [ - { - "label": "LICENSE.md", - "source": "node_modules/minimatch/LICENSE.md", - "sha256": "2c7c5d22ed5a8ee968c64757710979afcd77438c48b4a265b94e615babd8a901" - } - ] - }, - { - "name": "semver", - "version": "7.8.2", - "license": "ISC", - "repository": "git+https://github.com/npm/node-semver.git", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.2.tgz", - "integrity": "sha512-c8jsqUZm3omBOI66G90z1Dyw5z622G8oLG+omfsHBJf3CWQTlOcwOjvOG6wtiNfW6anKm/eA39LMwMtMez2TiQ==", - "legal_files": [ - { - "label": "LICENSE", - "source": "node_modules/semver/LICENSE", - "sha256": "4ec3d4c66cd87f5c8d8ad911b10f99bf27cb00cdfcff82621956e379186b016b" - } - ] - }, - { - "name": "vscode-jsonrpc", - "version": "9.0.1", - "license": "MIT", - "repository": "https://github.com/Microsoft/vscode-languageserver-node.git", - "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-9.0.1.tgz", - "integrity": "sha512-rfuA6T75H6m5EkbhtEPzre9pT0HPcDI2MMy4+nPFIBks5J8JBAUHD4tRYSgaBOijIEC7SRkC1kKyXTLqbmh9jw==", - "legal_files": [ - { - "label": "License.txt", - "source": "node_modules/vscode-jsonrpc/License.txt", - "sha256": "ec9ee83580841e8eb687aca9867f221503809ba6426c7f876ede17d91b9fcfd0" - }, - { - "label": "thirdpartynotices.txt", - "source": "node_modules/vscode-jsonrpc/thirdpartynotices.txt", - "sha256": "a89123562fe364dc8e969e85614eb6c1f8452afe131d6e42bd1314bb2d092b04" - } - ] - }, - { - "name": "vscode-languageclient", - "version": "10.1.0", - "license": "MIT", - "repository": "https://github.com/Microsoft/vscode-languageserver-node.git", - "resolved": "https://registry.npmjs.org/vscode-languageclient/-/vscode-languageclient-10.1.0.tgz", - "integrity": "sha512-XXRx6lqVitQy/oOLr9MfNYRG+MbQkhXkDaxbQMiKxEm8zZNfheRFUKNb8UYNh2stn9btl2wQM5wZFJjJvoc+jA==", - "legal_files": [ - { - "label": "License.txt", - "source": "node_modules/vscode-languageclient/License.txt", - "sha256": "ec9ee83580841e8eb687aca9867f221503809ba6426c7f876ede17d91b9fcfd0" - } - ] - }, - { - "name": "vscode-languageserver-protocol", - "version": "3.18.2", - "license": "MIT", - "repository": "https://github.com/Microsoft/vscode-languageserver-node.git", - "resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.18.2.tgz", - "integrity": "sha512-XRyDbT0Pp3sSNti3JmxVEUMySWCSi1hhM+/KUlCy1hV1zmrqpM1OwO12EAki8blhmLuIMpaJrYbo0OzGVfK2Qg==", - "legal_files": [ - { - "label": "License.txt", - "source": "node_modules/vscode-languageserver-protocol/License.txt", - "sha256": "ec9ee83580841e8eb687aca9867f221503809ba6426c7f876ede17d91b9fcfd0" - }, - { - "label": "thirdpartynotices.txt", - "source": "node_modules/vscode-languageserver-protocol/thirdpartynotices.txt", - "sha256": "9265d27cf75775aa5ae19c5ba01846fb70ecd5121f8c39c81bef7e03f007072c" - } - ] - }, - { - "name": "vscode-languageserver-textdocument", - "version": "1.0.13", - "license": "MIT", - "repository": "https://github.com/Microsoft/vscode-languageserver-node.git", - "resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.13.tgz", - "integrity": "sha512-nx0ZHwMGIsVkzFG3/VLeJYBLTaFBRuNdGDvevvjuoayU5EOS2fEYazOhtCM3PI9ClMMg5igc0uwXtAq4tJj+Dw==", - "legal_files": [ - { - "label": "License.txt", - "source": "node_modules/vscode-languageserver-textdocument/License.txt", - "sha256": "ec9ee83580841e8eb687aca9867f221503809ba6426c7f876ede17d91b9fcfd0" - }, - { - "label": "thirdpartynotices.txt", - "source": "node_modules/vscode-languageserver-textdocument/thirdpartynotices.txt", - "sha256": "a89123562fe364dc8e969e85614eb6c1f8452afe131d6e42bd1314bb2d092b04" - } - ] - }, - { - "name": "vscode-languageserver-types", - "version": "3.18.0", - "license": "MIT", - "repository": "https://github.com/Microsoft/vscode-languageserver-node.git", - "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.18.0.tgz", - "integrity": "sha512-8TsGPNMIMiiBdkORgRSvLjuiEIiAFtO+KssmYWxQ+uSVvlf7RjK8YKCOjPzZ+YA04jXEV7+7LvkSmHkhpNS99g==", - "legal_files": [ - { - "label": "License.txt", - "source": "node_modules/vscode-languageserver-types/License.txt", - "sha256": "ec9ee83580841e8eb687aca9867f221503809ba6426c7f876ede17d91b9fcfd0" - }, - { - "label": "thirdpartynotices.txt", - "source": "node_modules/vscode-languageserver-types/thirdpartynotices.txt", - "sha256": "a89123562fe364dc8e969e85614eb6c1f8452afe131d6e42bd1314bb2d092b04" - } - ] - } - ], - "production_graph_sha256": "393945c298e4e686471c62592a55a0ecedde9a2fe5782e696613f4f79d5f2bba" + "carrier_sha256": "b5605c7ae131255f9f2a55095428ae6cdd33e1f89e6ac1c08bbe089cfa5e0a58", + "dependencies": [], + "production_graph_sha256": "37517e5f3dc66819f61f5a7bb8ace1921282415f10551d2defa5c3eb0985b570" } diff --git a/website/_hero_verify.mjs b/website/_hero_verify.mjs deleted file mode 100644 index 10bc481d1..000000000 --- a/website/_hero_verify.mjs +++ /dev/null @@ -1,49 +0,0 @@ -import { chromium } from 'playwright-core'; - -const chromePath = chromium.executablePath(); -const BASE = 'http://localhost:8199/'; -const OUT = '/private/tmp/claude-501/-Users-christianfindlay-Documents-Code-Basilisk/179e3680-02ec-4d6e-9c31-c7911d352e54/scratchpad'; - -const viewports = [ - { name: 'wide', width: 2560, height: 1440, dsf: 1 }, - { name: 'desktop', width: 1440, height: 900, dsf: 1 }, - { name: 'mobile', width: 390, height: 844, dsf: 2 }, -]; - -const browser = await chromium.launch({ executablePath: chromePath, headless: true }); -for (const vp of viewports) { - const page = await browser.newPage({ viewport: { width: vp.width, height: vp.height }, deviceScaleFactor: vp.dsf }); - await page.goto(BASE, { waitUntil: 'networkidle' }); - await page.waitForTimeout(250); - const m = await page.evaluate(() => { - const q = (s) => document.querySelector(s); - const r = (el) => { if (!el) return null; const b = el.getBoundingClientRect(); return { w: Math.round(b.width), h: Math.round(b.height), top: Math.round(b.top), left: Math.round(b.left), right: Math.round(b.right), bottom: Math.round(b.bottom) }; }; - const vw = window.innerWidth, vh = window.innerHeight; - const hero = q('.hero'); - const split = q('.hero__split'); - const img = q('.hero__shot'); - const sb = split ? split.getBoundingClientRect() : null; - return { - vw, vh, - heroH: hero ? Math.round(hero.getBoundingClientRect().height) : null, - split: r(split), - // side gutter = empty space from viewport edge to the content block - gutterLeft: sb ? Math.round(sb.left) : null, - gutterRight: sb ? Math.round(vw - sb.right) : null, - // vertical gap from hero top/bottom to the content block (within the hero) - gapTop: (split && hero) ? Math.round(sb.top - hero.getBoundingClientRect().top) : null, - gapBottom: (split && hero) ? Math.round(hero.getBoundingClientRect().bottom - sb.bottom) : null, - text: r(q('.hero__split > div:first-child')), - shot: r(img), - shotDisplayRatio: img ? (img.getBoundingClientRect().width / img.getBoundingClientRect().height).toFixed(3) : null, - shotNatRatio: img ? (img.naturalWidth / img.naturalHeight).toFixed(3) : null, - metaLines: (() => { const meta = q('.hero__meta'); if (!meta) return null; const tops = new Set([...meta.querySelectorAll('span')].map(s => Math.round(s.getBoundingClientRect().top))); return tops.size; })(), - }; - }); - console.log(`\n=== ${vp.name} ${vp.width}x${vp.height} ===`); - console.log(JSON.stringify(m)); - await page.screenshot({ path: `${OUT}/hero-${vp.name}.png`, fullPage: false }); - await page.close(); -} -await browser.close(); -console.log('\nDONE'); diff --git a/website/eleventy.config.js b/website/eleventy.config.js index 1ebba9045..19348e7d0 100644 --- a/website/eleventy.config.js +++ b/website/eleventy.config.js @@ -2,39 +2,26 @@ import { readFileSync, writeFileSync, existsSync } from "fs"; import { dirname, join } from "path"; import { fileURLToPath } from "url"; import techdoc from "eleventy-plugin-techdoc"; -import markdownIt from "markdown-it"; -import markdownItAnchor from "markdown-it-anchor"; +import withdrawal from "./src/_data/withdrawal.json" with { type: "json" }; const __dirname = dirname(fileURLToPath(import.meta.url)); -// Patch techdoc templates and behavior with project-owned versions. The plugin -// reads these files after this config loads, so the copies survive a fresh npm -// install without maintaining a fork of the package. +// Patch techdoc templates with project-owned versions. The plugin reads these +// files after this config loads, so the copies survive a fresh npm install +// without maintaining a fork of the package. const templateOverrides = [ ["src/assets/js/mobile-menu.js", "assets/js/mobile-menu.js"], ["src/_includes/layouts/base.njk", "templates/layouts/base.njk"], - ["src/_includes/layouts/blog.njk", "templates/layouts/blog.njk"], - ["src/_includes/layouts/docs.njk", "templates/layouts/docs.njk"], - ["src/_includes/pages/feed.njk", "templates/pages/feed.njk"], ["src/_includes/pages/robots.txt.njk", "templates/pages/robots.txt.njk"], ["src/_includes/pages/sitemap.njk", "templates/pages/sitemap.njk"], - ["src/_includes/pages/blog/index.njk", "templates/pages/blog/index.njk"], - ["src/_includes/pages/blog/tags.njk", "templates/pages/blog/tags.njk"], - ["src/_includes/pages/blog/tags-pages.njk", "templates/pages/blog/tags-pages.njk"], - ["src/_includes/pages/blog/categories.njk", "templates/pages/blog/categories.njk"], - ["src/_includes/pages/blog/categories-pages.njk", "templates/pages/blog/categories-pages.njk"], + ["src/_includes/pages/llms.txt.njk", "templates/pages/llms.txt.njk"], + ["src/_includes/pages/feed.njk", "templates/pages/feed.njk"], ]; // The copy is LINE-ENDING NORMALIZED, and must stay that way. These overrides // are working-tree files, so on Windows — where git's `autocrlf` default checks -// them out CRLF — a verbatim copy hands the plugin CRLF template content. Every -// probe below (`patchIndexFrontMatter`'s `^title: .*$` replacements, -// `addSharedProseClass`'s literal `class="docs-content"`, and above all -// `addLocalizedTemplateLang`'s `lang: zh` guard) is written against LF, so on a -// CRLF copy the guard silently missed an existing `lang: zh` and inserted a -// SECOND one — "duplicated mapping key", and the whole site build died on -// Windows while Linux CI stayed green. Normalizing here makes the bytes the -// plugin sees identical on every platform, so one probe cannot pass on Linux and -// fail on Windows. On Linux this is a no-op: the files are already LF. +// them out CRLF — a verbatim copy hands the plugin CRLF template content, and +// every literal probe written against LF silently misses. Normalizing here makes +// the bytes the plugin sees identical on every platform. On Linux it is a no-op. const toLf = (text) => text.replace(/\r\n/g, "\n"); for (const [source, target] of templateOverrides) { @@ -49,193 +36,40 @@ for (const [source, target] of templateOverrides) { } } -// SEO metadata for the plugin-generated blog / tags / categories index pages. -// The techdoc plugin registers these as virtual templates (node_modules-only, -// no source file to edit) whose default front matter only sets a bare title -// ("Blog", "Tags", "Categories") and no description, so every index inherits the -// generic site description — non-unique and too short for SEO. We can't add a -// same-path source override (Eleventy errors when a file collides with a virtual -// template), and the plugin registers its templates AFTER this config callback -// returns, so the virtualTemplates map is empty here. Instead we wrap -// addTemplate() below and patch each index template's front matter as the plugin -// registers it — project-level, leaving node_modules untouched. -// [SEO index metadata override] -const indexSeo = { - "blog/index.njk": { - title: "Basilisk Blog — Python Type-Checking News & Releases", - description: - "News, releases, and deep dives from Basilisk — the open-source, strict-by-default Python language server in Rust for VS Code, Cursor, Zed, and Neovim.", - }, - "blog/tags.njk": { - title: "Blog Tags — Browse Basilisk Posts by Topic", - description: - "Browse Basilisk blog posts by tag to find writing on Python type checking, strict typing, LSP features, refactoring, debugging, profiling, and release notes.", - }, - "blog/categories.njk": { - title: "Blog Categories — Browse Basilisk Posts by Section", - description: - "Browse Basilisk blog posts by category to explore announcements, deep dives, and release notes for the strict-by-default Python language server built in Rust.", - }, - "zh/blog/index.njk": { - title: "Basilisk 博客 — Python 类型检查动态与版本发布", - description: - "来自 Basilisk 项目的动态、版本发布与深入解析——一个用 Rust 构建、严格优先的开源 Python 语言服务器,支持 VS Code、Cursor、Zed 与 Neovim。", - }, - "zh/blog/tags.njk": { - title: "博客标签 — 按主题浏览 Basilisk 文章", - description: - "按标签浏览 Basilisk 博客文章,查找有关 Python 类型检查、严格类型、LSP 功能、重构、调试、性能分析与版本发布说明的内容,按主题快速定位。", - }, - "zh/blog/categories.njk": { - title: "博客分类 — 按栏目浏览 Basilisk 文章", - description: - "按分类浏览 Basilisk 博客文章,探索这个用 Rust 构建、严格优先的开源 Python 语言服务器的公告、深入解析与版本发布说明等栏目内容。", - }, -}; - -function patchIndexFrontMatter(path, content) { - const meta = indexSeo[path]; - if (!meta) { - return content; - } - return content - .replace(/^title: .*$/m, `title: "${meta.title}"`) - .replace(/^(title: .*)$/m, `$1\ndescription: "${meta.description}"`); -} - -function addSharedProseClass(path, content) { - return path === "_includes/layouts/api.njk" - ? content.replace('class="docs-content"', 'class="docs-content prose"') - : content; -} - -function addLocalizedTemplateLang(path, content) { - return path.startsWith("zh/blog/") && !content.includes("\nlang: zh\n") - ? content.replace("layout: layouts/base.njk", "layout: layouts/base.njk\nlang: zh") - : content; -} - export default function (eleventyConfig) { - const originalAddTemplate = eleventyConfig.addTemplate.bind(eleventyConfig); - eleventyConfig.addTemplate = (virtualInputPath, content, data) => - originalAddTemplate( - virtualInputPath, - patchIndexFrontMatter( - virtualInputPath, - addSharedProseClass( - virtualInputPath, - addLocalizedTemplateLang(virtualInputPath, content) - ) - ), - data - ); - eleventyConfig.addPlugin(techdoc, { + // Implements [WITHDRAWAL-COPY]: the site's own description is the approved + // one-line copy, generated from the messaging spec. site: { name: "Basilisk", url: "https://www.basilisk-python.dev", - description: - "Open-source Python type checker and language server built in Rust. Conformance and benchmark results are withdrawn during an integrity review.", + description: withdrawal.line, author: "The Basilisk Project", themeColor: "#e8500a", stylesheet: "/assets/css/styles.css", - ogImage: "/assets/images/og-image.png", organization: { name: "Basilisk", url: "https://www.basilisk-python.dev", logo: "/assets/images/favicon.png", - sameAs: [ - "https://github.com/Nimblesite/Basilisk", - ], + sameAs: ["https://github.com/Nimblesite/Basilisk"], }, }, + // The site serves one statement and a notice at every retired URL. There is + // no blog, no docs tree, and no second language to translate into: the + // approved copy exists only in English ([WITHDRAWAL-COPY]). features: { - blog: true, - docs: true, + blog: false, + docs: false, darkMode: true, - i18n: true, - }, - // Register the languages the site actually ships so the base layout emits a - // complete hreflang cluster (en + zh + x-default) and og:locale:alternate. - // Without this, supportedLanguages defaults to ['en'] and the Chinese pages - // are never declared as alternates — Google can't connect / ⇄ /zh/. - i18n: { - defaultLanguage: "en", - languages: ["en", "zh"], + i18n: false, }, }); - // Preserve CJK headings in fragment identifiers. Techdoc's default slugger - // strips all non-ASCII letters, which produces empty and numeric-only IDs on - // Chinese prose pages and breaks their heading permalinks. - const markdown = markdownIt({ html: true, breaks: false, linkify: true }).use( - markdownItAnchor, - { - level: [1, 2, 3, 4], - permalink: markdownItAnchor.permalink.headerLink(), - slugify: (value) => - value - .normalize("NFKC") - .toLowerCase() - .trim() - .replace(/[^\p{Letter}\p{Number}_-]+/gu, "-") - .replace(/^-+|-+$/g, ""), - } - ); - // Eleventy executes plugins after the project config returns, so register - // this override as the next plugin to ensure it runs after techdoc's default. - eleventyConfig.addPlugin((config) => config.setLibrary("md", markdown)); - eleventyConfig.addPassthroughCopy("src/assets"); - eleventyConfig.addPassthroughCopy({ - "node_modules/monaco-editor/min/vs": "assets/vendor/monaco/vs", - "node_modules/lz-string/libs/lz-string.min.js": "assets/vendor/lz-string.min.js", - }); eleventyConfig.addPassthroughCopy("src/CNAME"); - // [Author pages] Posts written by a given author, matched on the post's - // `author` front-matter string == the author's `name` in _data/authors.json. - // Newest first, English posts only (Chinese posts carry their own byline). - eleventyConfig.addFilter("authorPosts", (posts, authorName) => - (posts || []) - .filter((p) => p.data.author === authorName && !p.url.startsWith("/zh/")) - .sort((a, b) => b.date - a.date) - ); - - // [Author pages] Plain-text truncation for meta descriptions built from a bio. - eleventyConfig.addFilter("truncate", (str, len) => { - const s = String(str || ""); - if (s.length <= len) return s; - return s.slice(0, s.lastIndexOf(" ", len)).trimEnd() + "…"; - }); - - const categoryLabels = { - en: { announcements: "Announcements", "deep-dives": "Deep dives" }, - zh: { announcements: "公告", "deep-dives": "深度解析" }, - }; - eleventyConfig.addFilter("blogCategoryLabel", (category, lang = "en") => - categoryLabels[lang]?.[category] || - String(category || "").replaceAll("-", " ") - ); - - const docsNavActive = (node, currentUrl) => { - const current = String(currentUrl || "").replace(/^\/zh(?=\/)/, ""); - if ( - node?.kind === "rules" && - (current.startsWith("/docs/rules/") || current.startsWith("/errors/")) - ) { - return true; - } - if (node?.url === current) return true; - return [...(node?.items || []), ...(node?.children || [])].some((child) => - docsNavActive(child, current) - ); - }; - eleventyConfig.addFilter("docsNavActive", docsNavActive); - // Base layout guard: only advertise a language alternate when Eleventy - // actually generated that URL. This prevents hreflang and switcher 404s on - // English-only docs, author profiles, benchmarks, and diagnostic pages. + // actually generated that URL. eleventyConfig.addFilter("hasPageUrl", (pages, url) => (pages || []).some((page) => page.url === url) ); @@ -247,7 +81,7 @@ export default function (eleventyConfig) { includes: "_includes", data: "_data", }, - templateFormats: ["md", "njk", "html"], + templateFormats: ["njk", "html"], markdownTemplateEngine: "njk", htmlTemplateEngine: "njk", }; diff --git a/website/package-lock.json b/website/package-lock.json index 1feff7b86..743901836 100644 --- a/website/package-lock.json +++ b/website/package-lock.json @@ -7,16 +7,11 @@ "": { "name": "basilisk-website", "version": "1.0.0", - "dependencies": { - "lz-string": "^1.5.0", - "monaco-editor": "^0.56.0" - }, "devDependencies": { "@11ty/eleventy": "^3.1.6", "@playwright/test": "^1.62.1", "@types/node": "^26.1.2", - "eleventy-plugin-techdoc": "^0.2.0", - "markdown-it": "^15.0.0" + "eleventy-plugin-techdoc": "^0.2.0" } }, "node_modules/@11ty/dependency-tree": { @@ -748,13 +743,6 @@ "undici-types": "~8.3.0" } }, - "node_modules/@types/trusted-types": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", - "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", - "license": "MIT", - "optional": true - }, "node_modules/a-sync-waterfall": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/a-sync-waterfall/-/a-sync-waterfall-1.0.1.tgz", @@ -1066,15 +1054,6 @@ "url": "https://github.com/fb55/domhandler?sponsor=1" } }, - "node_modules/dompurify": { - "version": "3.4.8", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.8.tgz", - "integrity": "sha512-yb1cEmaOum7wFvOCSQxyfgVlv5D47Rc30iZWoMpbDIWTnJ6grDDQyu2KFJzB2k7u0pMuJcQ1zphH//fFnw2tjQ==", - "license": "(MPL-2.0 OR Apache-2.0)", - "optionalDependencies": { - "@types/trusted-types": "^2.0.7" - } - }, "node_modules/domutils": { "version": "2.8.0", "resolved": "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz", @@ -1751,6 +1730,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "uc.micro": "^3.0.0" } @@ -1793,15 +1773,6 @@ "node": ">=12" } }, - "node_modules/lz-string": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", - "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", - "license": "MIT", - "bin": { - "lz-string": "bin/bin.js" - } - }, "node_modules/markdown-it": { "version": "15.0.0", "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-15.0.0.tgz", @@ -1818,6 +1789,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "argparse": "^3.0.0", "entities": "^8.0.0", @@ -1856,7 +1828,8 @@ "url": "https://github.com/sponsors/nodeca" } ], - "license": "Python-2.0" + "license": "Python-2.0", + "peer": true }, "node_modules/markdown-it/node_modules/entities": { "version": "8.0.0", @@ -1864,6 +1837,7 @@ "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "engines": { "node": ">=20.19.0" }, @@ -1871,18 +1845,6 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, - "node_modules/marked": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz", - "integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==", - "license": "MIT", - "bin": { - "marked": "bin/marked.js" - }, - "engines": { - "node": ">= 18" - } - }, "node_modules/mdurl": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.1.0.tgz", @@ -1963,16 +1925,6 @@ "node": ">=16 || 14 >=14.17" } }, - "node_modules/monaco-editor": { - "version": "0.56.0", - "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.56.0.tgz", - "integrity": "sha512-sXboRm3BeBeLm938eaiyLMe0OxzfXIlZvbv4ir/jVgQy1zDhWjgmny0WoN45fuDKhCCQsYMbBJrv/A6jd8aCUg==", - "license": "MIT", - "dependencies": { - "dompurify": "3.4.8", - "marked": "14.0.0" - } - }, "node_modules/moo": { "version": "0.5.2", "resolved": "https://registry.npmjs.org/moo/-/moo-0.5.2.tgz", @@ -2471,7 +2423,8 @@ "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-3.0.0.tgz", "integrity": "sha512-U3PppEkleoTnIfi8BozMx3yju3qc/L6SwqWo2Sw+54PX+PX0q9I+r1Um5HCmqD7n9VDX5/v3vQH/AjA6deDdtw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/undici-types": { "version": "8.3.0", diff --git a/website/package.json b/website/package.json index e04e73486..5f865d2b6 100644 --- a/website/package.json +++ b/website/package.json @@ -3,13 +3,11 @@ "version": "1.0.0", "private": true, "type": "module", - "description": "Website for Basilisk, the open-source Python type checker and language server built in Rust.", + "description": "Website for Basilisk.", "scripts": { "build": "eleventy", - "build:wasm": "npx --yes wasm-pack build ../crates/basilisk-wasm --target web --release --out-dir ../../website/src/assets/wasm --out-name basilisk_wasm", "start": "eleventy --serve --watch", "clean": "rm -rf _site", - "screenshots": "node screenshots/generate.mjs", "test:e2e": "playwright test", "test:e2e:ui": "playwright test --ui" }, @@ -17,11 +15,6 @@ "@11ty/eleventy": "^3.1.6", "@playwright/test": "^1.62.1", "@types/node": "^26.1.2", - "eleventy-plugin-techdoc": "^0.2.0", - "markdown-it": "^15.0.0" - }, - "dependencies": { - "lz-string": "^1.5.0", - "monaco-editor": "^0.56.0" + "eleventy-plugin-techdoc": "^0.2.0" } } diff --git a/website/screenshots/ansi.mjs b/website/screenshots/ansi.mjs deleted file mode 100644 index 85cc0d1e5..000000000 --- a/website/screenshots/ansi.mjs +++ /dev/null @@ -1,72 +0,0 @@ -// Implements [WEBSITE-SCREENSHOTS-ANSI]: faithful ANSI SGR → HTML conversion for -// the exact escape sequences `basilisk check --color always` emits. See -// docs/specs/WEBSITE-SCREENSHOTS-SPEC.md. -// -// basilisk uses a small, fixed palette: reset (0), bold (1), and bold foreground -// red (31, errors), yellow (33, warnings), blue (34, gutters), cyan (36, labels). -// We model exactly that set rather than a general 256-colour terminal, so the -// output is deterministic and matches a real macOS Terminal window pixel-for-pixel. - -// Colours tuned to match macOS Terminal's default dark profile as it renders the -// real binary — the values our committed reference PNGs were captured with. -const FOREGROUND = { - default: "#d6d6d6", // unstyled text (the echoed source line) - bold: "#f4f4f4", // bold, no colour (diagnostic message) - 31: "#ff6b5e", // red — error / summary - 33: "#e8c062", // yellow — warning - 34: "#7d8cff", // blue — `-->`, `|`, `=`, line numbers - 36: "#4ec9d4", // cyan — help / note / see labels -}; - -const ESCAPE_PATTERN = /\x1b\[([0-9;]*)m/g; - -const escapeHtml = (text) => - text - .replace(/&/g, "&") - .replace(/</g, "<") - .replace(/>/g, ">"); - -const initialState = () => ({ bold: false, color: null }); - -// Fold one SGR parameter list into the running style state. -const applyParams = (state, params) => { - const codes = params === "" ? [0] : params.split(";").map(Number); - return codes.reduce((next, code) => { - if (code === 0) return initialState(); - if (code === 1) return { ...next, bold: true }; - if (code >= 30 && code <= 37) return { ...next, color: code }; - if (code >= 90 && code <= 97) return { ...next, color: code - 60 }; - return next; - }, state); -}; - -const colorFor = (state) => { - if (state.color !== null && FOREGROUND[state.color]) return FOREGROUND[state.color]; - return state.bold ? FOREGROUND.bold : FOREGROUND.default; -}; - -const wrap = (text, state) => { - if (text === "") return ""; - const weight = state.bold ? "700" : "400"; - return `<span style="color:${colorFor(state)};font-weight:${weight}">${escapeHtml(text)}</span>`; -}; - -/** - * Convert a string containing basilisk's ANSI escape sequences into themed HTML. - * Unstyled runs still emit a span so every glyph carries the terminal foreground. - */ -export const ansiToHtml = (raw) => { - let html = ""; - let state = initialState(); - let cursor = 0; - - for (const match of raw.matchAll(ESCAPE_PATTERN)) { - html += wrap(raw.slice(cursor, match.index), state); - state = applyParams(state, match[1]); - cursor = match.index + match[0].length; - } - html += wrap(raw.slice(cursor), state); - return html; -}; - -export const TERMINAL_FOREGROUND = FOREGROUND; diff --git a/website/screenshots/generate.mjs b/website/screenshots/generate.mjs deleted file mode 100644 index 71d28dbbd..000000000 --- a/website/screenshots/generate.mjs +++ /dev/null @@ -1,95 +0,0 @@ -// Implements [WEBSITE-SCREENSHOTS] / [WEBSITE-SCREENSHOTS-PURPOSE]: the single, -// fully automated, reproducible command that produces the site's CLI screenshots — -// real `basilisk check --color always` output, PII-free, with a built-in guard -// that every snippet still triggers the diagnostic it documents. -// Implements [WEBSITE-SCREENSHOTS-GENERATE]: regenerate every CLI screenshot on -// the site from the real `basilisk` binary, with no manual Terminal/screencapture -// step. See docs/specs/WEBSITE-SCREENSHOTS-SPEC.md. -// -// For each shot it writes the snippet to a throwaway, neutrally-named directory -// (so diagnostic paths read `e0001.py:1:13` with no PII), runs -// `basilisk check --color always <file>` there, asserts the documented code -// actually fires, renders the output inside a macOS Terminal window via Playwright, -// and writes website/src/assets/images/<name>.png at 2× for crisp Retina display. -// -// Usage: node screenshots/generate.mjs (regenerate all) -// node screenshots/generate.mjs e0001 e0012 (regenerate a subset) -// BASILISK_BIN=../target/release/basilisk node screenshots/generate.mjs - -import { chromium } from "@playwright/test"; -import { execFileSync } from "node:child_process"; -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; - -import { SHOTS } from "./shots.mjs"; -import { buildTerminalHtml, WINDOW_SELECTOR } from "./terminal.mjs"; - -const here = path.dirname(fileURLToPath(import.meta.url)); -const OUTPUT_DIR = path.resolve(here, "../src/assets/images"); -const BIN = process.env.BASILISK_BIN ?? "basilisk"; -const SCALE = 2; - -const stripAnsi = (text) => text.replace(/\x1b\[[0-9;]*m/g, ""); - -// Run `basilisk check` in `cwd`. A non-zero exit is expected whenever diagnostics -// are reported, so we read the captured stdout off the thrown error too. -const runChecker = (file, cwd) => { - try { - return execFileSync(BIN, ["check", "--color", "always", file], { - cwd, - encoding: "utf8", - maxBuffer: 8 * 1024 * 1024, - }); - } catch (error) { - if (typeof error.stdout === "string" && error.stdout.length > 0) return error.stdout; - throw new Error(`basilisk failed for ${file}: ${error.stderr || error.message}`); - } -}; - -const captureShot = async (page, shot, workDir) => { - fs.writeFileSync(path.join(workDir, shot.file), shot.code); - const output = runChecker(shot.file, workDir); - - if (!stripAnsi(output).includes(shot.expect)) { - throw new Error( - `${shot.name}: expected "${shot.expect}" in output but it was absent — ` + - `the snippet no longer triggers the documented diagnostic.\n${stripAnsi(output)}`, - ); - } - - await page.setContent( - buildTerminalHtml({ command: `basilisk check ${shot.file}`, ansiOutput: output }), - { waitUntil: "load" }, - ); - const target = OUTPUT_DIR + path.sep + `${shot.name}.png`; - await page.locator(WINDOW_SELECTOR).screenshot({ path: target }); - const kb = Math.round(fs.statSync(target).size / 1024); - console.log(` ✓ ${shot.name}.png (${kb} KB) [${shot.expect}]`); -}; - -const main = async () => { - const requested = new Set(process.argv.slice(2)); - const shots = requested.size === 0 ? SHOTS : SHOTS.filter((s) => requested.has(s.name)); - if (shots.length === 0) throw new Error(`no shots matched: ${[...requested].join(", ")}`); - - fs.mkdirSync(OUTPUT_DIR, { recursive: true }); - const workDir = fs.mkdtempSync(path.join(os.tmpdir(), "basilisk-demo-")); - console.log(`Generating ${shots.length} screenshot(s) → src/assets/images/`); - - const browser = await chromium.launch(); - const page = await browser.newPage({ viewport: { width: 1200, height: 2400 }, deviceScaleFactor: SCALE }); - try { - for (const shot of shots) await captureShot(page, shot, workDir); - } finally { - await browser.close(); - fs.rmSync(workDir, { recursive: true, force: true }); - } - console.log("Done."); -}; - -main().catch((error) => { - console.error(`screenshots: ${error.message}`); - process.exit(1); -}); diff --git a/website/screenshots/shots.mjs b/website/screenshots/shots.mjs deleted file mode 100644 index 700e7ea17..000000000 --- a/website/screenshots/shots.mjs +++ /dev/null @@ -1,275 +0,0 @@ -// Implements [WEBSITE-SCREENSHOTS-MANIFEST]: the single source of truth for every -// CLI screenshot on the site. See docs/specs/WEBSITE-SCREENSHOTS-SPEC.md. -// -// Each entry pairs the EXACT snippet shown in the docs with the diagnostic code -// that snippet must produce. The generator runs the real `basilisk` binary on the -// snippet and refuses to write the image unless `expect` appears in the output — -// this is the automated form of the "verify the example actually triggers the -// rule" rule, so a checker behaviour change can never silently produce a -// misleading screenshot. -// -// `name` is the output PNG stem (website/src/assets/images/<name>.png) and matches -// the reference used by the docs page (e.g. `e0001` → e0001.png). - -// Rule screenshots — the "# Error" snippet from docs/rules/*.md, crafted so that -// exactly the documented rule fires (e.g. e0001 keeps `-> str` so only E0001, -// not E0002, is reported). -const RULE_SHOTS = [ - { - name: "e0001", - expect: "BSK-0001", - code: `def process(data) -> str: - return data.upper() -`, - }, - { - name: "e0002", - expect: "BSK-0002", - code: `def get_user(user_id: int): - return {"id": user_id} -`, - }, - { - name: "e0003", - expect: "BSK-0003", - code: `data = [] -`, - }, - { - name: "e0004", - expect: "BSK-0004", - code: `def log(*args, **kwargs) -> None: - print(args, kwargs) -`, - }, - { - name: "e0005", - expect: "BSK-0005", - code: `class Registry: - entries = [] -`, - }, - { - name: "e0010", - expect: "imports_unresolved", - code: `from legacy_module import process_data -`, - }, - { - name: "e0011", - expect: "BSK-0014", - code: `from typing import Any - - -def handle(data: Any) -> bool: - return True -`, - }, - { - name: "e0012", - expect: "calls_argument_type", - code: `def greet(name: str) -> str: - return f"Hello, {name}" - - -greet(42) -`, - }, - { - name: "e0013", - expect: "returns_compatibility_2", - code: `def get_count() -> int: - return "many" -`, - }, - { - name: "e0014", - expect: "assignment_compatibility", - code: `count: int = "zero" -`, - }, - { - name: "e0015", - expect: "callables_annotation", - code: `x: dict[str] = {} -`, - }, - { - name: "e0016", - expect: "classes_override", - code: `from typing import override - - -class Base: - def process(self, data: str) -> str: - return data - - -class Child(Base): - @override - def process(self, data: int) -> str: - return str(data) -`, - }, - { - name: "e0018", - expect: "names_undefined", - code: `def f() -> int: - return missing_local -`, - }, - { - name: "e0019", - expect: "names_unbound", - code: `def check(flag: bool) -> str: - if flag: - result = "yes" - return result -`, - }, - { - name: "e0025", - expect: "BSK-0025", - code: `class Base: - def process(self) -> str: - return "base" - - -class Child(Base): - def process(self) -> str: - return "child" -`, - }, - { - name: "e0017", - expect: "classes_override_2", - code: `class Base: - x: int - - -class Child(Base): - x: str -`, - }, - { - name: "e0020", - expect: "overloads_definitions", - code: `from typing import overload - - -@overload -def f(x: int) -> int: ... -@overload -def f(x: str) -> str: ... -`, - }, - { - name: "e0023", - expect: "match_exhaustiveness", - code: `def classify(x: int | str) -> str: - match x: - case int(): - return "number" -`, - }, - { - name: "e0026", - expect: "generics_basic", - code: `from typing import TypeVar - -T = TypeVar("T", int) -`, - }, - { - name: "e0027", - expect: "generics_base_class", - code: `from typing import Generic, TypeVar - -T = TypeVar("T") - - -class Box(Generic[T, T]): - ... -`, - }, - { - name: "e0029", - expect: "typeddicts_class_syntax", - code: `from typing import TypedDict - - -class Movie(TypedDict): - title: str - - def play(self) -> None: - ... -`, - }, - { - name: "e0031", - expect: "directives_cast", - code: `from typing import cast - -x = cast(int) -`, - }, - { - name: "e0033", - expect: "directives_reveal_type", - code: `reveal_type() -`, - }, - { - name: "e0040", - expect: "enums_behaviors", - code: `from enum import Enum - - -class Base(Enum): - A = 1 - - -class Sub(Base): - B = 2 -`, - }, - { - name: "e0041", - expect: "calls_argument_count", - code: `def add(x: int, y: int) -> int: - return x + y - - -add(1) -`, - }, - { - name: "e0099", - expect: "protocols_explicit", - code: `from typing import Protocol - - -class P(Protocol): - def f(self) -> None: ... - - -P() -`, - }, - { - name: "e0115", - expect: "directives_deprecated", - code: `from warnings import deprecated - - -@deprecated("use bar") -def foo() -> None: ... - - -foo() -`, - }, -]; - -// A rule shot's source file is named after the image stem (e0001 → e0001.py). -export const SHOTS = RULE_SHOTS.map((shot) => ({ ...shot, file: `${shot.name}.py` })); diff --git a/website/screenshots/terminal.mjs b/website/screenshots/terminal.mjs deleted file mode 100644 index caf4e1559..000000000 --- a/website/screenshots/terminal.mjs +++ /dev/null @@ -1,109 +0,0 @@ -// Implements [WEBSITE-SCREENSHOTS-CHROME]: the macOS Terminal.app window chrome -// (traffic-light buttons, folder + title bar, dark body) the CLI screenshots are -// framed in. See docs/specs/WEBSITE-SCREENSHOTS-SPEC.md. -// -// This reproduces in HTML what the old manual process captured with -// Terminal.app + screencapture: a 120-column window titled "basilisk-demo — -zsh" -// on the default dark profile, so regenerated images are visually identical to -// the originals but fully reproducible and PII-free. - -import { ansiToHtml } from "./ansi.mjs"; - -// 120-column window, matching the original `120×26` captures. Width is fixed in -// `ch` so every screenshot lines up at the same column width; height is content. -const COLUMNS = 120; -const TITLE = "basilisk-demo — -zsh"; - -// macOS "open folder" Finder glyph, inlined so rendering needs no network/font. -const FOLDER_ICON = `<svg width="15" height="15" viewBox="0 0 16 16" fill="none" aria-hidden="true"> - <path d="M1.5 4.2c0-.6.5-1.1 1.1-1.1h3.1c.3 0 .6.1.8.4l.8.9h5.1c.6 0 1.1.5 1.1 1.1v1H1.5V4.2z" fill="#7fb3ff"/> - <path d="M1.5 6.1h13.1l-1 6c-.1.5-.5.9-1.1.9H2.6c-.5 0-1-.4-1.1-.9l-1-6z" fill="#9cc6ff"/> -</svg>`; - -const STYLE = ` - * { margin: 0; padding: 0; box-sizing: border-box; } - html, body { background: transparent; } - body { padding: 24px; display: inline-block; } - .window { - display: inline-block; - border-radius: 10px; - overflow: hidden; - box-shadow: 0 22px 70px rgba(0, 0, 0, 0.55); - font-family: "SF Mono", "Menlo", "Monaco", "Consolas", monospace; - } - .titlebar { - position: relative; - height: 30px; - display: flex; - align-items: center; - padding: 0 12px; - background: linear-gradient(#3c3c3e, #303032); - border-bottom: 1px solid #1f1f21; - } - .lights { display: flex; gap: 8px; } - .light { width: 13px; height: 13px; border-radius: 50%; } - .light.close { background: #ff5f57; border: 0.5px solid #e0443e; } - .light.min { background: #febc2e; border: 0.5px solid #dea123; } - .light.expand { background: #28c840; border: 0.5px solid #1aab29; } - .title { - position: absolute; - left: 0; right: 0; - display: flex; - align-items: center; - justify-content: center; - gap: 6px; - font: 500 13px -apple-system, "SF Pro Text", "Helvetica Neue", sans-serif; - color: #c7c7c9; - pointer-events: none; - } - .body { - background: rgb(30, 30, 30); - color: #d6d6d6; - padding: 14px 18px 16px; - font-size: 12px; - line-height: 1.5; - width: ${COLUMNS}ch; - } - .body pre { - font-family: inherit; - font-size: inherit; - white-space: pre-wrap; - word-break: break-word; - tab-size: 4; - } - .prompt { color: #d6d6d6; } -`; - -// One Terminal "size" suffix per window, e.g. "120×26", computed from the lines -// shown so the title bar reads like a real session. -const sizeSuffix = (lineCount) => `${COLUMNS}×${Math.max(lineCount, 26)}`; - -/** - * Build a complete HTML document for one screenshot. - * - * @param {string} command - the command echoed after the prompt, e.g. "basilisk check e0001.py". - * @param {string} ansiOutput - raw stdout from the binary, including ANSI escapes. - */ -export const buildTerminalHtml = ({ command, ansiOutput }) => { - const outputHtml = ansiToHtml(ansiOutput.replace(/\n+$/, "")); - const lineCount = ansiOutput.split("\n").length + 3; - const body = `<span class="prompt">$ </span>${command}\n${outputHtml}\n<span class="prompt">$ </span>`; - - return `<!doctype html> -<html><head><meta charset="utf-8"><style>${STYLE}</style></head> -<body> - <div class="window"> - <div class="titlebar"> - <div class="lights"> - <span class="light close"></span> - <span class="light min"></span> - <span class="light expand"></span> - </div> - <div class="title">${FOLDER_ICON}<span>${TITLE} — ${sizeSuffix(lineCount)}</span></div> - </div> - <div class="body"><pre>${body}</pre></div> - </div> -</body></html>`; -}; - -export const WINDOW_SELECTOR = ".window"; diff --git a/website/src/404.njk b/website/src/404.njk new file mode 100644 index 000000000..ddc8f3686 --- /dev/null +++ b/website/src/404.njk @@ -0,0 +1,18 @@ +--- +layout: layouts/base.njk +permalink: /404.html +noTranslation: true +robots: "noindex, follow" +eleventyComputed: + title: "{{ withdrawal.title }}" + description: "{{ withdrawal.line }}" +--- + +{#- Implements [WITHDRAWAL-COPY-SHORT] for any URL the retired list does not + cover. Copy comes from the messaging spec via _data/withdrawal.json. -#} +<article class="notice"> + {% for paragraph in withdrawal.short %} + <p>{{ paragraph | safe }}</p> + {% endfor %} + <p><a href="/">Read the full statement</a></p> +</article> diff --git a/website/src/_data/authors.json b/website/src/_data/authors.json deleted file mode 100644 index 224c8aa4b..000000000 --- a/website/src/_data/authors.json +++ /dev/null @@ -1,42 +0,0 @@ -{ - "_doc": "Author registry — the single source of truth for blog author pages. Each entry generates a page at /authors/<slug>/ via src/authors/author.njk, and blog post bylines (author: field, matched by `name` or `nameZh`) link to it. Add an author here, set `author: <name>` (or the localized `nameZh` in a zh post) in a post's front matter, and the byline links automatically.", - "authors": [ - { - "slug": "basilisk-team", - "name": "The Basilisk Project", - "nameZh": "Basilisk 项目", - "shortName": "Basilisk Team", - "role": "The team behind Basilisk", - "avatar": "/assets/images/authors/basilisk-team.png", - "bio": "The Basilisk Project is the team voice for everyone who contributes to Basilisk, an open-source Python type checker and language server built in Rust. Basilisk is built by Nimblesite through a human and AI development process. The project is currently strengthening its review, auditing, and robustness-testing practices after withdrawing its former conformance result.", - "links": [ - { "label": "GitHub", "url": "https://github.com/Nimblesite/Basilisk" }, - { "label": "Discord", "url": "https://discord.gg/4wBDSGEZQd" }, - { "label": "Nimblesite", "url": "https://www.nimblesite.co" } - ], - "sameAs": [ - "https://github.com/Nimblesite/Basilisk", - "https://www.nimblesite.co" - ] - }, - { - "slug": "christian-findlay", - "name": "Christian Findlay", - "nameZh": "Christian Findlay", - "shortName": "Christian Findlay", - "role": "Director, Nimblesite", - "avatar": "/assets/images/authors/christian-findlay.png", - "bio": "Christian Findlay is the director of Nimblesite and the person behind Basilisk. He has spent more than two decades building software across .NET, Dart, Flutter, and Rust, and writes about type systems, developer experience, and building software with AI. He is leading the project's current conformance remediation and review-process changes.", - "links": [ - { "label": "Website", "url": "https://www.christianfindlay.com" }, - { "label": "GitHub", "url": "https://github.com/MelbourneDeveloper" }, - { "label": "Medium", "url": "https://cfdevelop.medium.com" } - ], - "sameAs": [ - "https://www.christianfindlay.com", - "https://github.com/MelbourneDeveloper", - "https://cfdevelop.medium.com" - ] - } - ] -} diff --git a/website/src/_data/benchmarks.js b/website/src/_data/benchmarks.js deleted file mode 100644 index ba804314d..000000000 --- a/website/src/_data/benchmarks.js +++ /dev/null @@ -1,189 +0,0 @@ -// Eleventy global data for withdrawn historical benchmark results, read from the -// git-tracked per-machine CSV that `make bench` generated. -// -// The integrity review has withdrawn these measurements from comparison. This -// loader preserves the old table for transparency; derived medians and fastest -// fields are historical implementation details and must not drive public claims. -// -// Historical primary-machine selection (what the withdrawn table preserves): -// 1. $BASILISK_BENCH_PRIMARY (slug) 2. benchmarks/status/.primary file -// 3. otherwise rank by tool coverage (a CSV missing competitor columns must -// never win), then prefer `gha-*` (stable CI hardware), then alphabetical -import { readFileSync, readdirSync, existsSync } from "fs"; -import { dirname, join } from "path"; -import { fileURLToPath } from "url"; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const STATUS_DIR = join(__dirname, "../../../benchmarks/status"); - -// Parse the CSV `# tools:` header into [{ tool, version }] for the methodology -// footnote. The header looks like: -// "basilisk=basilisk 0.0.0, pyright=pyright 1.1.408, mypy=mypy 1.19.1 (compiled: yes), ..." -// i.e. comma-separated `name=<--version output>` entries. The version output -// usually repeats the tool name and may carry a trailing parenthetical, both of -// which we strip so the site shows a clean "pyright 1.1.408". This is metadata -// pass-through — the harness records each installed tool's version output, and -// the page shows a cleaned form of that recorded value. -function parseToolVersions(toolsStr) { - if (!toolsStr) return []; - return toolsStr - .split(/,\s+(?=[a-z0-9_]+=)/i) - .map((entry) => { - const eq = entry.indexOf("="); - const tool = (eq >= 0 ? entry.slice(0, eq) : entry).trim(); - let version = (eq >= 0 ? entry.slice(eq + 1) : "").replace(/\s*\(.*\)\s*$/, "").trim(); - const prefix = `${tool} `; - if (version.toLowerCase().startsWith(prefix.toLowerCase())) { - version = version.slice(prefix.length).trim(); - } - // Dev builds don't have a released version number. Preserve the source - // identifier emitted in 0.0.0-dev+g<sha> and keep a dirty marker visible. - // A bare placeholder degrades to a plain "dev build" label. - const devPin = version.match(/dev\+g([0-9a-f]+(?:-dirty)?)/i); - if (devPin) version = `dev (${devPin[1]})`; - else if (!version || /placeholder/i.test(version)) version = "dev build"; - return { tool, version }; - }) - .filter((t) => t.tool); -} - -function parseCsv(text) { - const meta = {}; - const dataLines = []; - for (const raw of text.split(/\r?\n/)) { - const line = raw.trim(); - if (!line) continue; - if (line.startsWith("#")) { - const m = line.slice(1).match(/^\s*([^:]+):\s*(.*)$/); - if (m) meta[m[1].trim()] = m[2].trim(); - } else { - dataLines.push(line); - } - } - if (dataLines.length < 2) return null; - - // Friendly minimum run count: the header begins with the number of Hyperfine - // measurements required for every file, followed by the noisy-run policy. - const runsMatch = (meta.runs || "").match(/^\d+/); - meta.runsCount = runsMatch ? runsMatch[0] : null; - meta.toolVersions = parseToolVersions(meta.tools); - - // Column layout: `fixture`, then one `<tool>_ms` per timed tool. Diagnostic - // columns follow, but the benchmark page intentionally presents timings only. - // A blank `_ms` cell means the tool was unavailable or failed preflight. - const msIdx = new Map(); - dataLines[0].split(",").forEach((c, i) => { - if (c.endsWith("_ms")) msIdx.set(c.slice(0, -"_ms".length), i); - }); - const allTools = [...msIdx.keys()]; - // Warm-cache variants (…-warm) aren't separate checkers, so exclude them from - // the historical cold medians. Their per-file values stay in rows. - const tools = allTools.filter((t) => !t.endsWith("-warm")); - const rows = dataLines.slice(1).map((line) => { - const parts = line.split(","); - const num = (i) => - i == null || parts[i] === undefined || parts[i] === "" ? null : parseFloat(parts[i]); - const values = {}; - const valueText = {}; - for (const [tool, index] of msIdx) { - values[tool] = num(index); - valueText[tool] = parts[index] ? `${parts[index]} ms` : "—"; - } - return { - fixture: parts[0], - filename: `${parts[0]}.py`, - values, - valueText, - }; - }); - return { meta, tools, allTools, rows }; -} - -function median(nums) { - const s = [...nums].sort((a, b) => a - b); - const mid = Math.floor(s.length / 2); - return s.length % 2 ? s[mid] : (s[mid - 1] + s[mid]) / 2; -} - -// Historical per-checker median cold full-file time. This and `fastest` remain -// available only to preserve the old data shape; neither is publishable while -// the benchmark methodology and results are under integrity review. -function computeToolMedians(rows, tools) { - const ms = {}; - const text = {}; - for (const tool of tools) { - if (tool.endsWith("-warm")) continue; - const vals = rows.map((r) => r.values[tool]).filter((v) => v != null && v > 0); - ms[tool] = vals.length ? Math.round(median(vals)) : null; - text[tool] = ms[tool] == null ? "—" : `${ms[tool]} ms`; - } - const ranked = Object.entries(ms).filter(([, v]) => v != null); - const fastest = ranked.length - ? ranked.reduce((best, entry) => (entry[1] < best[1] ? entry : best))[0] - : null; - return { ms, text, fastest }; -} - -// How many tool columns in a CSV carry at least one real measurement. A machine -// that only ran basilisk scores 1; a full competitor sweep scores every tool. -// Used to keep an incomplete CSV from ever becoming the site's primary and -// rendering a benchmark table full of empty competitor columns. -function toolCoverage(file) { - const parsed = parseCsv(readFileSync(join(STATUS_DIR, file), "utf-8")); - if (!parsed) return -1; - return parsed.tools.filter((t) => parsed.rows.some((r) => r.values[t] != null)) - .length; -} - -function pickPrimary(files) { - // Explicit overrides win, in order: env var, then a committed .primary pin. - const env = process.env.BASILISK_BENCH_PRIMARY; - if (env && files.includes(`${env}.csv`)) return `${env}.csv`; - const primaryFile = join(STATUS_DIR, ".primary"); - if (existsSync(primaryFile)) { - const slug = readFileSync(primaryFile, "utf-8").trim(); - if (files.includes(`${slug}.csv`)) return `${slug}.csv`; - } - // Automatic fallback: NEVER let an incomplete CSV (e.g. a machine that only - // ran basilisk) win and drop competitor columns. Rank by tool coverage first, - // then stable CI hardware (gha-*), then alphabetical for determinism. - const coverage = new Map(files.map((f) => [f, toolCoverage(f)])); - return [...files].sort( - (a, b) => - coverage.get(b) - coverage.get(a) || - (a.startsWith("gha-") ? 0 : 1) - (b.startsWith("gha-") ? 0 : 1) || - a.localeCompare(b), - )[0]; -} - -export default function () { - const empty = { - available: [], - primary: null, - meta: {}, - tools: [], - rows: [], - hasData: false, - withdrawn: true, - publicationStatus: "historical-withdrawn", - }; - if (!existsSync(STATUS_DIR)) return empty; - - const files = readdirSync(STATUS_DIR).filter((f) => f.endsWith(".csv")).sort(); - if (files.length === 0) return empty; - - const primary = pickPrimary(files); - const parsed = parseCsv(readFileSync(join(STATUS_DIR, primary), "utf-8")); - if (!parsed) return empty; - - // Preserve the old measurements as explicitly withdrawn historical data. - return { - available: files.map((f) => f.replace(/\.csv$/, "")), - primary: primary.replace(/\.csv$/, ""), - ...parsed, - toolMedians: computeToolMedians(parsed.rows, parsed.tools), - hasData: parsed.rows.length > 0, - withdrawn: true, - publicationStatus: "historical-withdrawn", - }; -} diff --git a/website/src/_data/conformance.js b/website/src/_data/conformance.js deleted file mode 100644 index 33f49c086..000000000 --- a/website/src/_data/conformance.js +++ /dev/null @@ -1,253 +0,0 @@ -// Eleventy global data retained for the conformance integrity audit. These are -// historical outputs from the python/typing harness, not a current Basilisk -// conformance result. The former result is withdrawn because fitted checker -// logic made it untrustworthy; public pages must not present these values as a -// score, standing, pass count, or proof of implementation quality. -// -// conformance/conformance_status.csv -> historical per-file output -// website/src/_data/conformance_report.json -> historical run metadata -// git log of conformance_status.csv -> historical audit trail -// -// A file was marked passing when the harness reported no diagnostic diff. That -// records what happened in the exact fixtures; it does not establish general -// conformance. Values are exposed only under `historical` with an explicit -// withdrawn status. -import { readFileSync, existsSync } from "fs"; -import { execFileSync } from "child_process"; -import { dirname, join } from "path"; -import { fileURLToPath } from "url"; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const REPO_ROOT = join(__dirname, "../../.."); -const CONF_DIR = join(REPO_ROOT, "conformance"); -const STATUS_REL = "conformance/conformance_status.csv"; -const STATUS_CSV = join(CONF_DIR, "conformance_status.csv"); -// The exact historical python/typing snapshot and withdrawn fixture-result -// metadata. It lives in this same _data dir; it is not current-main data. -const REPORT = join(__dirname, "conformance_report.json"); - -// The day the official python/typing scoring rules replaced our earlier in-repo -// script. That script excluded some diagnostic codes and did not count false -// positives, so it miscalculated the score (up to 100%). Commits dated on/after -// this used the official scoring semantics; before, the earlier in-repo -// measurement. -const OFFICIAL_SINCE = "2026-06-23"; - -// The CSV stores lowercase category slugs; these render the few that are not a -// plain title-case word. Everything else falls back to capitalising the slug. -const CATEGORY_LABELS = { - typeddicts: "TypedDicts", - namedtuples: "NamedTuples", - typeforms: "TypeForms", - specialtypes: "Special types", -}; - -const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; - -const round1 = (n) => Math.round(n * 10) / 10; -const labelFor = (slug) => CATEGORY_LABELS[slug] || (slug ? slug.charAt(0).toUpperCase() + slug.slice(1) : "—"); - -// "2026-06-21" -> "Jun 21" (manual parse — no timezone surprises). -function shortDate(iso) { - const [, m, d] = iso.split("-").map((p) => parseInt(p, 10)); - return Number.isFinite(m) && Number.isFinite(d) ? `${MONTHS[m - 1]} ${d}` : iso; -} - -// Read the machine-readable report, which is the single source for the upstream -// commit. Written by the pristine fixture runner; never hand-edited. -function readReport() { - if (!existsSync(REPORT)) return null; - try { - return JSON.parse(readFileSync(REPORT, "utf-8")); - } catch { - return null; - } -} - -// Tally one CSV body (pass/total/fp/missed) from its raw text. -function tally(csvText) { - const rows = csvText.split(/\r?\n/).slice(1).filter((l) => l.trim() && !l.startsWith("#")); - const t = { pass: 0, total: 0, fp: 0, missed: 0, byFile: rows }; - for (const line of rows) { - const f = line.split(","); - if (f.length < 7) continue; - t.total += 1; - if (f[3] === "PASS") t.pass += 1; - t.missed += parseInt(f[5], 10) || 0; - t.fp += parseInt(f[6], 10) || 0; - } - return t; -} - -function parseStatus() { - if (!existsSync(STATUS_CSV)) return null; - const text = readFileSync(STATUS_CSV, "utf-8"); - const t = tally(text); - if (!t.total) return null; - - const cats = new Map(); - const failing = []; - let caught = 0; - for (const line of t.byFile) { - const f = line.split(","); - if (f.length < 7) continue; - const passed = f[3] === "PASS"; - const slug = f[2]; - const missed = parseInt(f[5], 10) || 0; - const fp = parseInt(f[6], 10) || 0; - caught += parseInt(f[4], 10) || 0; - if (!cats.has(slug)) cats.set(slug, { slug, label: labelFor(slug), pass: 0, total: 0 }); - const entry = cats.get(slug); - entry.total += 1; - entry.pass += passed ? 1 : 0; - if (!passed) failing.push({ file: f[1], category: slug, missed, fp }); - } - - const categories = [...cats.values()] - .filter((c) => c.slug) - .map((c) => ({ ...c, pct: round1((c.pass / c.total) * 100) })) - .sort((a, b) => a.label.localeCompare(b.label)); - - return { - pass: t.pass, - total: t.total, - fail: t.total - t.pass, - caught, - missed: t.missed, - fp: t.fp, - scorePct: round1((t.pass / t.total) * 100), - categories, - categoriesTotal: categories.length, - categoriesPass100: categories.filter((c) => c.pass === c.total).length, - failing: failing.sort((a, b) => b.fp + b.missed - (a.fp + a.missed)), - }; -} - -function git(args) { - // stderr ignored: early commits hold the file under an old path, so `git show` - // legitimately fails for those — we skip them, no need to spam the build log. - return execFileSync("git", args, { cwd: REPO_ROOT, encoding: "utf-8", maxBuffer: 1 << 26, stdio: ["ignore", "pipe", "ignore"] }); -} - -// The over-time series, read from the GIT history of conformance_status.csv. -// One real data point per commit that changed the file: its commit date and the -// score that commit recorded. Points dated before OFFICIAL_SINCE were produced -// by the earlier in-repo script; on/after, by the official scoring semantics. -function gitHistory() { - let log; - try { - log = git(["log", "--follow", "--format=%H|%cs", "--", STATUS_REL]); - } catch { - return []; - } - const points = []; - for (const line of log.split(/\r?\n/).filter(Boolean)) { - const [hash, date] = line.split("|"); - let csv; - try { - csv = git(["show", `${hash}:${STATUS_REL}`]); - } catch { - continue; - } - const t = tally(csv); - if (!t.total) continue; - points.push({ - hash: hash.slice(0, 8), - date, - shortDate: shortDate(date), - pass: t.pass, - total: t.total, - fp: t.fp, - missed: t.missed, - score: round1((t.pass / t.total) * 100), - official: date >= OFFICIAL_SINCE, - }); - } - return points.reverse(); // oldest -> newest -} - -// Inline-SVG geometry for the over-time chart. Computed here (testable, DRY) so -// the Nunjucks include only loops over coordinates. Points are spaced evenly by -// commit (each is a real event); the y-axis is the pass percentage 0–100. -function buildChart(points) { - if (points.length < 2) return null; - const width = 760, height = 360, left = 48, right = 24, top = 28, bottom = 64; - const plotW = width - left - right, plotH = height - top - bottom; - const n = points.length; - const xAt = (i) => round1(left + (i / (n - 1)) * plotW); - const yAt = (score) => round1(top + (1 - score / 100) * plotH); - - let lastLabel = null; - const pts = points.map((p, i) => { - const showDate = p.shortDate !== lastLabel; - lastLabel = p.shortDate; - return { ...p, i, x: xAt(i), y: yAt(p.score), showDate }; - }); - const yTicks = [0, 25, 50, 75, 100].map((value) => ({ value, y: yAt(value) })); - - const previous = pts.filter((p) => !p.official); - const official = pts.filter((p) => p.official); - const lastPrevious = previous[previous.length - 1]; - const firstOfficial = official[0]; - const peak = pts.reduce((a, b) => (b.score > a.score ? b : a), pts[0]); - - return { - width, height, left, right, top, bottom, - baselineY: yAt(0), - pts, - yTicks, - prevPolyline: previous.map((p) => `${p.x},${p.y}`).join(" "), - officialPolyline: official.map((p) => `${p.x},${p.y}`).join(" "), - // The correction "cliff": last earlier-era point down to the first official one. - drop: lastPrevious && firstOfficial - ? { x1: lastPrevious.x, y1: lastPrevious.y, x2: firstOfficial.x, y2: firstOfficial.y, from: lastPrevious.score, to: firstOfficial.score } - : null, - peak, - current: pts[pts.length - 1], - }; -} - -export default function () { - const status = parseStatus(); - if (!status) { - return { - hasData: false, - withdrawn: true, - publicationStatus: "historical-withdrawn", - historical: null, - }; - } - - // The resolved upstream commit comes from the conformance report. - const report = readReport(); - const upstream = report?.upstream ?? {}; - const pinnedRef = upstream.sha ?? null; - - // Historical data still carries the exact python/typing commit so the audit - // can reproduce the withdrawn run. A missing commit would make that record - // incomplete, so fail rather than silently detach it from its source. - if (!pinnedRef) { - throw new Error( - "conformance: conformance_status.csv has score data but conformance_report.json " + - "records no python/typing commit — run the real python/typing harness gate", - ); - } - - const history = gitHistory(); - return { - hasData: true, - withdrawn: true, - publicationStatus: "historical-withdrawn", - historical: { - ...status, - upstreamRef: upstream.ref ?? "main", - pinnedRef, - pinnedRefShort: upstream.shortSha ?? (pinnedRef ? pinnedRef.slice(0, 7) : null), - commitDate: upstream.commitDate || null, - stale: upstream.stale ?? false, - officialHarnessSince: OFFICIAL_SINCE, - history, - chart: buildChart(history), - }, - }; -} diff --git a/website/src/_data/conformanceOfficial.js b/website/src/_data/conformanceOfficial.js deleted file mode 100644 index 67afff6fc..000000000 --- a/website/src/_data/conformanceOfficial.js +++ /dev/null @@ -1,78 +0,0 @@ -// Historical python/typing leaderboard snapshot retained only for the public -// record of Basilisk's withdrawn announcement. Basilisk is no longer listed in -// the live official results, and its row below is invalid as evidence of actual -// conformance because the implementation was fitted to exact fixtures. -// -// _data/conformance.js -> historical outputs from Basilisk's withdrawn run. -// _data/conformanceOfficial.js (this file) -// -> the dated snapshot used in the retracted post. -// -// The snapshot is pinned so the retraction can show exactly what was published. -// It must never be described as current. The live source is linked separately. -// -// Source of every value below: -// https://github.com/python/typing/blob/main/conformance/results/results.html -// as published in python/typing@3410759355c3018063d3a446102f88621fc43eb5, -// 2026-07-31. PR #2316 originally added Basilisk to the board. This snapshot is -// intentionally frozen; do not refresh it from the live leaderboard. - -const SNAPSHOT = { - source: "https://github.com/python/typing/blob/main/conformance/results/results.html", - resultsDir: "https://github.com/python/typing/tree/3410759355c3018063d3a446102f88621fc43eb5/conformance/results", - snapshotUrl: "https://github.com/python/typing/blob/3410759355c3018063d3a446102f88621fc43eb5/conformance/results/results.html", - commitUrl: "https://github.com/python/typing/commit/3410759355c3018063d3a446102f88621fc43eb5", - addedPrUrl: "https://github.com/python/typing/pull/2316", - sha: "3410759", - date: "2026-07-31", - dateLabel: "Jul 31, 2026", -}; - -// Historical leaderboard grand-total row, verbatim from that snapshot. -// Basilisk's row and comparisons derived from it are withdrawn. -const TOOLS = [ - { id: "basilisk", name: "Basilisk", version: "0.27.0", org: null, pass: 141, total: 141 }, - { id: "pyright", name: "Pyright", version: "1.1.410", org: "Microsoft", pass: 136.5, total: 141 }, - { id: "mypy", name: "mypy", version: "2.1.0", org: null, pass: 109, total: 141 }, - { id: "ty", name: "ty", version: "0.0.65", org: "Astral", pass: 122, total: 141 }, - { id: "pyrefly", name: "Pyrefly", version: "1.1.0", org: "Meta", pass: 138, total: 141 }, - { id: "zuban", name: "zuban", version: "0.8.2", org: null, pass: 140.5, total: 141 }, - { id: "pycroscope", name: "pycroscope", version: "0.4.0", org: null, pass: 130, total: 141 }, -]; - -const round1 = (n) => Math.round(n * 10) / 10; - -export default function () { - const enrich = (t) => ({ - ...t, - pct: round1((t.pass / t.total) * 100), - // A whole-number pass renders as "141"; a half-point as "140.5". - passLabel: Number.isInteger(t.pass) ? String(t.pass) : t.pass.toFixed(1), - resultsUrl: `${SNAPSHOT.resultsDir}/${t.id}`, - }); - - const tools = TOOLS.map(enrich); - const byId = Object.fromEntries(tools.map((t) => [t.id, t])); - const ranked = [...tools] - .sort((a, b) => b.pct - a.pct) - .map((t, i) => ({ ...t, rank: i + 1 })); - - const basilisk = byId.basilisk; - const perfect = tools.filter((t) => t.pass === t.total); - - return { - hasData: true, - withdrawn: true, - publicationStatus: "historical-withdrawn", - historical: { - snapshot: SNAPSHOT, - tools, - byId, - ranked, - basilisk, - basiliskRankAtSnapshot: ranked.find((t) => t.id === "basilisk").rank, - perfectCountAtSnapshot: perfect.length, - basiliskWasSolePerfectAtSnapshot: - perfect.length === 1 && perfect[0].id === "basilisk", - }, - }; -} diff --git a/website/src/_data/conformance_report.json b/website/src/_data/conformance_report.json deleted file mode 100644 index 516e350cf..000000000 --- a/website/src/_data/conformance_report.json +++ /dev/null @@ -1,1680 +0,0 @@ -{ - "_doc": "Generated by conformance/run_conformance.py from the python/typing harness at the last revision carrying the removed Basilisk adapter. This is internal fixture-regression evidence, not a current official conformance score.", - "upstream": { - "repo": "python/typing", - "ref": "a4906624f170c169cf667f962080c56d5a5ba6ff", - "sha": "a4906624f170c169cf667f962080c56d5a5ba6ff", - "shortSha": "a490662", - "commitDate": "2026-08-04", - "stale": true, - "withdrawn": true, - "status": "historical internal regression snapshot" - }, - "calculator": { - "file": "python/typing@a490662:conformance/src/main.py", - "sha256": "3cb2a27bfc689e89a541528f8bdaa8ed24ae8845ce048eaff69717ef0205b112", - "bytes": 10810, - "funcs": [ - "get_expected_errors", - "diff_expected_errors" - ] - }, - "grading": "upstream python/typing harness at the frozen last-adapter revision (src/main.py --only-run basilisk), every rule enabled; internal fixture-regression evidence only", - "score": { - "pass": 141, - "total": 141, - "fail": 0, - "scorePct": 100.0, - "caught": 970, - "missed": 0, - "falsePositives": 0 - }, - "files": [ - { - "file": "aliases_explicit.py", - "category": "aliases", - "status": "PASS", - "caught": 21, - "missed": 0, - "falsePositives": 0, - "codes": [ - "aliases_implicit" - ] - }, - { - "file": "aliases_implicit.py", - "category": "aliases", - "status": "PASS", - "caught": 22, - "missed": 0, - "falsePositives": 0, - "codes": [ - "aliases_implicit", - "annotations_forward_refs", - "generics_defaults_specialization" - ] - }, - { - "file": "aliases_newtype.py", - "category": "aliases", - "status": "PASS", - "caught": 14, - "missed": 0, - "falsePositives": 0, - "codes": [ - "aliases_newtype" - ] - }, - { - "file": "aliases_recursive.py", - "category": "aliases", - "status": "PASS", - "caught": 11, - "missed": 0, - "falsePositives": 0, - "codes": [ - "aliases_recursive", - "assignment_compatibility" - ] - }, - { - "file": "aliases_type_statement.py", - "category": "aliases", - "status": "PASS", - "caught": 24, - "missed": 0, - "falsePositives": 0, - "codes": [ - "aliases_type_statement", - "generics_syntax_scoping" - ] - }, - { - "file": "aliases_typealiastype.py", - "category": "aliases", - "status": "PASS", - "caught": 22, - "missed": 0, - "falsePositives": 0, - "codes": [ - "aliases_typealiastype" - ] - }, - { - "file": "aliases_variance.py", - "category": "aliases", - "status": "PASS", - "caught": 4, - "missed": 0, - "falsePositives": 0, - "codes": [ - "generics_variance" - ] - }, - { - "file": "annotations_coroutines.py", - "category": "annotations", - "status": "PASS", - "caught": 0, - "missed": 0, - "falsePositives": 0, - "codes": [] - }, - { - "file": "annotations_forward_refs.py", - "category": "annotations", - "status": "PASS", - "caught": 19, - "missed": 0, - "falsePositives": 0, - "codes": [ - "annotations_forward_refs" - ] - }, - { - "file": "annotations_generators.py", - "category": "annotations", - "status": "PASS", - "caught": 10, - "missed": 0, - "falsePositives": 0, - "codes": [ - "annotations_generators", - "annotations_generators_2" - ] - }, - { - "file": "annotations_methods.py", - "category": "annotations", - "status": "PASS", - "caught": 0, - "missed": 0, - "falsePositives": 0, - "codes": [] - }, - { - "file": "annotations_typeexpr.py", - "category": "annotations", - "status": "PASS", - "caught": 15, - "missed": 0, - "falsePositives": 0, - "codes": [ - "aliases_implicit", - "annotations_forward_refs", - "annotations_typeexpr" - ] - }, - { - "file": "callables_annotation.py", - "category": "callables", - "status": "PASS", - "caught": 16, - "missed": 0, - "falsePositives": 0, - "codes": [ - "assignment_compatibility", - "callables_annotation", - "callables_protocol", - "callables_protocol_2" - ] - }, - { - "file": "callables_kwargs.py", - "category": "callables", - "status": "PASS", - "caught": 12, - "missed": 0, - "falsePositives": 0, - "codes": [ - "callables_kwargs", - "callables_protocol_2", - "calls_argument_type" - ] - }, - { - "file": "callables_protocol.py", - "category": "callables", - "status": "PASS", - "caught": 17, - "missed": 0, - "falsePositives": 0, - "codes": [ - "assignment_compatibility", - "callables_protocol_2" - ] - }, - { - "file": "callables_subtyping.py", - "category": "callables", - "status": "PASS", - "caught": 32, - "missed": 0, - "falsePositives": 0, - "codes": [ - "assignment_compatibility", - "callables_subtyping" - ] - }, - { - "file": "classes_classvar.py", - "category": "classes", - "status": "PASS", - "caught": 17, - "missed": 0, - "falsePositives": 0, - "codes": [ - "classes_classvar", - "protocols_definition_2", - "qualifiers_final_annotation" - ] - }, - { - "file": "classes_override.py", - "category": "classes", - "status": "PASS", - "caught": 0, - "missed": 0, - "falsePositives": 0, - "codes": [ - "classes_override_3" - ] - }, - { - "file": "constructors_call_init.py", - "category": "constructors", - "status": "PASS", - "caught": 5, - "missed": 0, - "falsePositives": 0, - "codes": [ - "constructors_call_init", - "generics_defaults_referential_2" - ] - }, - { - "file": "constructors_call_metaclass.py", - "category": "constructors", - "status": "PASS", - "caught": 2, - "missed": 0, - "falsePositives": 0, - "codes": [ - "calls_argument_count" - ] - }, - { - "file": "constructors_call_new.py", - "category": "constructors", - "status": "PASS", - "caught": 2, - "missed": 0, - "falsePositives": 0, - "codes": [ - "constructors_call_new" - ] - }, - { - "file": "constructors_call_type.py", - "category": "constructors", - "status": "PASS", - "caught": 8, - "missed": 0, - "falsePositives": 0, - "codes": [ - "constructors_call_type" - ] - }, - { - "file": "constructors_callable.py", - "category": "constructors", - "status": "PASS", - "caught": 12, - "missed": 0, - "falsePositives": 0, - "codes": [ - "constructors_callable" - ] - }, - { - "file": "constructors_consistency.py", - "category": "constructors", - "status": "PASS", - "caught": 0, - "missed": 0, - "falsePositives": 0, - "codes": [] - }, - { - "file": "dataclasses_descriptors.py", - "category": "dataclasses", - "status": "PASS", - "caught": 0, - "missed": 0, - "falsePositives": 0, - "codes": [] - }, - { - "file": "dataclasses_final.py", - "category": "dataclasses", - "status": "PASS", - "caught": 5, - "missed": 0, - "falsePositives": 0, - "codes": [ - "qualifiers_final_annotation_2" - ] - }, - { - "file": "dataclasses_frozen.py", - "category": "dataclasses", - "status": "PASS", - "caught": 2, - "missed": 0, - "falsePositives": 0, - "codes": [ - "dataclasses_frozen" - ] - }, - { - "file": "dataclasses_hash.py", - "category": "dataclasses", - "status": "PASS", - "caught": 4, - "missed": 0, - "falsePositives": 0, - "codes": [ - "dataclasses_hash" - ] - }, - { - "file": "dataclasses_inheritance.py", - "category": "dataclasses", - "status": "PASS", - "caught": 2, - "missed": 0, - "falsePositives": 0, - "codes": [ - "classes_override_2" - ] - }, - { - "file": "dataclasses_kwonly.py", - "category": "dataclasses", - "status": "PASS", - "caught": 3, - "missed": 0, - "falsePositives": 0, - "codes": [ - "dataclasses_kwonly" - ] - }, - { - "file": "dataclasses_match_args.py", - "category": "dataclasses", - "status": "PASS", - "caught": 1, - "missed": 0, - "falsePositives": 0, - "codes": [ - "dataclasses_match_args" - ] - }, - { - "file": "dataclasses_order.py", - "category": "dataclasses", - "status": "PASS", - "caught": 1, - "missed": 0, - "falsePositives": 0, - "codes": [ - "dataclasses_order" - ] - }, - { - "file": "dataclasses_postinit.py", - "category": "dataclasses", - "status": "PASS", - "caught": 4, - "missed": 0, - "falsePositives": 0, - "codes": [ - "dataclasses_postinit" - ] - }, - { - "file": "dataclasses_slots.py", - "category": "dataclasses", - "status": "PASS", - "caught": 4, - "missed": 0, - "falsePositives": 0, - "codes": [ - "dataclasses_slots" - ] - }, - { - "file": "dataclasses_transform_class.py", - "category": "dataclasses", - "status": "PASS", - "caught": 6, - "missed": 0, - "falsePositives": 0, - "codes": [ - "dataclasses_transform_class" - ] - }, - { - "file": "dataclasses_transform_converter.py", - "category": "dataclasses", - "status": "PASS", - "caught": 9, - "missed": 0, - "falsePositives": 0, - "codes": [ - "dataclasses_transform_class" - ] - }, - { - "file": "dataclasses_transform_field.py", - "category": "dataclasses", - "status": "PASS", - "caught": 2, - "missed": 0, - "falsePositives": 0, - "codes": [ - "dataclasses_kwonly" - ] - }, - { - "file": "dataclasses_transform_func.py", - "category": "dataclasses", - "status": "PASS", - "caught": 5, - "missed": 0, - "falsePositives": 0, - "codes": [ - "assignment_compatibility", - "constructors_call_init", - "dataclasses_frozen", - "dataclasses_kwonly", - "dataclasses_order" - ] - }, - { - "file": "dataclasses_transform_meta.py", - "category": "dataclasses", - "status": "PASS", - "caught": 6, - "missed": 0, - "falsePositives": 0, - "codes": [ - "dataclasses_transform_meta" - ] - }, - { - "file": "dataclasses_usage.py", - "category": "dataclasses", - "status": "PASS", - "caught": 9, - "missed": 0, - "falsePositives": 0, - "codes": [ - "calls_argument_count", - "dataclasses_inheritance", - "dataclasses_kwonly", - "dataclasses_usage" - ] - }, - { - "file": "directives_assert_type.py", - "category": "directives", - "status": "PASS", - "caught": 7, - "missed": 0, - "falsePositives": 0, - "codes": [ - "directives_assert_type", - "directives_assert_type_2" - ] - }, - { - "file": "directives_cast.py", - "category": "directives", - "status": "PASS", - "caught": 3, - "missed": 0, - "falsePositives": 0, - "codes": [ - "directives_cast" - ] - }, - { - "file": "directives_deprecated.py", - "category": "directives", - "status": "PASS", - "caught": 12, - "missed": 0, - "falsePositives": 0, - "codes": [ - "directives_deprecated" - ] - }, - { - "file": "directives_disjoint_base.py", - "category": "directives", - "status": "PASS", - "caught": 8, - "missed": 0, - "falsePositives": 0, - "codes": [ - "directives_disjoint_base" - ] - }, - { - "file": "directives_no_type_check.py", - "category": "directives", - "status": "PASS", - "caught": 1, - "missed": 0, - "falsePositives": 0, - "codes": [ - "calls_argument_count", - "calls_argument_type", - "returns_compatibility_2" - ] - }, - { - "file": "directives_reveal_type.py", - "category": "directives", - "status": "PASS", - "caught": 2, - "missed": 0, - "falsePositives": 0, - "codes": [ - "directives_reveal_type" - ] - }, - { - "file": "directives_type_checking.py", - "category": "directives", - "status": "PASS", - "caught": 0, - "missed": 0, - "falsePositives": 0, - "codes": [] - }, - { - "file": "directives_type_ignore.py", - "category": "directives", - "status": "PASS", - "caught": 0, - "missed": 0, - "falsePositives": 0, - "codes": [] - }, - { - "file": "directives_type_ignore_file1.py", - "category": "directives", - "status": "PASS", - "caught": 0, - "missed": 0, - "falsePositives": 0, - "codes": [] - }, - { - "file": "directives_type_ignore_file2.py", - "category": "directives", - "status": "PASS", - "caught": 1, - "missed": 0, - "falsePositives": 0, - "codes": [ - "assignment_compatibility" - ] - }, - { - "file": "directives_version_platform.py", - "category": "directives", - "status": "PASS", - "caught": 3, - "missed": 0, - "falsePositives": 0, - "codes": [ - "directives_version_platform" - ] - }, - { - "file": "enums_behaviors.py", - "category": "enums", - "status": "PASS", - "caught": 1, - "missed": 0, - "falsePositives": 0, - "codes": [ - "enums_behaviors", - "enums_expansion" - ] - }, - { - "file": "enums_definition.py", - "category": "enums", - "status": "PASS", - "caught": 1, - "missed": 0, - "falsePositives": 0, - "codes": [ - "enums_definition" - ] - }, - { - "file": "enums_expansion.py", - "category": "enums", - "status": "PASS", - "caught": 1, - "missed": 0, - "falsePositives": 0, - "codes": [ - "enums_expansion" - ] - }, - { - "file": "enums_member_names.py", - "category": "enums", - "status": "PASS", - "caught": 0, - "missed": 0, - "falsePositives": 0, - "codes": [] - }, - { - "file": "enums_member_values.py", - "category": "enums", - "status": "PASS", - "caught": 2, - "missed": 0, - "falsePositives": 0, - "codes": [ - "enums_member_values" - ] - }, - { - "file": "enums_members.py", - "category": "enums", - "status": "PASS", - "caught": 7, - "missed": 0, - "falsePositives": 0, - "codes": [ - "enums_members", - "enums_members_2" - ] - }, - { - "file": "exceptions_context_managers.py", - "category": "exceptions", - "status": "PASS", - "caught": 0, - "missed": 0, - "falsePositives": 0, - "codes": [] - }, - { - "file": "generics_base_class.py", - "category": "generics", - "status": "PASS", - "caught": 7, - "missed": 0, - "falsePositives": 0, - "codes": [ - "annotations_forward_refs", - "generics_base_class", - "generics_base_class_2", - "generics_base_class_3", - "generics_defaults_specialization" - ] - }, - { - "file": "generics_basic.py", - "category": "generics", - "status": "PASS", - "caught": 18, - "missed": 0, - "falsePositives": 0, - "codes": [ - "calls_argument_type", - "generics_base_class", - "generics_basic", - "generics_basic_2", - "generics_basic_3", - "generics_variance_inference" - ] - }, - { - "file": "generics_defaults.py", - "category": "generics", - "status": "PASS", - "caught": 5, - "missed": 0, - "falsePositives": 0, - "codes": [ - "directives_assert_type_2", - "generics_defaults", - "generics_defaults_2", - "generics_defaults_specialization" - ] - }, - { - "file": "generics_defaults_referential.py", - "category": "generics", - "status": "PASS", - "caught": 7, - "missed": 0, - "falsePositives": 0, - "codes": [ - "generics_defaults_referential", - "generics_defaults_referential_2", - "generics_variance_inference" - ] - }, - { - "file": "generics_defaults_specialization.py", - "category": "generics", - "status": "PASS", - "caught": 3, - "missed": 0, - "falsePositives": 0, - "codes": [ - "assignment_compatibility", - "generics_defaults_specialization" - ] - }, - { - "file": "generics_paramspec_basic.py", - "category": "generics", - "status": "PASS", - "caught": 7, - "missed": 0, - "falsePositives": 0, - "codes": [ - "annotations_forward_refs", - "generics_basic" - ] - }, - { - "file": "generics_paramspec_components.py", - "category": "generics", - "status": "PASS", - "caught": 16, - "missed": 0, - "falsePositives": 0, - "codes": [ - "callables_protocol" - ] - }, - { - "file": "generics_paramspec_semantics.py", - "category": "generics", - "status": "PASS", - "caught": 9, - "missed": 0, - "falsePositives": 0, - "codes": [ - "callables_protocol" - ] - }, - { - "file": "generics_paramspec_specialization.py", - "category": "generics", - "status": "PASS", - "caught": 5, - "missed": 0, - "falsePositives": 0, - "codes": [ - "callables_protocol", - "generics_defaults_specialization" - ] - }, - { - "file": "generics_scoping.py", - "category": "generics", - "status": "PASS", - "caught": 10, - "missed": 0, - "falsePositives": 0, - "codes": [ - "directives_assert_type_2", - "generics_scoping", - "generics_variance_inference" - ] - }, - { - "file": "generics_self_advanced.py", - "category": "generics", - "status": "PASS", - "caught": 0, - "missed": 0, - "falsePositives": 0, - "codes": [] - }, - { - "file": "generics_self_attributes.py", - "category": "generics", - "status": "PASS", - "caught": 2, - "missed": 0, - "falsePositives": 0, - "codes": [ - "generics_self_attributes" - ] - }, - { - "file": "generics_self_basic.py", - "category": "generics", - "status": "PASS", - "caught": 3, - "missed": 0, - "falsePositives": 0, - "codes": [ - "generics_self_basic" - ] - }, - { - "file": "generics_self_protocols.py", - "category": "generics", - "status": "PASS", - "caught": 2, - "missed": 0, - "falsePositives": 0, - "codes": [ - "generics_self_protocols" - ] - }, - { - "file": "generics_self_usage.py", - "category": "generics", - "status": "PASS", - "caught": 11, - "missed": 0, - "falsePositives": 0, - "codes": [ - "generics_self_basic", - "generics_self_usage" - ] - }, - { - "file": "generics_syntax_compatibility.py", - "category": "generics", - "status": "PASS", - "caught": 2, - "missed": 0, - "falsePositives": 0, - "codes": [ - "generics_syntax_compatibility" - ] - }, - { - "file": "generics_syntax_declarations.py", - "category": "generics", - "status": "PASS", - "caught": 10, - "missed": 0, - "falsePositives": 0, - "codes": [ - "generics_basic_2", - "generics_syntax_declarations", - "generics_syntax_declarations_2" - ] - }, - { - "file": "generics_syntax_infer_variance.py", - "category": "generics", - "status": "PASS", - "caught": 18, - "missed": 0, - "falsePositives": 0, - "codes": [ - "generics_typevartuple_basic", - "generics_variance_inference" - ] - }, - { - "file": "generics_syntax_scoping.py", - "category": "generics", - "status": "PASS", - "caught": 7, - "missed": 0, - "falsePositives": 0, - "codes": [ - "aliases_implicit", - "generics_syntax_scoping" - ] - }, - { - "file": "generics_type_erasure.py", - "category": "generics", - "status": "PASS", - "caught": 7, - "missed": 0, - "falsePositives": 0, - "codes": [ - "constructors_call_init", - "generics_type_erasure" - ] - }, - { - "file": "generics_typevartuple_args.py", - "category": "generics", - "status": "PASS", - "caught": 8, - "missed": 0, - "falsePositives": 0, - "codes": [ - "generics_typevartuple_args" - ] - }, - { - "file": "generics_typevartuple_basic.py", - "category": "generics", - "status": "PASS", - "caught": 13, - "missed": 0, - "falsePositives": 0, - "codes": [ - "generics_typevartuple_args", - "generics_typevartuple_basic", - "generics_typevartuple_basic_2", - "generics_typevartuple_basic_3", - "generics_typevartuple_specialization" - ] - }, - { - "file": "generics_typevartuple_callable.py", - "category": "generics", - "status": "PASS", - "caught": 1, - "missed": 0, - "falsePositives": 0, - "codes": [ - "generics_typevartuple_callable" - ] - }, - { - "file": "generics_typevartuple_concat.py", - "category": "generics", - "status": "PASS", - "caught": 0, - "missed": 0, - "falsePositives": 0, - "codes": [] - }, - { - "file": "generics_typevartuple_overloads.py", - "category": "generics", - "status": "PASS", - "caught": 0, - "missed": 0, - "falsePositives": 0, - "codes": [] - }, - { - "file": "generics_typevartuple_specialization.py", - "category": "generics", - "status": "PASS", - "caught": 6, - "missed": 0, - "falsePositives": 0, - "codes": [ - "generics_typevartuple_specialization", - "generics_typevartuple_specialization_2", - "generics_variance_inference" - ] - }, - { - "file": "generics_typevartuple_unpack.py", - "category": "generics", - "status": "PASS", - "caught": 1, - "missed": 0, - "falsePositives": 0, - "codes": [ - "generics_typevartuple_unpack" - ] - }, - { - "file": "generics_upper_bound.py", - "category": "generics", - "status": "PASS", - "caught": 3, - "missed": 0, - "falsePositives": 0, - "codes": [ - "directives_assert_type_2", - "generics_basic", - "generics_typevartuple_basic", - "generics_upper_bound" - ] - }, - { - "file": "generics_variance.py", - "category": "generics", - "status": "PASS", - "caught": 9, - "missed": 0, - "falsePositives": 0, - "codes": [ - "generics_typevartuple_basic", - "generics_variance" - ] - }, - { - "file": "generics_variance_inference.py", - "category": "generics", - "status": "PASS", - "caught": 23, - "missed": 0, - "falsePositives": 0, - "codes": [ - "generics_variance_inference" - ] - }, - { - "file": "historical_positional.py", - "category": "historical", - "status": "PASS", - "caught": 4, - "missed": 0, - "falsePositives": 0, - "codes": [ - "historical_positional" - ] - }, - { - "file": "literals_interactions.py", - "category": "literals", - "status": "PASS", - "caught": 4, - "missed": 0, - "falsePositives": 0, - "codes": [ - "calls_argument_type", - "tuples_index_2" - ] - }, - { - "file": "literals_literalstring.py", - "category": "literals", - "status": "PASS", - "caught": 9, - "missed": 0, - "falsePositives": 0, - "codes": [ - "assignment_compatibility", - "generics_upper_bound_2", - "literals_literalstring", - "literals_parameterizations", - "literals_semantics_2" - ] - }, - { - "file": "literals_parameterizations.py", - "category": "literals", - "status": "PASS", - "caught": 17, - "missed": 0, - "falsePositives": 0, - "codes": [ - "assignment_compatibility", - "generics_scoping", - "generics_variance_inference", - "literals_parameterizations", - "literals_parameterizations_2", - "literals_semantics_2" - ] - }, - { - "file": "literals_semantics.py", - "category": "literals", - "status": "PASS", - "caught": 4, - "missed": 0, - "falsePositives": 0, - "codes": [ - "assignment_compatibility", - "literals_semantics_2" - ] - }, - { - "file": "namedtuples_define_class.py", - "category": "namedtuples", - "status": "PASS", - "caught": 15, - "missed": 0, - "falsePositives": 0, - "codes": [ - "constructors_call_init", - "namedtuples_define_class", - "namedtuples_usage" - ] - }, - { - "file": "namedtuples_define_functional.py", - "category": "namedtuples", - "status": "PASS", - "caught": 9, - "missed": 0, - "falsePositives": 0, - "codes": [ - "calls_argument_count", - "namedtuples_define_functional" - ] - }, - { - "file": "namedtuples_type_compat.py", - "category": "namedtuples", - "status": "PASS", - "caught": 2, - "missed": 0, - "falsePositives": 0, - "codes": [ - "namedtuples_type_compat" - ] - }, - { - "file": "namedtuples_usage.py", - "category": "namedtuples", - "status": "PASS", - "caught": 8, - "missed": 0, - "falsePositives": 0, - "codes": [ - "namedtuples_usage" - ] - }, - { - "file": "narrowing_typeguard.py", - "category": "narrowing", - "status": "PASS", - "caught": 4, - "missed": 0, - "falsePositives": 0, - "codes": [ - "narrowing_typeguard", - "narrowing_typeis" - ] - }, - { - "file": "narrowing_typeis.py", - "category": "narrowing", - "status": "PASS", - "caught": 9, - "missed": 0, - "falsePositives": 0, - "codes": [ - "narrowing_typeguard", - "narrowing_typeis", - "narrowing_typeis_2" - ] - }, - { - "file": "overloads_basic.py", - "category": "overloads", - "status": "PASS", - "caught": 1, - "missed": 0, - "falsePositives": 0, - "codes": [ - "overloads_basic" - ] - }, - { - "file": "overloads_consistency.py", - "category": "overloads", - "status": "PASS", - "caught": 0, - "missed": 0, - "falsePositives": 0, - "codes": [ - "overloads_consistency_3" - ] - }, - { - "file": "overloads_definitions.py", - "category": "overloads", - "status": "PASS", - "caught": 0, - "missed": 0, - "falsePositives": 0, - "codes": [ - "classes_override_3", - "overloads_consistency_2", - "overloads_definitions", - "qualifiers_final_decorator" - ] - }, - { - "file": "overloads_definitions_stub.pyi", - "category": "overloads", - "status": "PASS", - "caught": 0, - "missed": 0, - "falsePositives": 0, - "codes": [ - "classes_override_3", - "overloads_consistency_2", - "overloads_definitions", - "qualifiers_final_decorator" - ] - }, - { - "file": "overloads_evaluation.py", - "category": "overloads", - "status": "PASS", - "caught": 4, - "missed": 0, - "falsePositives": 0, - "codes": [ - "calls_argument_count", - "calls_argument_type", - "overloads_evaluation" - ] - }, - { - "file": "protocols_class_objects.py", - "category": "protocols", - "status": "PASS", - "caught": 8, - "missed": 0, - "falsePositives": 0, - "codes": [ - "protocols_class_objects_2", - "protocols_explicit" - ] - }, - { - "file": "protocols_definition.py", - "category": "protocols", - "status": "PASS", - "caught": 21, - "missed": 0, - "falsePositives": 0, - "codes": [ - "classes_classvar", - "protocols_definition", - "protocols_definition_2" - ] - }, - { - "file": "protocols_explicit.py", - "category": "protocols", - "status": "PASS", - "caught": 6, - "missed": 0, - "falsePositives": 0, - "codes": [ - "protocols_explicit", - "protocols_explicit_2", - "protocols_explicit_3", - "protocols_subtyping" - ] - }, - { - "file": "protocols_generic.py", - "category": "protocols", - "status": "PASS", - "caught": 9, - "missed": 0, - "falsePositives": 0, - "codes": [ - "generics_variance_inference", - "protocols_generic" - ] - }, - { - "file": "protocols_merging.py", - "category": "protocols", - "status": "PASS", - "caught": 6, - "missed": 0, - "falsePositives": 0, - "codes": [ - "assignment_compatibility", - "protocols_definition_2", - "protocols_explicit", - "protocols_merging" - ] - }, - { - "file": "protocols_modules.py", - "category": "protocols", - "status": "PASS", - "caught": 3, - "missed": 0, - "falsePositives": 0, - "codes": [ - "protocols_modules" - ] - }, - { - "file": "protocols_recursive.py", - "category": "protocols", - "status": "PASS", - "caught": 0, - "missed": 0, - "falsePositives": 0, - "codes": [] - }, - { - "file": "protocols_runtime_checkable.py", - "category": "protocols", - "status": "PASS", - "caught": 6, - "missed": 0, - "falsePositives": 0, - "codes": [ - "protocols_runtime_checkable", - "protocols_runtime_checkable_2" - ] - }, - { - "file": "protocols_self.py", - "category": "protocols", - "status": "PASS", - "caught": 0, - "missed": 0, - "falsePositives": 0, - "codes": [] - }, - { - "file": "protocols_subtyping.py", - "category": "protocols", - "status": "PASS", - "caught": 7, - "missed": 0, - "falsePositives": 0, - "codes": [ - "assignment_compatibility", - "protocols_explicit" - ] - }, - { - "file": "protocols_variance.py", - "category": "protocols", - "status": "PASS", - "caught": 5, - "missed": 0, - "falsePositives": 0, - "codes": [ - "protocols_variance", - "protocols_variance_2" - ] - }, - { - "file": "qualifiers_annotated.py", - "category": "qualifiers", - "status": "PASS", - "caught": 20, - "missed": 0, - "falsePositives": 0, - "codes": [ - "qualifiers_annotated", - "qualifiers_annotated_2" - ] - }, - { - "file": "qualifiers_final_annotation.py", - "category": "qualifiers", - "status": "PASS", - "caught": 26, - "missed": 0, - "falsePositives": 0, - "codes": [ - "calls_argument_count", - "classes_classvar", - "namedtuples_define_functional", - "qualifiers_final_annotation", - "qualifiers_final_annotation_2" - ] - }, - { - "file": "qualifiers_final_decorator.py", - "category": "qualifiers", - "status": "PASS", - "caught": 3, - "missed": 0, - "falsePositives": 0, - "codes": [ - "overloads_consistency_2", - "qualifiers_final_decorator" - ] - }, - { - "file": "specialtypes_any.py", - "category": "specialtypes", - "status": "PASS", - "caught": 0, - "missed": 0, - "falsePositives": 0, - "codes": [] - }, - { - "file": "specialtypes_never.py", - "category": "specialtypes", - "status": "PASS", - "caught": 3, - "missed": 0, - "falsePositives": 0, - "codes": [ - "assignment_compatibility", - "specialtypes_never", - "specialtypes_never_2" - ] - }, - { - "file": "specialtypes_none.py", - "category": "specialtypes", - "status": "PASS", - "caught": 3, - "missed": 0, - "falsePositives": 0, - "codes": [ - "assignment_compatibility", - "calls_argument_type" - ] - }, - { - "file": "specialtypes_promotions.py", - "category": "specialtypes", - "status": "PASS", - "caught": 1, - "missed": 0, - "falsePositives": 0, - "codes": [ - "specialtypes_promotions" - ] - }, - { - "file": "specialtypes_type.py", - "category": "specialtypes", - "status": "PASS", - "caught": 9, - "missed": 0, - "falsePositives": 0, - "codes": [ - "callables_annotation", - "generics_defaults_specialization", - "specialtypes_type" - ] - }, - { - "file": "tuples_type_compat.py", - "category": "tuples", - "status": "PASS", - "caught": 16, - "missed": 0, - "falsePositives": 0, - "codes": [ - "assignment_compatibility", - "directives_assert_type_2", - "tuples_type_compat" - ] - }, - { - "file": "tuples_type_form.py", - "category": "tuples", - "status": "PASS", - "caught": 11, - "missed": 0, - "falsePositives": 0, - "codes": [ - "assignment_compatibility", - "tuples_type_form", - "tuples_type_form_2" - ] - }, - { - "file": "tuples_unpacked.py", - "category": "tuples", - "status": "PASS", - "caught": 4, - "missed": 0, - "falsePositives": 0, - "codes": [ - "tuples_type_form" - ] - }, - { - "file": "typeddicts_alt_syntax.py", - "category": "typeddicts", - "status": "PASS", - "caught": 4, - "missed": 0, - "falsePositives": 0, - "codes": [ - "typeddicts_alt_syntax" - ] - }, - { - "file": "typeddicts_class_syntax.py", - "category": "typeddicts", - "status": "PASS", - "caught": 4, - "missed": 0, - "falsePositives": 0, - "codes": [ - "typeddicts_class_syntax", - "typeddicts_class_syntax_2", - "typeddicts_extra_items" - ] - }, - { - "file": "typeddicts_extra_items.py", - "category": "typeddicts", - "status": "PASS", - "caught": 22, - "missed": 0, - "falsePositives": 0, - "codes": [ - "assignment_compatibility", - "callables_kwargs", - "typeddicts_extra_items", - "typeddicts_operations" - ] - }, - { - "file": "typeddicts_final.py", - "category": "typeddicts", - "status": "PASS", - "caught": 0, - "missed": 0, - "falsePositives": 0, - "codes": [] - }, - { - "file": "typeddicts_inheritance.py", - "category": "typeddicts", - "status": "PASS", - "caught": 2, - "missed": 0, - "falsePositives": 0, - "codes": [ - "typeddicts_inheritance" - ] - }, - { - "file": "typeddicts_operations.py", - "category": "typeddicts", - "status": "PASS", - "caught": 11, - "missed": 0, - "falsePositives": 0, - "codes": [ - "typeddicts_operations" - ] - }, - { - "file": "typeddicts_readonly.py", - "category": "typeddicts", - "status": "PASS", - "caught": 6, - "missed": 0, - "falsePositives": 0, - "codes": [ - "typeddicts_readonly" - ] - }, - { - "file": "typeddicts_readonly_consistency.py", - "category": "typeddicts", - "status": "PASS", - "caught": 7, - "missed": 0, - "falsePositives": 0, - "codes": [ - "assignment_compatibility" - ] - }, - { - "file": "typeddicts_readonly_inheritance.py", - "category": "typeddicts", - "status": "PASS", - "caught": 11, - "missed": 0, - "falsePositives": 0, - "codes": [ - "typeddicts_inheritance", - "typeddicts_operations", - "typeddicts_readonly" - ] - }, - { - "file": "typeddicts_readonly_kwargs.py", - "category": "typeddicts", - "status": "PASS", - "caught": 1, - "missed": 0, - "falsePositives": 0, - "codes": [ - "typeddicts_readonly" - ] - }, - { - "file": "typeddicts_readonly_update.py", - "category": "typeddicts", - "status": "PASS", - "caught": 1, - "missed": 0, - "falsePositives": 0, - "codes": [ - "typeddicts_operations", - "typeddicts_readonly" - ] - }, - { - "file": "typeddicts_required.py", - "category": "typeddicts", - "status": "PASS", - "caught": 4, - "missed": 0, - "falsePositives": 0, - "codes": [ - "typeddicts_required" - ] - }, - { - "file": "typeddicts_type_consistency.py", - "category": "typeddicts", - "status": "PASS", - "caught": 9, - "missed": 0, - "falsePositives": 0, - "codes": [ - "typeddicts_operations" - ] - }, - { - "file": "typeddicts_usage.py", - "category": "typeddicts", - "status": "PASS", - "caught": 5, - "missed": 0, - "falsePositives": 0, - "codes": [ - "typeddicts_operations", - "typeddicts_usage" - ] - }, - { - "file": "typeforms_typeform.py", - "category": "typeforms", - "status": "PASS", - "caught": 16, - "missed": 0, - "falsePositives": 0, - "codes": [ - "assignment_compatibility", - "classes_classvar" - ] - } - ] -} diff --git a/website/src/_data/examples.js b/website/src/_data/examples.js deleted file mode 100644 index 3812058d0..000000000 --- a/website/src/_data/examples.js +++ /dev/null @@ -1,17 +0,0 @@ -// Implements [WEBSITE-ERROR-PAGES-EXAMPLES]: map each diagnostic code to the -// worked-example screenshot that demonstrates it, so /errors/<code>/ can embed -// the real `basilisk check` output. See docs/specs/WEBSITE-ERROR-PAGES-SPEC.md. -// -// The screenshot manifest is the single source of truth: each rule shot records -// the exact code it triggers in `expect` (e.g. e0011 → BSK-0014), so we key off -// that rather than the filename to stay correct even where they differ. -import { SHOTS } from "../../screenshots/shots.mjs"; - -const RULE_SHOT = /^e\d+$/; -const RULE_CODE = /^BSK-\d{4}$/; - -export default Object.fromEntries( - SHOTS.filter((shot) => RULE_SHOT.test(shot.name) && RULE_CODE.test(shot.expect)).map( - (shot) => [shot.expect, shot.name], - ), -); diff --git a/website/src/_data/i18n.json b/website/src/_data/i18n.json deleted file mode 100644 index fbc20aeb6..000000000 --- a/website/src/_data/i18n.json +++ /dev/null @@ -1,118 +0,0 @@ -{ - "en": { - "nav": { - "docs": "Docs", - "rules": "Rules", - "blog": "Blog", - "discord": "Discord", - "github": "GitHub", - "getStarted": "Get Started" - }, - "blog": { - "title": "Blog", - "eyebrow": "The Basilisk Journal", - "headline": "Ideas for safer Python.", - "subtitle": "Updates, announcements, and deep-dives from the Basilisk project.", - "description": "Updates, announcements, and deep-dives from the Basilisk project.", - "tags": "Tags", - "categories": "Categories", - "tagsTitle": "Tags", - "tagsDescription": "Browse blog posts by tag.", - "categoriesTitle": "Categories", - "categoriesDescription": "Browse blog posts by category.", - "readMore": "Read more", - "publishedOn": "Published on", - "browse": "Browse the blog", - "allStories": "All stories", - "latestEyebrow": "From the project", - "latest": "Latest writing", - "article": "article", - "articles": "articles", - "categoryResultsDescription": "Writing collected under this category.", - "tagResultsDescription": "Writing collected under this topic.", - "noTags": "No tags yet. Check back soon!", - "noCategories": "No categories yet. Check back soon!" - }, - "docs": { - "onThisPage": "On this page", - "editOnGithub": "Edit on GitHub", - "nextPage": "Next", - "prevPage": "Previous" - }, - "footer": { - "madeWith": "Built with", - "license": "MIT License", - "copyright": "The Basilisk Project", - "product": "Product", - "community": "Community", - "legal": "Legal", - "documentation": "Documentation", - "rulesReference": "Rules Reference", - "blog": "Blog", - "authors": "Authors", - "github": "GitHub", - "issues": "Issues", - "discord": "Discord", - "discussions": "Discussions", - "mit": "MIT" - } - }, - "zh": { - "nav": { - "docs": "文档", - "rules": "规则", - "blog": "博客", - "discord": "Discord", - "github": "GitHub", - "getStarted": "立即开始" - }, - "blog": { - "title": "博客", - "eyebrow": "Basilisk 技术期刊", - "headline": "探索更安全的 Python。", - "subtitle": "来自 Basilisk 项目的更新、公告和深度解析。", - "description": "来自 Basilisk 项目的更新、公告和深度解析。", - "tags": "标签", - "categories": "分类", - "tagsTitle": "标签", - "tagsDescription": "按标签浏览博客文章。", - "categoriesTitle": "分类", - "categoriesDescription": "按分类浏览博客文章。", - "readMore": "阅读更多", - "publishedOn": "发布于", - "browse": "浏览博客", - "allStories": "全部文章", - "latestEyebrow": "项目动态", - "latest": "最新文章", - "article": "篇文章", - "articles": "篇文章", - "categoryResultsDescription": "此分类下的全部文章。", - "tagResultsDescription": "此主题下的全部文章。", - "noTags": "暂无标签。", - "noCategories": "暂无分类。" - }, - "docs": { - "onThisPage": "本页内容", - "editOnGithub": "在 GitHub 上编辑", - "nextPage": "下一页", - "prevPage": "上一页" - }, - "footer": { - "madeWith": "构建于", - "license": "MIT 许可证", - "copyright": "Basilisk 项目", - "product": "产品", - "community": "社区", - "legal": "法律", - "documentation": "文档", - "rulesReference": "规则参考", - "blog": "博客", - "authors": "作者", - "github": "GitHub", - "issues": "问题", - "discord": "Discord", - "discussions": "讨论", - "mit": "MIT 许可证" - } - } -} diff --git a/website/src/_data/navigation.json b/website/src/_data/navigation.json index 53af2ed38..f9a72c6fc 100644 --- a/website/src/_data/navigation.json +++ b/website/src/_data/navigation.json @@ -1,92 +1,9 @@ { "main": [ - { "key": "playground", "text": "Playground", "url": "/playground/" }, - { "key": "docs", "text": "Docs", "url": "/docs/" }, - { "key": "rules", "text": "Rules", "url": "/docs/rules/" }, - { "key": "blog", "text": "Blog", "url": "/blog/" }, - { "key": "discord", "text": "Discord", "url": "https://discord.gg/4wBDSGEZQd", "external": true }, { "key": "github", "text": "GitHub", "url": "https://github.com/Nimblesite/Basilisk", "external": true } ], - "docs": [ - { - "title": "Introduction", - "titleZh": "简介", - "url": "/docs/" - }, - { - "title": "Getting started", - "titleZh": "开始使用", - "items": [ - { - "title": "Installation", - "titleZh": "安装", - "children": [ - { "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/" } - ] - }, - { "title": "Quick start", "titleZh": "快速开始", "url": "/docs/quick-start/" } - ] - }, - { - "title": "Set up & adopt", - "titleZh": "配置与采用", - "items": [ - { "title": "Configuration", "titleZh": "配置", "url": "/docs/configuration/" }, - { "title": "Migration guide", "titleZh": "迁移指南", "url": "/docs/migration/" } - ] - }, - { - "title": "Type checking", - "titleZh": "类型检查", - "items": [ - { "title": "Rules", "titleZh": "规则", "url": "/docs/rules/", "kind": "rules" }, - { "title": "Conformance", "titleZh": "规范符合性", "url": "/docs/conformance/" }, - { "title": "Type checker comparison", "titleZh": "类型检查器对比", "url": "/docs/comparison/" } - ] - }, - { - "title": "Developer tools", - "titleZh": "开发者工具", - "items": [ - { "title": "Refactoring", "titleZh": "重构", "url": "/docs/refactoring/" }, - { "title": "Debugging", "titleZh": "调试", "url": "/docs/debugging/" }, - { "title": "Profiler", "titleZh": "性能分析", "url": "/docs/profiler/" } - ] - }, - { - "title": "Project", - "titleZh": "项目", - "items": [ - { "title": "Benchmarks", "titleZh": "性能基准", "url": "/docs/benchmarks/" }, - { "title": "Releases", "titleZh": "版本发布", "url": "/docs/releases/" } - ] - } - ], + "docs": [], "footer": [ - { - "key": "product", - "title": "Product", - "items": [ - { "key": "documentation", "text": "Documentation", "url": "/docs/" }, - { "key": "rulesReference", "text": "Rules Reference", "url": "/docs/rules/" }, - { "key": "blog", "text": "Blog", "url": "/blog/" }, - { "key": "authors", "text": "Authors", "url": "/authors/" } - ] - }, - { - "key": "community", - "title": "Community", - "items": [ - { "key": "github", "text": "GitHub", "url": "https://github.com/Nimblesite/Basilisk" }, - { "key": "issues", "text": "Issues", "url": "https://github.com/Nimblesite/Basilisk/issues" }, - { "key": "discord", "text": "Discord", "url": "https://discord.gg/4wBDSGEZQd" }, - { "key": "discussions", "text": "Discussions", "url": "https://github.com/Nimblesite/Basilisk/discussions" } - ] - }, { "key": "legal", "title": "Legal", diff --git a/website/src/_data/releases.js b/website/src/_data/releases.js deleted file mode 100644 index 0c693c445..000000000 --- a/website/src/_data/releases.js +++ /dev/null @@ -1,150 +0,0 @@ -// Eleventy global data: the Basilisk GitHub Releases, fetched FRESH at every -// build from the public GitHub REST API — never hand-maintained. This mirrors -// the build-time data pattern of _data/conformance.js and _data/benchmarks.js: -// everything the /docs/releases/ page shows is whatever the API returns at build -// time (tag, title, date, release notes rendered from the release's markdown -// body, and downloadable assets). -// -// Drafts are excluded (not yet published). Prereleases are kept and badged. -// -// The build NEVER fails on a network/API error: exactly like conformance.js it -// degrades to `{ hasData: false }` and the page renders an empty state linking -// to GitHub, so an offline dev build or a rate-limited CI run still produces a -// valid site. When `GITHUB_TOKEN`/`GH_TOKEN` is present (CI) it is used to raise -// the API rate limit; the public, unauthenticated path works too. -import markdownIt from "markdown-it"; - -const OWNER = "Nimblesite"; -const REPO = "Basilisk"; -const API = `https://api.github.com/repos/${OWNER}/${REPO}/releases?per_page=100`; -const RELEASES_URL = `https://github.com/${OWNER}/${REPO}/releases`; - -// Release notes are authored by the maintainers (trusted), so raw HTML is -// allowed. `breaks: true` matches how GitHub itself renders release bodies. -const md = markdownIt({ html: true, linkify: true, breaks: true }); - -// Withdrawn-claim redaction. Release notes arrive verbatim from GitHub, and some -// historical entries quote the conformance result that has since been retracted. -// [CHKARCH-CONFORMANCE] forbids publishing, quoting, or marketing any conformance -// figure, so rendering those lines unchanged would keep republishing a claim we -// have withdrawn. Any line pairing a conformance subject with a figure is replaced -// by a visible marker — nothing is silently dropped, the marker links to the -// correction, and every release heading still links to the unmodified GitHub -// release so the original wording stays one click away. -const CLAIM_SUBJECT = /conformance|conformant/i; -const CLAIM_FIGURE = /\d+(?:\.\d+)?\s*%|\b\d{1,4}\s*\/\s*\d{1,4}\b|\b\d+\s+false\s+positives?\b/i; -const LIST_MARKER = /^(\s*(?:[-*+]|\d+\.)\s+)/; -const REDACTION = "*[withdrawn conformance claim redacted — see the correction](/docs/conformance/)*"; - -// Replace each claim-bearing line with the marker, preserving its list marker so -// the surrounding changelog structure still renders. -function redactWithdrawnClaims(markdown) { - return markdown - .split(/\r?\n/) - .map((line) => { - if (!CLAIM_SUBJECT.test(line) || !CLAIM_FIGURE.test(line)) return line; - const marker = line.match(LIST_MARKER); - return `${marker ? marker[1] : ""}${REDACTION}`; - }) - .join("\n"); -} - -const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; - -// "2026-06-23T10:16:43Z" -> "Jun 23, 2026". UTC getters keep the output -// deterministic regardless of the build machine's timezone. -function formatDate(iso) { - if (!iso) return null; - const date = new Date(iso); - return Number.isNaN(date.getTime()) - ? iso - : `${MONTHS[date.getUTCMonth()]} ${date.getUTCDate()}, ${date.getUTCFullYear()}`; -} - -// Bytes -> "1.2 MB" style, base-1024. -function formatBytes(bytes) { - if (!Number.isFinite(bytes) || bytes <= 0) return "0 B"; - const units = ["B", "KB", "MB", "GB"]; - const exp = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1); - const value = bytes / 1024 ** exp; - return `${exp === 0 ? value : Math.round(value * 10) / 10} ${units[exp]}`; -} - -// Pull the `rel="next"` URL out of a GitHub `Link` response header (string -// splitting, no regex). Returns null when there is no next page. -function nextPageUrl(linkHeader) { - if (!linkHeader) return null; - for (const part of linkHeader.split(",")) { - const [target, ...attrs] = part.split(";"); - if (attrs.some((attr) => attr.trim() === 'rel="next"')) { - return target.trim().slice(1, -1); // strip the surrounding < > - } - } - return null; -} - -async function fetchAllReleases() { - const headers = { - Accept: "application/vnd.github+json", - "User-Agent": `${OWNER}-${REPO}-website-build`, - "X-GitHub-Api-Version": "2022-11-28", - }; - const token = process.env.GITHUB_TOKEN || process.env.GH_TOKEN; - if (token) headers.Authorization = `Bearer ${token}`; - - const releases = []; - let url = API; - while (url) { - const response = await fetch(url, { headers }); - if (!response.ok) { - throw new Error(`GitHub API ${response.status} ${response.statusText}`); - } - releases.push(...(await response.json())); - url = nextPageUrl(response.headers.get("link")); - } - return releases; -} - -// Shape one API release into the flat record the template renders. -function toRecord(release) { - return { - tag: release.tag_name, - name: release.name || release.tag_name, - url: release.html_url, - date: formatDate(release.published_at || release.created_at), - dateIso: release.published_at || release.created_at, - prerelease: release.prerelease === true, - bodyHtml: release.body ? md.render(redactWithdrawnClaims(release.body)) : "", - assets: (release.assets || []).map((asset) => ({ - name: asset.name, - url: asset.browser_download_url, - size: formatBytes(asset.size), - downloads: asset.download_count || 0, - })), - }; -} - -const EMPTY = { hasData: false, releasesUrl: RELEASES_URL, count: 0, releases: [] }; - -export default async function () { - try { - const published = (await fetchAllReleases()) - .filter((release) => release.draft !== true) - .sort((a, b) => new Date(b.published_at || b.created_at) - new Date(a.published_at || a.created_at)) - .map(toRecord); - - if (!published.length) return EMPTY; - - return { - hasData: true, - releasesUrl: RELEASES_URL, - count: published.length, - latest: published[0], - releases: published, - }; - } catch (error) { - // Degrade gracefully — a broken build is worse than a stale releases page. - console.warn(`⚠ releases.js: ${error.message} — rendering empty state`); - return EMPTY; - } -} diff --git a/website/src/_data/retiredUrls.json b/website/src/_data/retiredUrls.json new file mode 100644 index 000000000..286abc833 --- /dev/null +++ b/website/src/_data/retiredUrls.json @@ -0,0 +1,299 @@ +[ + "/authors/", + "/authors/basilisk-team/", + "/authors/christian-findlay/", + "/blog/", + "/blog/ai-agents-write-python-type-checking-guardrail/", + "/blog/basilisk-037-python-type-checker-configuration/", + "/blog/basilisk-100-percent-python-typing-conformance/", + "/blog/categories/", + "/blog/categories/announcements/", + "/blog/categories/deep-dives/", + "/blog/free-threaded-python-why-type-checking-matters-more/", + "/blog/introducing-basilisk/", + "/blog/openai-acquires-astral-what-it-means-for-basilisk/", + "/blog/python-315-typeform-fastapi-pydantic-annotations/", + "/blog/tags/", + "/blog/tags/ai-coding-assistants/", + "/blog/tags/fastapi/", + "/blog/tags/pydantic/", + "/blog/tags/python-performance/", + "/blog/tags/python-tooling/", + "/blog/tags/python-typing/", + "/blog/type-manipulation-pep-827/", + "/docs/", + "/docs/benchmarks/", + "/docs/comparison/", + "/docs/configuration/", + "/docs/conformance/", + "/docs/debugging/", + "/docs/formatting/", + "/docs/install-cli/", + "/docs/install-neovim/", + "/docs/install-vscode/", + "/docs/install-zed/", + "/docs/installation/", + "/docs/migration/", + "/docs/profiler/", + "/docs/quick-start/", + "/docs/refactoring/", + "/docs/releases/", + "/docs/rules/", + "/docs/rules/basilisk/dependencies/", + "/docs/rules/basilisk/imports/", + "/docs/rules/basilisk/redundancy/", + "/docs/rules/basilisk/strictness/", + "/docs/rules/basilisk/stubs/", + "/docs/rules/basilisk/style/", + "/docs/rules/basilisk/suppressions/", + "/docs/rules/missing-annotations/", + "/docs/rules/pep/aliases/", + "/docs/rules/pep/annotations/", + "/docs/rules/pep/callables/", + "/docs/rules/pep/classes/", + "/docs/rules/pep/constructors/", + "/docs/rules/pep/core/", + "/docs/rules/pep/dataclasses/", + "/docs/rules/pep/directives/", + "/docs/rules/pep/enums/", + "/docs/rules/pep/generics/", + "/docs/rules/pep/historical/", + "/docs/rules/pep/literals/", + "/docs/rules/pep/namedtuples/", + "/docs/rules/pep/narrowing/", + "/docs/rules/pep/overloads/", + "/docs/rules/pep/protocols/", + "/docs/rules/pep/qualifiers/", + "/docs/rules/pep/specialtypes/", + "/docs/rules/pep/tuples/", + "/docs/rules/pep/typeddicts/", + "/docs/rules/type-safety/", + "/errors/", + "/errors/BSK-0001/", + "/errors/BSK-0002/", + "/errors/BSK-0003/", + "/errors/BSK-0004/", + "/errors/BSK-0005/", + "/errors/BSK-0011/", + "/errors/BSK-0012/", + "/errors/BSK-0013/", + "/errors/BSK-0014/", + "/errors/BSK-0025/", + "/errors/BSK-0040/", + "/errors/BSK-0050/", + "/errors/BSK-0060/", + "/errors/BSK-0061/", + "/errors/BSK-0062/", + "/errors/BSK-0063/", + "/errors/BSK-0152/", + "/errors/aliases_implicit/", + "/errors/aliases_newtype/", + "/errors/aliases_recursive/", + "/errors/aliases_type_statement/", + "/errors/aliases_typealiastype/", + "/errors/annotations_forward_refs/", + "/errors/annotations_generators/", + "/errors/annotations_generators_2/", + "/errors/annotations_typeexpr/", + "/errors/assignment_compatibility/", + "/errors/callables_annotation/", + "/errors/callables_kwargs/", + "/errors/callables_protocol/", + "/errors/callables_protocol_2/", + "/errors/callables_subtyping/", + "/errors/calls_argument_count/", + "/errors/calls_argument_type/", + "/errors/classes_classvar/", + "/errors/classes_override/", + "/errors/classes_override_2/", + "/errors/classes_override_3/", + "/errors/constructors_call_init/", + "/errors/constructors_call_new/", + "/errors/constructors_call_type/", + "/errors/constructors_callable/", + "/errors/dataclasses_frozen/", + "/errors/dataclasses_hash/", + "/errors/dataclasses_inheritance/", + "/errors/dataclasses_kwonly/", + "/errors/dataclasses_match_args/", + "/errors/dataclasses_order/", + "/errors/dataclasses_postinit/", + "/errors/dataclasses_slots/", + "/errors/dataclasses_transform_class/", + "/errors/dataclasses_transform_meta/", + "/errors/dataclasses_usage/", + "/errors/dict_key_hashable/", + "/errors/directives_assert_type/", + "/errors/directives_assert_type_2/", + "/errors/directives_cast/", + "/errors/directives_deprecated/", + "/errors/directives_disjoint_base/", + "/errors/directives_reveal_type/", + "/errors/directives_version_platform/", + "/errors/enums_behaviors/", + "/errors/enums_definition/", + "/errors/enums_expansion/", + "/errors/enums_member_values/", + "/errors/enums_members/", + "/errors/enums_members_2/", + "/errors/generics_base_class/", + "/errors/generics_base_class_2/", + "/errors/generics_base_class_3/", + "/errors/generics_basic/", + "/errors/generics_basic_2/", + "/errors/generics_basic_3/", + "/errors/generics_defaults/", + "/errors/generics_defaults_2/", + "/errors/generics_defaults_referential/", + "/errors/generics_defaults_referential_2/", + "/errors/generics_defaults_specialization/", + "/errors/generics_scoping/", + "/errors/generics_self_attributes/", + "/errors/generics_self_basic/", + "/errors/generics_self_protocols/", + "/errors/generics_self_usage/", + "/errors/generics_syntax_compatibility/", + "/errors/generics_syntax_declarations/", + "/errors/generics_syntax_declarations_2/", + "/errors/generics_syntax_scoping/", + "/errors/generics_type_erasure/", + "/errors/generics_typevartuple_args/", + "/errors/generics_typevartuple_basic/", + "/errors/generics_typevartuple_basic_2/", + "/errors/generics_typevartuple_basic_3/", + "/errors/generics_typevartuple_callable/", + "/errors/generics_typevartuple_specialization/", + "/errors/generics_typevartuple_specialization_2/", + "/errors/generics_typevartuple_unpack/", + "/errors/generics_upper_bound/", + "/errors/generics_upper_bound_2/", + "/errors/generics_variance/", + "/errors/generics_variance_inference/", + "/errors/historical_positional/", + "/errors/imports_missing_name/", + "/errors/imports_module_attribute/", + "/errors/imports_unresolved/", + "/errors/literals_literalstring/", + "/errors/literals_parameterizations/", + "/errors/literals_parameterizations_2/", + "/errors/literals_semantics/", + "/errors/literals_semantics_2/", + "/errors/match_exhaustiveness/", + "/errors/namedtuples_define_class/", + "/errors/namedtuples_define_functional/", + "/errors/namedtuples_type_compat/", + "/errors/namedtuples_usage/", + "/errors/names_unbound/", + "/errors/names_undefined/", + "/errors/narrowing_typeguard/", + "/errors/narrowing_typeis/", + "/errors/narrowing_typeis_2/", + "/errors/overloads_basic/", + "/errors/overloads_consistency/", + "/errors/overloads_consistency_2/", + "/errors/overloads_consistency_3/", + "/errors/overloads_definitions/", + "/errors/overloads_evaluation/", + "/errors/protocols_class_objects/", + "/errors/protocols_class_objects_2/", + "/errors/protocols_definition/", + "/errors/protocols_definition_2/", + "/errors/protocols_explicit/", + "/errors/protocols_explicit_2/", + "/errors/protocols_explicit_3/", + "/errors/protocols_generic/", + "/errors/protocols_merging/", + "/errors/protocols_modules/", + "/errors/protocols_runtime_checkable/", + "/errors/protocols_runtime_checkable_2/", + "/errors/protocols_subtyping/", + "/errors/protocols_variance/", + "/errors/protocols_variance_2/", + "/errors/qualifiers_annotated/", + "/errors/qualifiers_annotated_2/", + "/errors/qualifiers_final_annotation/", + "/errors/qualifiers_final_annotation_2/", + "/errors/qualifiers_final_decorator/", + "/errors/returns_compatibility/", + "/errors/returns_compatibility_2/", + "/errors/specialtypes_never/", + "/errors/specialtypes_never_2/", + "/errors/specialtypes_promotions/", + "/errors/specialtypes_type/", + "/errors/tuples_index/", + "/errors/tuples_index_2/", + "/errors/tuples_type_compat/", + "/errors/tuples_type_form/", + "/errors/tuples_type_form_2/", + "/errors/typeddicts_alt_syntax/", + "/errors/typeddicts_class_syntax/", + "/errors/typeddicts_class_syntax_2/", + "/errors/typeddicts_extra_items/", + "/errors/typeddicts_inheritance/", + "/errors/typeddicts_operations/", + "/errors/typeddicts_readonly/", + "/errors/typeddicts_required/", + "/errors/typeddicts_usage/", + "/errors/typeshed_source_license_changed/", + "/errors/typeshed_source_unpinned/", + "/errors/typeshed_source_user_managed/", + "/errors/version_target_syntax/", + "/playground/", + "/zh/", + "/zh/blog/", + "/zh/blog/basilisk-100-percent-python-typing-conformance/", + "/zh/blog/categories/", + "/zh/blog/categories/announcements/", + "/zh/blog/categories/deep-dives/", + "/zh/blog/free-threaded-python-why-type-checking-matters-more/", + "/zh/blog/introducing-basilisk/", + "/zh/blog/openai-acquires-astral-what-it-means-for-basilisk/", + "/zh/blog/tags/", + "/zh/blog/tags/python-performance/", + "/zh/blog/tags/python-tooling/", + "/zh/blog/tags/python-typing/", + "/zh/docs/", + "/zh/docs/comparison/", + "/zh/docs/configuration/", + "/zh/docs/conformance/", + "/zh/docs/debugging/", + "/zh/docs/install-cli/", + "/zh/docs/install-neovim/", + "/zh/docs/install-vscode/", + "/zh/docs/install-zed/", + "/zh/docs/installation/", + "/zh/docs/migration/", + "/zh/docs/profiler/", + "/zh/docs/quick-start/", + "/zh/docs/refactoring/", + "/zh/docs/rules/", + "/zh/docs/rules/basilisk/dependencies/", + "/zh/docs/rules/basilisk/imports/", + "/zh/docs/rules/basilisk/redundancy/", + "/zh/docs/rules/basilisk/strictness/", + "/zh/docs/rules/basilisk/stubs/", + "/zh/docs/rules/basilisk/style/", + "/zh/docs/rules/basilisk/suppressions/", + "/zh/docs/rules/missing-annotations/", + "/zh/docs/rules/pep/aliases/", + "/zh/docs/rules/pep/annotations/", + "/zh/docs/rules/pep/callables/", + "/zh/docs/rules/pep/classes/", + "/zh/docs/rules/pep/constructors/", + "/zh/docs/rules/pep/core/", + "/zh/docs/rules/pep/dataclasses/", + "/zh/docs/rules/pep/directives/", + "/zh/docs/rules/pep/enums/", + "/zh/docs/rules/pep/generics/", + "/zh/docs/rules/pep/historical/", + "/zh/docs/rules/pep/literals/", + "/zh/docs/rules/pep/namedtuples/", + "/zh/docs/rules/pep/narrowing/", + "/zh/docs/rules/pep/overloads/", + "/zh/docs/rules/pep/protocols/", + "/zh/docs/rules/pep/qualifiers/", + "/zh/docs/rules/pep/specialtypes/", + "/zh/docs/rules/pep/tuples/", + "/zh/docs/rules/pep/typeddicts/", + "/zh/docs/rules/type-safety/" +] diff --git a/website/src/_data/ruleStats.js b/website/src/_data/ruleStats.js deleted file mode 100644 index d1138d7ac..000000000 --- a/website/src/_data/ruleStats.js +++ /dev/null @@ -1,33 +0,0 @@ -// Implements [WEBSITE-ERROR-PAGES]: headline counts for the rules overview, kept -// in sync with the checker source so the prose can never drift from the table. -import { readFileSync } from "node:fs"; -import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; - -const here = dirname(fileURLToPath(import.meta.url)); -const rules = JSON.parse(readFileSync(join(here, "rules.json"), "utf8")); - -const errors = rules.filter((rule) => rule.severity === "error").length; -// Provenance split — the axis that actually matters on the homepage: the PEP -// typing-spec rules the conformance suite grades (on by default) are counted -// SEPARATELY from Basilisk's opt-in house-style rules (off by default). Never -// lump the two into one headline number. Provenance is the checker's own -// `opt_in_spec` signal, threaded through rules.json by gen_rules_reference.py. -const optIn = rules.filter((rule) => rule.provenance === "basilisk").length; -const pep = rules.length - optIn; -// The opt-in rules that enforce *real* typing beyond the spec — the ones the -// checker tags `strictness` (require an annotation on every parameter, return, -// variable, vararg, and attribute; require @override; reject implicit `Any`; -// annotate lambdas). Counted from the checker's own tags, so it can't drift. -const strictness = rules.filter((rule) => - (rule.tags || []).includes("strictness"), -).length; - -export default { - total: rules.length, - errors, - warnings: rules.length - errors, - pep, - optIn, - strictness, -}; diff --git a/website/src/_data/ruleTagGroups.js b/website/src/_data/ruleTagGroups.js deleted file mode 100644 index 25a011ca1..000000000 --- a/website/src/_data/ruleTagGroups.js +++ /dev/null @@ -1,95 +0,0 @@ -import rules from "./rules.json" with { type: "json" }; - -const LABELS = { - core: ["Cross-cutting core", "跨领域核心规则"], - aliases: ["Type aliases", "类型别名"], - annotations: ["Annotations", "类型注解"], - callables: ["Callables", "可调用对象"], - classes: ["Classes", "类"], - constructors: ["Constructors", "构造器"], - dataclasses: ["Dataclasses", "数据类"], - directives: ["Typing directives", "类型指令"], - enums: ["Enums", "枚举"], - exceptions: ["Exceptions", "异常"], - generics: ["Generics", "泛型"], - historical: ["Historical behavior", "历史行为"], - literals: ["Literals", "字面量"], - namedtuples: ["Named tuples", "命名元组"], - narrowing: ["Type narrowing", "类型缩小"], - overloads: ["Overloads", "重载"], - protocols: ["Protocols", "协议"], - qualifiers: ["Type qualifiers", "类型限定符"], - specialtypes: ["Special types", "特殊类型"], - tuples: ["Tuples", "元组"], - typeddicts: ["Typed dictionaries", "类型字典"], - typeforms: ["Type forms", "类型形式"], - strictness: ["Strictness", "严格性"], - dependencies: ["Dependencies", "依赖管理"], - style: ["Style", "代码风格"], - imports: ["Imports", "导入"], - redundancy: ["Redundancy", "冗余代码"], - stubs: ["Type stubs", "类型存根"], - suppressions: ["Suppressions", "抑制指令"], -}; - -const PEP_ORDER = [ - "core", "aliases", "annotations", "callables", "classes", "constructors", - "dataclasses", "directives", "enums", "exceptions", "generics", "historical", - "literals", "namedtuples", "narrowing", "overloads", "protocols", "qualifiers", - "specialtypes", "tuples", "typeddicts", "typeforms", -]; - -const BASILISK_ORDER = [ - "strictness", "dependencies", "style", "imports", "redundancy", "stubs", - "suppressions", -]; - -function rulesFor(provenance, tag) { - return rules.filter((rule) => { - if (rule.provenance !== provenance) return false; - if (tag === "core") return rule.tags.length === 1; - return rule.tags.includes(tag); - }); -} - -function makeGroups(provenance, order) { - return order - .map((tag) => { - const items = rulesFor(provenance, tag); - const [label, labelZh] = LABELS[tag]; - return { - provenance, - tag, - id: tag, - label, - labelZh, - count: items.length, - items, - url: `/docs/rules/${provenance}/${tag}/`, - zhUrl: `/zh/docs/rules/${provenance}/${tag}/`, - }; - }) - .filter((group) => group.count > 0); -} - -const basilisk = makeGroups("basilisk", BASILISK_ORDER); -const pep = makeGroups("pep", PEP_ORDER); -const basiliskPrimary = basilisk - .map((group) => { - const items = rules.filter( - (rule) => rule.provenance === "basilisk" && rule.tags[1] === group.tag - ); - return { ...group, count: items.length, items }; - }) - .filter((group) => group.count > 0); - -export default { - basilisk, - pep, - pages: [...basilisk, ...pep], - indexGroups: [...basiliskPrimary, ...pep], - counts: { - basilisk: rules.filter((rule) => rule.provenance === "basilisk").length, - pep: rules.filter((rule) => rule.provenance === "pep").length, - }, -}; diff --git a/website/src/_data/rules.json b/website/src/_data/rules.json deleted file mode 100644 index fc75c5e0f..000000000 --- a/website/src/_data/rules.json +++ /dev/null @@ -1,7382 +0,0 @@ -[ - { - "code": "BSK-0001", - "scope": "analyze", - "provenance": "basilisk", - "tags": [ - "basilisk", - "strictness" - ], - "summary": "Missing parameter type annotation", - "summaryHtml": "Missing parameter type annotation", - "body": [ - { - "type": "text", - "html": "receiver exemption shared by TYPEINF-SPECIAL-SELF." - }, - { - "type": "text", - "html": "Never fires where the current engine already infers the parameter type: a scalar-literal default (<code>timeout=30</code> \u2192 <code>int</code>) determines the type, so demanding an annotation there would be redundant (TYPEINF-FUNC-DEFAULTS). Defaults that do NOT determine the type \u2014 <code>None</code>, empty containers, calls, lambdas, arbitrary expressions \u2014 still require an annotation." - }, - { - "type": "code", - "lang": "python", - "code": "def connect(timeout=30): # \u2713 \u2014 type inferred as int\n pass\n\ndef connect(retries): # BSK-0001 \u2014 nothing to infer from\n pass\n\ndef connect(timeout=None): # BSK-0001 \u2014 None does not determine T | None\n pass" - } - ], - "group": "Missing Annotations", - "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", - "scope": "analyze", - "provenance": "basilisk", - "tags": [ - "basilisk", - "strictness" - ], - "summary": "Missing return type annotation", - "summaryHtml": "Missing return type annotation", - "body": [ - { - "type": "text", - "html": "Never fires where the current engine already infers the return type (TYPEINF-FUNC-RETURN): a body whose every <code>return</code> is bare or carries a type-determining literal \u2014 or that has no <code>return</code> at all (<code>None</code>) \u2014 needs no annotation. Returns the engine cannot infer (calls, names, arbitrary expressions) and generators (<code>Generator...</code>) still require one." - }, - { - "type": "code", - "lang": "python", - "code": "def answer(): # \u2713 \u2014 return type inferred as int\n return 42\n\ndef log_it(msg: str): # \u2713 \u2014 no return: inferred as None\n print(msg)\n\ndef fetch(): # BSK-0002 \u2014 call result is not inferable\n return make_value()" - } - ], - "group": "Missing Annotations", - "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", - "scope": "analyze", - "provenance": "basilisk", - "tags": [ - "basilisk", - "strictness" - ], - "summary": "Missing variable type annotation", - "summaryHtml": "Missing variable type annotation", - "body": [ - { - "type": "text", - "html": "Fires when a module-level variable has no type annotation. This house rule is off by default \u2014 the default configuration is pure PEP conformance \u2014 and a project opts in via configuration. When enabled, every module-level binding must carry an explicit annotation so that Basilisk can verify downstream usage and generate accurate stubs." - } - ], - "group": "Missing Annotations", - "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", - "scope": "analyze", - "provenance": "basilisk", - "tags": [ - "basilisk", - "strictness" - ], - "summary": "Missing `*args` / `**kwargs` type annotation", - "summaryHtml": "Missing <code>*args</code> / <code>**kwargs</code> type annotation", - "body": [ - { - "type": "text", - "html": "This house rule is off by default \u2014 the default configuration is pure PEP conformance. When a project enables it, every variadic positional parameter (<code>*args</code>) and variadic keyword parameter (<code>**kwargs</code>) must carry an explicit type annotation." - } - ], - "group": "Missing Annotations", - "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", - "scope": "analyze", - "provenance": "basilisk", - "tags": [ - "basilisk", - "strictness" - ], - "summary": "Missing class attribute type annotation", - "summaryHtml": "Missing class attribute type annotation", - "body": [ - { - "type": "text", - "html": "Every class attribute declared in the class body must have an explicit type annotation. Without one, Basilisk cannot verify assignments to the attribute and cannot produce accurate stub types." - }, - { - "type": "text", - "html": "Enum subclasses and Protocol subclasses are exempt: Enum members have metaclass-synthesised <code>Literal...</code> types, and Protocol attributes are interface specifications rather than concrete class variables." - } - ], - "group": "Missing Annotations", - "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", - "scope": "analyze", - "provenance": "basilisk", - "tags": [ - "basilisk", - "dependencies", - "imports" - ], - "summary": "Undeclared dependency import", - "summaryHtml": "Undeclared dependency import", - "body": [ - { - "type": "text", - "html": "Fires when an import resolves to a package that is only a transitive dependency \u2014 present in <code>uv.lock</code> but not listed in the project's <code>project.dependencies</code> in <code>pyproject.toml</code>." - }, - { - "type": "text", - "html": "Transitive dependencies can disappear when a direct dependency drops them, breaking imports that relied on their implicit availability." - }, - { - "type": "code", - "lang": "python", - "code": "import urllib3 # BSK-0011: 'urllib3' is a transitive dependency (via requests)" - } - ], - "group": "Dependencies", - "docsUrl": "https://www.basilisk-python.dev/errors/BSK-0011", - "references": [ - { - "label": "PEP 621", - "url": "https://peps.python.org/pep-0621/" - } - ] - }, - { - "code": "BSK-0012", - "scope": "analyze", - "provenance": "basilisk", - "tags": [ - "basilisk", - "dependencies" - ], - "summary": "Unused dependency", - "summaryHtml": "Unused dependency", - "body": [ - { - "type": "text", - "html": "Fires when a package is declared in <code>project.dependencies</code> but no module in the workspace imports it. This indicates a dependency that can be removed, reducing the project's dependency footprint." - }, - { - "type": "text", - "html": "This is a **whole-module-only** diagnostic \u2014 it requires scanning all files in the workspace to determine which packages are actually imported. The rule currently provides the skeleton; it activates when the workspace layer provides aggregate import data." - } - ], - "group": "Dependencies", - "docsUrl": "https://www.basilisk-python.dev/errors/BSK-0012", - "references": [ - { - "label": "PEP 621", - "url": "https://peps.python.org/pep-0621/" - } - ] - }, - { - "code": "BSK-0013", - "scope": "analyze", - "provenance": "basilisk", - "tags": [ - "basilisk", - "dependencies" - ], - "summary": "Stale uv lock file", - "summaryHtml": "Stale uv lock file", - "body": [ - { - "type": "text", - "html": "Fires when the <code>uv.lock</code> file is older than <code>pyproject.toml</code>, indicating that dependencies may have changed without re-locking. This can cause import resolution to use stale package versions." - }, - { - "type": "text", - "html": "This rule currently provides the skeleton; it activates when the workspace provides lock-file staleness information via the resolver context." - } - ], - "group": "Dependencies", - "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", - "scope": "analyze", - "provenance": "basilisk", - "tags": [ - "basilisk", - "style", - "strictness" - ], - "summary": "Explicit `Any` annotation", - "summaryHtml": "Explicit <code>Any</code> annotation", - "body": [ - { - "type": "text", - "html": "Emitted as a <code>Warning</code> when a function parameter or return annotation is written as <code>Any</code> (from <code>typing</code>). <code>Any</code> silences all type checking for the annotated value and should be used only when intentional." - }, - { - "type": "text", - "html": "This is an opinionated <em>strictness nudge</em>, not a type-system requirement: the typing spec treats <code>Any</code> as a fully valid type. It is therefore a distinct (user-suppressible) code from the genuine return-type-mismatch error (returns_compatibility); the two used to share a code, so a user could not silence the style nudge while keeping the real type check. BSK-0014 itself is never disabled for PEP conformance \u2014 like every rule it runs fully enabled during scoring; there is no "spec-conformance mode" that turns it off. See CHKARCH-CONFORMANCE-MODE." - }, - { - "type": "code", - "lang": "python", - "code": "from typing import Any\n\ndef greet(name: Any) -> str: ... # BSK-0014 \u2014 parameter `name` is annotated Any\ndef parse(text: str) -> Any: ... # BSK-0014 \u2014 return annotation is Any\n\ndef greet(name: str) -> str: ... # NO warning \u2014 concrete types" - } - ], - "group": "Style", - "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", - "scope": "analyze", - "provenance": "basilisk", - "tags": [ - "basilisk", - "strictness" - ], - "summary": "Missing `@override` decorator", - "summaryHtml": "Missing <code>@override</code> decorator", - "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 <code>@override</code> decorator (<a href=\"https://peps.python.org/pep-0698/\">PEP 698</a> / <code>typing.override</code>)." - }, - { - "type": "text", - "html": "The check is limited to base classes that appear in the same source module, because Basilisk cannot inspect the base class body without resolving cross-module imports in Phase 1." - }, - { - "type": "text", - "html": "Protocol implementations are exempt: when a class satisfies a <code>Protocol</code> contract, it is expected to define the protocol methods without <code>@override</code>." - }, - { - "type": "text", - "html": "Version gate (issue #171): <code>@override</code> (<a href=\"https://peps.python.org/pep-0698/\">PEP 698</a> / <code>typing.override</code>) 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 <code>python_version</code> is below 3.12." - } - ], - "group": "Missing Annotations", - "docsUrl": "https://www.basilisk-python.dev/errors/BSK-0025", - "references": [ - { - "label": "PEP 698", - "url": "https://peps.python.org/pep-0698/" - } - ] - }, - { - "code": "BSK-0040", - "scope": "analyze", - "provenance": "basilisk", - "tags": [ - "basilisk", - "strictness" - ], - "summary": "Lambda function missing type annotations", - "summaryHtml": "Lambda function missing type annotations", - "body": [ - { - "type": "text", - "html": "Emitted when a lambda function is assigned to a variable without type annotations. This is a warning rather than an error since lambda functions are often used for simple operations where type annotations might be considered verbose." - }, - { - "type": "code", - "lang": "python", - "code": "# BAD (warning)\nf = lambda x: x + 1 # W: lambda assigned to unannotated variable 'f'\n\n# GOOD\nf: Callable[[int], int] = lambda x: x + 1 # OK: variable has type annotation" - } - ], - "group": "Missing Annotations", - "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", - "scope": "analyze", - "provenance": "basilisk", - "tags": [ - "basilisk", - "redundancy", - "style" - ], - "summary": "Redundant type annotation warning", - "summaryHtml": "Redundant type annotation warning", - "body": [ - { - "type": "text", - "html": "Emits a warning when a type annotation is redundant because the inferred type exactly matches the declared type. This is Basilisk's headline differentiator from other type checkers." - }, - { - "type": "code", - "lang": "python", - "code": "x: int = 42 # BSK-0050 \u2014 annotation is redundant\ny: str = \"hello\" # BSK-0050 \u2014 annotation is redundant\nz: float = 42 # NO warning \u2014 annotation adds information (widening)" - } - ], - "group": "Redundancy", - "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", - "scope": "analyze", - "provenance": "basilisk", - "tags": [ - "basilisk", - "suppressions" - ], - "summary": "Active code-specific suppression", - "summaryHtml": "Active code-specific suppression", - "body": [ - { - "type": "text", - "html": "Reports a valid source directive that names one or more Basilisk rules and actively suppresses a diagnostic or changes its effective severity." - } - ], - "group": "Suppressions", - "docsUrl": "https://www.basilisk-python.dev/errors/BSK-0060", - "references": [] - }, - { - "code": "BSK-0061", - "scope": "analyze", - "provenance": "basilisk", - "tags": [ - "basilisk", - "suppressions" - ], - "summary": "Active blanket suppression", - "summaryHtml": "Active blanket suppression", - "body": [ - { - "type": "text", - "html": "Reports a valid source directive that actively changes diagnostics without selecting individual Basilisk rule codes." - } - ], - "group": "Suppressions", - "docsUrl": "https://www.basilisk-python.dev/errors/BSK-0061", - "references": [] - }, - { - "code": "BSK-0062", - "scope": "analyze", - "provenance": "basilisk", - "tags": [ - "basilisk", - "suppressions" - ], - "summary": "Unused suppression directive", - "summaryHtml": "Unused suppression directive", - "body": [ - { - "type": "text", - "html": "Reports a syntactically valid directive that matches no diagnostic or does not change the effective severity of anything it matches." - } - ], - "group": "Suppressions", - "docsUrl": "https://www.basilisk-python.dev/errors/BSK-0062", - "references": [] - }, - { - "code": "BSK-0063", - "scope": "analyze", - "provenance": "basilisk", - "tags": [ - "basilisk", - "suppressions" - ], - "summary": "Malformed suppression directive", - "summaryHtml": "Malformed suppression directive", - "body": [ - { - "type": "text", - "html": "Reports malformed directives, unknown Basilisk rule codes, conflicting directives, and unmatched block boundaries." - } - ], - "group": "Suppressions", - "docsUrl": "https://www.basilisk-python.dev/errors/BSK-0063", - "references": [] - }, - { - "code": "BSK-0152", - "scope": "analyze", - "provenance": "basilisk", - "tags": [ - "basilisk", - "stubs" - ], - "summary": "Missing type stubs for installed package", - "summaryHtml": "Missing type stubs for installed package", - "body": [ - { - "type": "text", - "html": "Fires when a package is imported and resolves to a <code>.py</code> source file (not <code>.pyi</code>) without a <code>py.typed</code> marker. This means the package is installed but lacks type information, reducing type safety. This rule is off by default \u2014 the default configuration is pure PEP conformance \u2014 and a project opts in with an explicit <code>BSK-0152</code> severity. Once enabled, an untyped third-party import is a hard error; a project can soften it per import (<code># type: warningBSK-0152</code>) or globally (<code>"BSK-0152" = "warning"</code>) to use non-type-safe libraries at its own risk." - }, - { - "type": "code", - "lang": "python", - "code": "import flask # E0152: Package 'flask' is installed but has no type stubs" - } - ], - "group": "Stubs", - "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", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep", - "aliases" - ], - "summary": "Invalid right-hand side for a `TypeAlias` annotation", - "summaryHtml": "Invalid right-hand side for a <code>TypeAlias</code> annotation", - "body": [ - { - "type": "text", - "html": "<a href=\"https://peps.python.org/pep-0613/\">PEP 613</a> requires that the RHS of an explicit <code>TypeAlias</code> annotation must be a valid type expression. The following are errors:" - }, - { - "type": "text", - "html": "- List literals: <code>x: TypeAlias = int, str</code> - Tuple literals: <code>x: TypeAlias = ((int, str),)</code> - Dict literals: <code>x: TypeAlias = {"a": "b"}</code> - List comprehensions: <code>x: TypeAlias = int for i in range(1)</code> - Lambda calls: <code>x: TypeAlias = (lambda: int)()</code> - Conditional expressions: <code>x: TypeAlias = int if cond else str</code> - Boolean literals: <code>x: TypeAlias = True</code> - Integer literals: <code>x: TypeAlias = 1</code> - Binary boolean operators: <code>x: TypeAlias = list or set</code> - F-strings: <code>x: TypeAlias = f"..."</code> - Subscript-into-subscript: <code>x: TypeAlias = int0</code> - Runtime calls: <code>x: TypeAlias = eval("int")</code>" - }, - { - "type": "code", - "lang": "python", - "code": "from typing import TypeAlias\nBadTypeAlias2: TypeAlias = [int, str] # E \u2014 list literal\nBadTypeAlias10: TypeAlias = True # E \u2014 bool literal" - } - ], - "group": "Type System", - "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", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep", - "aliases" - ], - "summary": "Invalid `NewType(...)` call", - "summaryHtml": "Invalid <code>NewType(...)</code> call", - "body": [ - { - "type": "text", - "html": "<a href=\"https://peps.python.org/pep-0484/\">PEP 484</a> places restrictions on <code>NewType</code>:" - }, - { - "type": "text", - "html": "- The string name must match the variable it is assigned to - The base type must be a proper concrete class - <code>NewType</code> accepts exactly two arguments" - }, - { - "type": "code", - "lang": "python", - "code": "from typing import NewType\nGoodName = NewType(\"BadName\", int) # E: name mismatch\nBadNewType6 = NewType(\"BadNewType6\", int, int) # E: too many arguments\nBadNewType7 = NewType(\"BadNewType7\", Any) # E: cannot be Any" - } - ], - "group": "Type System", - "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", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep", - "aliases" - ], - "summary": "Cyclical type alias reference", - "summaryHtml": "Cyclical type alias reference", - "body": [ - { - "type": "text", - "html": "A <code>TypeAlias</code>-annotated assignment whose RHS contains a forward-reference string that resolves back to the alias itself (directly or through a chain of mutual references) creates an infinite type that cannot be resolved." - }, - { - "type": "code", - "lang": "python", - "code": "from typing import TypeAlias, Union\n\n# Direct self-reference \u2014 the Union *only* wraps itself and a base type,\n# producing an infinitely expanding alias:\nRecursiveUnion: TypeAlias = Union[\"RecursiveUnion\", int] # E\n\n# Mutual reference \u2014 two aliases reference each other:\nA: TypeAlias = Union[\"B\", int]\nB: TypeAlias = Union[\"A\", str] # E" - } - ], - "group": "Type System", - "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", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep", - "aliases" - ], - "summary": "Invalid RHS in a PEP 695 `type X = rhs` statement", - "summaryHtml": "Invalid RHS in a <a href=\"https://peps.python.org/pep-0695/\">PEP 695</a> <code>type X = rhs</code> statement", - "body": [ - { - "type": "text", - "html": "<a href=\"https://peps.python.org/pep-0695/\">PEP 695</a> requires the RHS of a <code>type</code> statement to be a valid type expression. The RHS is validated **structurally** on the parsed <code>ruff</code> expression tree (issue #379 \u2014 substring matching both missed invalid forms and misfired on identifiers containing matched text): names, dotted names, <code>X | Y</code> unions, <code>None</code>, string forward references, and subscriptions of those are type expressions; every other expression form (literals, calls, lambdas, conditionals, comparisons, comprehensions, boolean operators) is not. Subscript <em>arguments</em> are never descended into \u2014 special forms like <code>Literal...</code>, <code>Callable[..., X]</code>, and <code>AnnotatedX, ...</code> legitimately hold non-type expressions there." - }, - { - "type": "code", - "lang": "python", - "code": "type BadAlias1 = [int, str] # E \u2014 list literal\ntype BadAlias2 = True # E \u2014 bool literal\ntype BadAlias3 = 1 # E \u2014 int literal" - } - ], - "group": "Type System", - "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", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep", - "aliases" - ], - "summary": "Invalid `TypeAliasType(...)` call", - "summaryHtml": "Invalid <code>TypeAliasType(...)</code> call", - "body": [ - { - "type": "text", - "html": "Detects violations in <code>TypeAliasType(...)</code> calls:" - }, - { - "type": "text", - "html": "1. **Invalid type expression**: The value argument is not a valid type form (e.g. a list literal, dict literal, lambda, conditional expression)." - }, - { - "type": "text", - "html": "2. **Circular reference**: The alias value references itself directly or through a forward-reference string." - }, - { - "type": "text", - "html": "3. **Undeclared type variable**: A <code>TypeVar</code> / <code>ParamSpec</code> / <code>TypeVarTuple</code> used in the value is not listed in <code>type_params</code>." - }, - { - "type": "text", - "html": "4. **Non-literal <code>type_params</code>**: The <code>type_params</code> keyword argument is not a literal tuple expression." - }, - { - "type": "code", - "lang": "python", - "code": "from typing import TypeAliasType, TypeVar\n\nT = TypeVar(\"T\")\nS = TypeVar(\"S\")\n\nBad1 = TypeAliasType(\"Bad1\", [int, str]) # E: list is not a type expression\nBad2 = TypeAliasType(\"Bad2\", \"Bad2\") # E: circular reference\nBad3 = TypeAliasType(\"Bad3\", list[S], type_params=(T,)) # E: S not in type_params\nBad4 = TypeAliasType(\"Bad4\", int, type_params=my_tuple) # E: not a literal tuple" - } - ], - "group": "Type System", - "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", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep", - "annotations" - ], - "summary": "Invalid type expression in annotation", - "summaryHtml": "Invalid type expression in annotation", - "body": [ - { - "type": "text", - "html": "<a href=\"https://peps.python.org/pep-0484/\">PEP 484</a> requires that annotations contain valid type expressions. Only certain expression forms are valid as types:" - }, - { - "type": "text", - "html": "- Names (<code>int</code>, <code>str</code>, <code>MyClass</code>) - Subscripts (<code>listint</code>, <code>dictstr, int</code>) - Binary-or unions (<code>int | str</code>) - String literals (forward references) - <code>None</code> - <code>...</code> (Ellipsis, in Callable signatures)" - }, - { - "type": "text", - "html": "The following are invalid and should be flagged:" - }, - { - "type": "text", - "html": "- List literals: <code>int, str</code> - Dict literals: <code>{}</code> - Tuple literals: <code>(int, str)</code> - List comprehensions: <code>int for i in range(1)</code> - Lambda expressions (called or uncalled) - Conditional expressions: <code>int if cond else str</code> - Boolean binary operators: <code>int or str</code>, <code>int and str</code> - F-string literals: <code>f"int"</code> - Explicit function calls like <code>eval(...)</code> - Negative numeric literals (positive are caught by E0024) - Names that refer to module objects (<code>import types</code> \u2192 <code>types</code> is a module, not a type) - Names that refer to unannotated literal variables (<code>var1 = 3</code> \u2192 <code>var1</code> is <code>int</code>, not a type)" - }, - { - "type": "code", - "lang": "python", - "code": "def f(x: [int, str]): ... # E \u2014 list literal not a type\ndef g(x: int if True else str): ... # E \u2014 conditional not a type\ny: {} = {} # E \u2014 dict literal not a type" - } - ], - "group": "Type System", - "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", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep", - "annotations" - ], - "summary": "Generator return type and yield type violations", - "summaryHtml": "Generator return type and yield type violations", - "body": [ - { - "type": "text", - "html": "A generator function (one containing <code>yield</code> or <code>yield from</code>) must declare a return type compatible with generator protocols:" - }, - { - "type": "text", - "html": "- Sync generators: <code>Generator</code>, <code>Iterator</code>, or <code>Iterable</code> - Async generators: <code>AsyncGenerator</code>, <code>AsyncIterator</code>, or <code>AsyncIterable</code>" - }, - { - "type": "text", - "html": "Additionally, yield expressions must produce values assignable to the declared yield type, and <code>yield from</code> sub-generators must have compatible yield and send types." - }, - { - "type": "code", - "lang": "python", - "code": "from typing import Generator, Iterator\n\n# BAD -- generator with non-generator return type\ndef bad() -> int:\n yield 1\n\n# GOOD\ndef good() -> Iterator[int]:\n yield 1" - } - ], - "group": "Type System", - "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", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep", - "annotations" - ], - "summary": "Generator yield/send/return type mismatch", - "summaryHtml": "Generator yield/send/return type mismatch", - "body": [ - { - "type": "text", - "html": "When a function is annotated with <code>GeneratorY, S, R</code>, <code>IteratorY</code>, or <code>IterableY</code>, the yield expressions must produce values compatible with <code>Y</code>, and <code>yield from</code> expressions must delegate to generators whose yield and send types are compatible." - }, - { - "type": "code", - "lang": "python", - "code": "from typing import Generator, Iterator\n\nclass A: ...\nclass B: ...\n\ndef bad() -> Generator[A, None, None]:\n yield 3 # E: incompatible yield type\n\ndef bad2() -> Iterator[A]:\n yield B() # E: incompatible yield type" - } - ], - "group": "Type System", - "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", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep", - "annotations" - ], - "summary": "Invalid type form \u2014 numeric literal used as type annotation", - "summaryHtml": "Invalid type form \u2014 numeric literal used as type annotation", - "body": [ - { - "type": "text", - "html": "Type annotations must be type expressions, not literal values. Using a number such as <code>42</code>, <code>3.14</code>, or <code>True</code> as a type annotation is always a mistake (it is valid Python syntax but meaningless as a type)." - }, - { - "type": "code", - "lang": "python", - "code": "def f(x: 42) -> 0: # both parameter and return annotation are literals\n ..." - } - ], - "group": "Type System", - "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", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep" - ], - "summary": "Assignment type incompatibility (literal mismatches)", - "summaryHtml": "Assignment type incompatibility (literal mismatches)", - "body": [ - { - "type": "text", - "html": "Owns structural <code>TypedDict</code> assignment for TYPEINF-SUBTYPING-TYPEDDICT." - }, - { - "type": "text", - "html": "Detects annotated module-level variables where the declared type and the literal kind of the right-hand side are clearly incompatible, for example:" - }, - { - "type": "code", - "lang": "python", - "code": "count: int = \"hello\" # str literal assigned to int annotation \u2192 E0014\nlabel: str = 42 # int literal assigned to str annotation \u2192 E0014\nflag: bool = \"yes\" # str literal assigned to bool annotation \u2192 E0014\nratio: float = \"1.5\" # str literal assigned to float annotation \u2192 E0014" - }, - { - "type": "text", - "html": "Every right-hand side \u2014 literal, call, constructor, method, variable \u2014 is typed by the module's ModuleOracle (NARROWPLAN-INTEGRATION Step 1: <code>BidirEngine::synth</code>, with <code>synth_call</code> resolving call returns, GitHub #397/#378), collection displays are judged in the annotation's expected-type context by engine check mode, and nominal verdicts route through crate::subtyping::SubtypingContext." - } - ], - "group": "Type System", - "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", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep", - "callables" - ], - "summary": "Invalid type argument count or form", - "summaryHtml": "Invalid type argument count or form", - "body": [ - { - "type": "text", - "html": "Certain generic types accept a fixed number of type arguments. This rule catches the most common violations detectable from source text alone:" - }, - { - "type": "text", - "html": "| Annotation pattern | Expected args | Error condition | |---|---|---| | <code>list...</code> | exactly 1 | 0 or 2+ args | | <code>set...</code> | exactly 1 | 0 or 2+ args | | <code>frozenset...</code> | exactly 1 | 0 or 2+ args | | <code>type...</code> | exactly 1 | 0 or 2+ args | | <code>Type...</code> | exactly 1 | 0 or 2+ args | | <code>dict...</code> | exactly 2 | 0, 1, or 3+ args | | <code>Callable...</code> | exactly 2 | wrong count or invalid form |" - }, - { - "type": "text", - "html": "For <code>Callable</code>, the first argument must be a parameter list <code>int, str</code>, bare ellipsis <code>...</code>, a <code>ParamSpec</code>, or <code>Concatenate...</code>. The second argument (return type) must not be a list literal." - } - ], - "group": "Type System", - "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", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep", - "callables" - ], - "summary": "Unpack[`TypedDict`] kwargs violations", - "summaryHtml": "UnpackTypedDict kwargs violations", - "body": [ - { - "type": "text", - "html": "Detects invalid uses of <code>**kwargs: UnpackTypedDict</code> in function signatures: parameter overlap with <code>TypedDict</code> keys, <code>UnpackTypeVar</code> (invalid), and call-site validation for functions with Unpack kwargs." - } - ], - "group": "Type System", - "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", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep", - "callables" - ], - "summary": "Callable call-site arity and argument validation", - "summaryHtml": "Callable call-site arity and argument validation", - "body": [ - { - "type": "text", - "html": "When a parameter is annotated as <code>Callable[int, str, T]</code>, calls to that parameter must match the expected argument count. Additionally, <code>Callable</code> parameters are implicitly positional-only, so keyword arguments are not allowed." - } - ], - "group": "Type System", - "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", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep", - "callables" - ], - "summary": "Callable and Protocol assignment compatibility", - "summaryHtml": "Callable and Protocol assignment compatibility", - "body": [ - { - "type": "text", - "html": "Checks that when a function is assigned to a variable annotated with a <code>Callable</code> type or a callback <code>Protocol</code>, the signatures are compatible." - } - ], - "group": "Type System", - "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", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep", - "callables" - ], - "summary": "Callable subtyping violations (covariance / contravariance)", - "summaryHtml": "Callable subtyping violations (covariance / contravariance)", - "body": [ - { - "type": "text", - "html": "Callable types are covariant with respect to return types and contravariant with respect to parameter types. When a <code>Callable[T, R]</code>-annotated variable is assigned a value whose type is <code>Callable[S, Q]</code>, the assignment is only valid when:" - }, - { - "type": "text", - "html": "- <code>Q</code> is a subtype of <code>R</code> (return type \u2014 covariant) - <code>T</code> is a subtype of <code>S</code> (parameter type \u2014 contravariant, i.e. the source must accept everything the target accepts, which means a broader type)" - }, - { - "type": "code", - "lang": "python", - "code": "def func(\n cb1: Callable[[float], int],\n cb3: Callable[[int], int],\n) -> None:\n f6: Callable[[float], float] = cb3 # E \u2014 int param is not supertype of float\n f8: Callable[[int], int] = cb2 # E \u2014 float return is not subtype of int" - }, - { - "type": "text", - "html": "This rule specifically handles assignments inside function bodies where the RHS is a parameter whose type is already known to be a <code>Callable</code>." - } - ], - "group": "Type System", - "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", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep" - ], - "summary": "Too few arguments in a function call", - "summaryHtml": "Too few arguments in a function call", - "body": [ - { - "type": "text", - "html": "When a function is called with fewer positional arguments than it has required parameters (parameters without default values), Basilisk reports a missing-argument error. Handles overloaded functions by checking all overload signatures." - }, - { - "type": "text", - "html": "Also validates constructor calls: when a class is instantiated and the metaclass <code>__call__</code> passes through arguments (uses <code>*args, **kwargs</code>), the <code>__new__</code> or <code>__init__</code> method signature is checked for missing required arguments." - }, - { - "type": "code", - "lang": "python", - "code": "def func1(a: int, b: str) -> None: ...\n\nfunc1() # E: missing required arguments\nfunc1(1) # E: missing required argument `b`" - } - ], - "group": "Type System", - "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", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep" - ], - "summary": "Argument type mismatch at a call site", - "summaryHtml": "Argument type mismatch at a call site", - "body": [ - { - "type": "text", - "html": "Every argument is judged by the TYPE the module's bidirectional engine synthesises for it (NARROWPLAN-INTEGRATION Step 3), checked against the declared parameter type through the one shared judgment (TypeJudge) \u2014 never by the syntactic shape of the expression." - }, - { - "type": "code", - "lang": "python", - "code": "def add(x: int, y: int) -> int:\n return x + y\n\nresult: int = add(\"hello\", \"world\") # str literals for int params \u2192 E0012" - } - ], - "group": "Type System", - "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", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep", - "classes" - ], - "summary": "`ClassVar` used in an invalid context", - "summaryHtml": "<code>ClassVar</code> used in an invalid context", - "body": [ - { - "type": "text", - "html": "<a href=\"https://peps.python.org/pep-0526/\">PEP 526</a> and the typing spec restrict <code>ClassVarT</code> to:" - }, - { - "type": "text", - "html": "- Annotations of class body attributes (class variables)" - }, - { - "type": "text", - "html": "Using <code>ClassVar</code> outside a class body (in function parameters, return types, local variable annotations, or module-level variable annotations) is an error. Additionally, nesting <code>ClassVar</code> inside another type constructor (e.g. <code>FinalClassVar[int]</code> or <code>listClassVar[int]</code>) is forbidden." - }, - { - "type": "text", - "html": "Note: <code>AnnotatedClassVar[T, ...]</code> is a valid exception." - }, - { - "type": "text", - "html": "This rule also validates <code>ClassVar</code> argument correctness: - <code>ClassVar</code> accepts at most one argument - The argument must be a valid type (not a literal or runtime variable) - The argument must not contain <code>TypeVar</code>, <code>ParamSpec</code>, or <code>TypeVarTuple</code>" - }, - { - "type": "text", - "html": "Additionally, <code>ClassVar</code> attributes cannot be assigned via instances." - }, - { - "type": "code", - "lang": "python", - "code": "class MyClass:\n bad9: Final[ClassVar[int]] = 3 # E0036 \u2014 ClassVar cannot be nested\n bad10: list[ClassVar[int]] = [] # E0036 \u2014 ClassVar cannot be nested\n\n def method1(self, a: ClassVar[int]): # E0036 \u2014 ClassVar not allowed here\n x: ClassVar[str] = \"\" # E0036 \u2014 ClassVar not allowed here\n self.xx: ClassVar[str] = \"\" # E0036 \u2014 ClassVar not allowed here\n\n def method2(self) -> ClassVar[int]: # E0036 \u2014 ClassVar not allowed here\n ...\n\nbad11: ClassVar[int] = 3 # E0036 \u2014 ClassVar not allowed at module level\nbad12: TypeAlias = ClassVar[str] # E0036 \u2014 ClassVar not allowed here" - } - ], - "group": "Type System", - "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", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep", - "classes" - ], - "summary": "Incompatible method override", - "summaryHtml": "Incompatible method override", - "body": [ - { - "type": "text", - "html": "When a class method marked with <code>@override</code> has a different parameter signature or return type than the corresponding method in a same-module base class, Basilisk reports an incompatible override." - }, - { - "type": "text", - "html": "The check compares annotation text extracted from the source for non-self parameters and the return type. The <code>self</code>/<code>cls</code> parameter is always skipped since its type naturally differs between base and child class." - }, - { - "type": "code", - "lang": "python", - "code": "class Base:\n def process(self: Base, data: str) -> str: ...\n\nclass Child(Base):\n @override\n def process(self: Child, data: int) -> int: ... # E0016" - } - ], - "group": "Type System", - "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", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep", - "classes" - ], - "summary": "Incompatible class attribute override", - "summaryHtml": "Incompatible class attribute override", - "body": [ - { - "type": "text", - "html": "When a child class declares an attribute that also exists in a same-module base class but with a different type annotation, Basilisk reports an incompatible override." - }, - { - "type": "code", - "lang": "python", - "code": "class Base:\n count: int = 0\n\nclass Child(Base):\n count: str = \"zero\" # annotation changed from int to str \u2192 E0017" - } - ], - "group": "Type System", - "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", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep", - "classes" - ], - "summary": "`@override` on a method with no matching ancestor method", - "summaryHtml": "<code>@override</code> on a method with no matching ancestor method", - "body": [ - { - "type": "text", - "html": "<a href=\"https://peps.python.org/pep-0698/\">PEP 698</a> \u2014 a method decorated <code>@override</code> (or <code>typing.override</code>) 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", - "html": "To stay free of false positives the check is deliberately conservative: it only fires when the <em>entire</em> ancestor chain is resolvable within the current module (no <code>Any</code> base and no imported base whose methods we cannot see), so a method that legitimately overrides something in an unseen base is never flagged." - }, - { - "type": "code", - "lang": "python", - "code": "class Base:\n def existing(self) -> int: ...\n\nclass Child(Base):\n @override\n def missing(self) -> int: # E0159: nothing named `missing` in any base\n return 1" - } - ], - "group": "Type System", - "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", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep", - "constructors" - ], - "summary": "Constructor call errors via `__init__` method", - "summaryHtml": "Constructor call errors via <code>__init__</code> method", - "body": [ - { - "type": "text", - "html": "Detects several categories of constructor call errors when a class defines or inherits <code>__init__</code>:" - }, - { - "type": "text", - "html": "1. **Specialized generic argument mismatch** (L21): Calling <code>Classint(1.0)</code> when <code>__init__</code> expects <code>x: T</code> and <code>T=int</code>, but <code>1.0</code> is <code>float</code>." - }, - { - "type": "text", - "html": "2. **Self type incompatibility** (L42): Passing a base-class instance where <code>Self</code> in <code>__init__</code> demands a subclass instance." - }, - { - "type": "text", - "html": "3. **Explicit self annotation mismatch** (L56): <code>__init__</code> annotates <code>self</code> as <code>Class4int</code> but the constructor is called as <code>Class4str()</code>." - }, - { - "type": "text", - "html": "4. **Class-scoped <code>TypeVar</code>s in self annotation** (L107): Using class-scoped type variables in a reordered <code>self</code> annotation is invalid." - }, - { - "type": "text", - "html": "5. **No custom <code>__init__</code> with arguments** (L130): Classes inheriting only from <code>object</code> (no custom <code>__init__</code> or <code>__new__</code>) cannot accept arguments." - } - ], - "group": "Type System", - "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", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep", - "constructors" - ], - "summary": "Constructor call type mismatch with specialized generic class", - "summaryHtml": "Constructor call type mismatch with specialized generic class", - "body": [ - { - "type": "text", - "html": "When a generic class is called with explicit type arguments (e.g. <code>Class1int(1.0)</code>), Basilisk substitutes the type parameters into the <code>__new__</code> method signature and checks that the provided arguments are compatible." - }, - { - "type": "text", - "html": "This rule covers two cases:" - }, - { - "type": "text", - "html": "1. **Argument type mismatch after substitution**: The <code>__new__</code> method has a parameter typed with a type variable (e.g. <code>x: T</code>), and after substituting the type argument (e.g. <code>T=int</code>), the provided argument is incompatible (e.g. <code>1.0</code> is <code>float</code>, not <code>int</code>)." - }, - { - "type": "text", - "html": "2. **Explicit <code>cls</code> parameter type mismatch**: The <code>__new__</code> method has an explicitly typed <code>cls</code> parameter (e.g. <code>cls: typeClass11[int]</code>), and the class is called with different type arguments (e.g. <code>Class11str()</code>)." - }, - { - "type": "code", - "lang": "python", - "code": "class Class1(Generic[T]):\n def __new__(cls, x: T) -> Self:\n return super().__new__(cls)\n\nClass1[int](1.0) # E: float is not compatible with int" - } - ], - "group": "Type System", - "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", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep", - "constructors" - ], - "summary": "Invalid constructor call via `type[T]` parameter", - "summaryHtml": "Invalid constructor call via <code>typeT</code> parameter", - "body": [ - { - "type": "text", - "html": "When a parameter is typed as <code>typeT</code> (where <code>T</code> is a concrete class or a type variable), calling it as a constructor is equivalent to calling <code>T(...)</code>. This rule checks that the arguments passed to such calls are consistent with the constructor of <code>T</code>." - }, - { - "type": "text", - "html": "Specification: <https://typing.readthedocs.io/en/latest/spec/constructors.html#constructor-calls-for-type-t>" - }, - { - "type": "text", - "html": "## Cases detected" - }, - { - "type": "text", - "html": "1. <code>cls: typeClass</code> where <code>Class.__init__</code> / <code>Class.__new__</code> / metaclass <code>__call__</code> requires arguments but <code>cls()</code> is called with none. 2. <code>cls: typeClass</code> where <code>Class</code> has no custom constructor but <code>cls(arg)</code> is called with extra arguments. 3. <code>cls: typeT</code> (unbound <code>TypeVar</code>) called with any arguments \u2014 the constraint is unknown, so no arguments are permitted. 4. <code>cls: typeT</code> where <code>T</code> is bounded: same rules as the bound class." - } - ], - "group": "Type System", - "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", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep", - "constructors" - ], - "summary": "Invalid call to a constructor-derived callable", - "summaryHtml": "Invalid call to a constructor-derived callable", - "body": [ - { - "type": "text", - "html": "(<https://typing.readthedocs.io/en/latest/spec/constructors.html#converting-a-constructor-to-callable>)." - }, - { - "type": "text", - "html": "When a class object flows through an identity-over-callable function such as" - }, - { - "type": "code", - "lang": "python", - "code": "def accepts_callable(cb: Callable[P, R]) -> Callable[P, R]:\n return cb\n\nr1 = accepts_callable(Class1) # r1 has Class1's constructor signature" - }, - { - "type": "text", - "html": "the bound variable (<code>r1</code>) gains the <em>constructor-to-callable</em> signature of the class. Calls to that variable must match the synthesized signature:" - }, - { - "type": "code", - "lang": "python", - "code": "r1() # E0153: missing required argument `x`\nr1(y=1) # E0153: unexpected keyword argument `y`" - }, - { - "type": "text", - "html": "The synthesized signature is derived (in priority order) from the metaclass <code>__call__</code>, then <code>__new__</code> (when it returns a type other than the class / <code>Self</code>), then <code>__init__</code>, mirroring runtime construction." - } - ], - "group": "Type System", - "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", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep", - "dataclasses" - ], - "summary": "Assignment to attribute of a frozen dataclass instance, or invalid frozen/non-frozen dataclass inheritance", - "summaryHtml": "Assignment to attribute of a frozen dataclass instance, or invalid frozen/non-frozen dataclass inheritance", - "body": [ - { - "type": "text", - "html": "<code>@dataclass(frozen=True)</code> instances are immutable \u2014 their attributes cannot be reassigned after construction. Additionally, a frozen dataclass cannot inherit from a non-frozen one, and vice versa." - }, - { - "type": "code", - "lang": "python", - "code": "@dataclass(frozen=True)\nclass Point:\n x: float\n\np = Point(1.0)\np.x = 2.0 # E: dataclass is frozen\n\n@dataclass # E: non-frozen cannot inherit from frozen\nclass Sub(Point):\n pass" - } - ], - "group": "Type System", - "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", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep", - "dataclasses" - ], - "summary": "Non-hashable dataclass assigned to a `Hashable`-annotated variable", - "summaryHtml": "Non-hashable dataclass assigned to a <code>Hashable</code>-annotated variable", - "body": [ - { - "type": "text", - "html": "A <code>@dataclass</code> with <code>eq=True</code> (the default) sets <code>__hash__</code> to <code>None</code> unless the class is <code>frozen=True</code>, uses <code>unsafe_hash=True</code>, or explicitly defines a <code>__hash__</code> method. Assigning such an instance to a variable annotated <code>Hashable</code> is a type error." - }, - { - "type": "code", - "lang": "python", - "code": "from dataclasses import dataclass\nfrom typing import Hashable\n\n@dataclass\nclass DC1:\n a: int\n\nv: Hashable = DC1(0) # E \u2014 DC1.__hash__ is None\n\n@dataclass(eq=True, frozen=True)\nclass DC2:\n a: int\n\nv2: Hashable = DC2(0) # OK \u2014 frozen dataclasses are hashable" - }, - { - "type": "text", - "html": "<a href=\"https://peps.python.org/pep-0557/\">PEP 557</a> specifies the <code>__hash__</code> synthesis rules: - If <code>eq</code> is true and <code>frozen</code> is false, <code>__hash__</code> is set to <code>None</code>. - If <code>eq</code> is true and <code>frozen</code> is true, Python synthesises a <code>__hash__</code>. - If <code>unsafe_hash</code> is true, Python synthesises a <code>__hash__</code> regardless. - If <code>eq</code> is false, <code>__hash__</code> is left untouched (inherited from parent). - If the class defines <code>__hash__</code> explicitly, that definition is used." - } - ], - "group": "Type System", - "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", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep", - "dataclasses" - ], - "summary": "Dataclass field without a default after a field with a default", - "summaryHtml": "Dataclass field without a default after a field with a default", - "body": [ - { - "type": "text", - "html": "A dataclass synthesizes an <code>__init__</code> whose parameters follow field declaration order. A field <em>without</em> a default that follows a field <em>with</em> a default would produce a non-default argument after a default one \u2014 a <code>TypeError</code> at class-definition time. <code>field(default=...)</code> and <code>InitVar</code> fields with a value both count as "has a default"; <code>ClassVar</code>, <code>kw_only</code>, and <code>field(init=False)</code> fields are excluded because they do not become positional <code>__init__</code> parameters." - }, - { - "type": "code", - "lang": "python", - "code": "@dataclass\nclass C:\n a: int = 0\n b: int # E0157: no-default field after a defaulted one" - } - ], - "group": "Type System", - "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", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep", - "dataclasses" - ], - "summary": "Dataclass constructor argument violations", - "summaryHtml": "Dataclass constructor argument violations", - "body": [ - { - "type": "text", - "html": "Reports errors when: - A positional argument is passed to a keyword-only dataclass field - A keyword argument targets a field with <code>init=False</code> (not part of <code>__init__</code>)" - }, - { - "type": "code", - "lang": "python", - "code": "from dataclasses import dataclass, KW_ONLY\n\n@dataclass\nclass Point:\n x: float\n _: KW_ONLY\n y: float = 0.0\n\nPoint(1.0) # OK \u2014 x positional, y uses default\nPoint(1.0, 2.0) # E \u2014 y is keyword-only, cannot be passed positionally" - } - ], - "group": "Type System", - "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", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep", - "dataclasses" - ], - "summary": "Access to `__match_args__` on a dataclass with `match_args=False`", - "summaryHtml": "Access to <code>__match_args__</code> on a dataclass with <code>match_args=False</code>", - "body": [ - { - "type": "text", - "html": "When <code>@dataclass(match_args=False)</code> is specified, Python does **not** generate the <code>__match_args__</code> class variable. Accessing <code>ClassName.__match_args__</code> on such a class is an <code>AttributeError</code> at runtime and a static type error." - }, - { - "type": "code", - "lang": "python", - "code": "from dataclasses import dataclass\n\n@dataclass(match_args=False)\nclass DC4:\n x: int\n\nDC4.__match_args__ # E: attribute not generated" - } - ], - "group": "Type System", - "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", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep", - "dataclasses" - ], - "summary": "Invalid ordering comparison of dataclass instances", - "summaryHtml": "Invalid ordering comparison of dataclass instances", - "body": [ - { - "type": "text", - "html": "When <code>@dataclass(order=True)</code>, Python synthesizes <code>__lt__</code>, <code>__le__</code>, <code>__gt__</code>, and <code>__ge__</code> methods. These methods raise <code>TypeError</code> at runtime if the other operand is not an instance of the **same** class. Comparing two <code>order=True</code> dataclass instances of different types with <code><</code>, <code><=</code>, <code>></code>, or <code>>=</code> is therefore a type error." - }, - { - "type": "text", - "html": "Additionally, when a class does NOT have <code>order=True</code> (including <code>dataclass_transform</code> classes with <code>order=False</code>), ordering comparisons are not supported at all because <code>__lt__</code> etc. are never synthesized." - }, - { - "type": "code", - "lang": "python", - "code": "from dataclasses import dataclass\n\n@dataclass(order=True)\nclass DC1:\n a: str\n\n@dataclass(order=True)\nclass DC2:\n a: str\n\ndc1 = DC1(\"x\")\ndc2 = DC2(\"y\")\n\nif dc1 < dc2: # E: incompatible types\n pass" - } - ], - "group": "Type System", - "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", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep", - "dataclasses" - ], - "summary": "`InitVar` field validation in dataclasses", - "summaryHtml": "<code>InitVar</code> field validation in dataclasses", - "body": [ - { - "type": "text", - "html": "Detects two categories of <code>InitVar</code> violations:" - }, - { - "type": "text", - "html": "1. **<code>__post_init__</code> signature mismatch**: A dataclass with <code>InitVar</code> fields must declare a <code>__post_init__</code> method whose parameters (after <code>self</code>) match the <code>InitVar</code> fields in count and type." - }, - { - "type": "text", - "html": "2. **Access to <code>InitVar</code> fields as instance attributes**: <code>InitVarT</code> fields are constructor-only parameters passed to <code>__post_init__</code>; they are not stored as instance attributes and cannot be accessed as <code>instance.field</code>." - }, - { - "type": "code", - "lang": "python", - "code": "from dataclasses import InitVar, dataclass\n\n@dataclass\nclass DC1:\n x: InitVar[int]\n y: InitVar[str]\n\n def __post_init__(self, x: int, y: int) -> None: # E: y should be str\n pass\n\ndc1 = DC1(1, \"\")\ndc1.x # E: cannot access InitVar field as attribute" - } - ], - "group": "Type System", - "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", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep", - "dataclasses" - ], - "summary": "Dataclass slots violations", - "summaryHtml": "Dataclass slots violations", - "body": [ - { - "type": "text", - "html": "Reports errors when: - <code>self.attr = value</code> assigns to an attribute not in <code>__slots__</code> inside a class with <code>@dataclass(slots=True)</code> or a manual <code>__slots__</code> definition. - <code>ClassName.__slots__</code> or <code>ClassName().__slots__</code> is accessed on a dataclass that does not define <code>__slots__</code> (neither via <code>slots=True</code> nor a manual <code>__slots__</code> assignment)." - }, - { - "type": "code", - "lang": "python", - "code": "@dataclass(slots=True)\nclass DC:\n x: int\n def __init__(self):\n self.y = 3 # E: \"y\" is not in __slots__\n\n@dataclass\nclass DC2:\n a: int\nDC2.__slots__ # E: __slots__ not defined" - } - ], - "group": "Type System", - "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", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep", - "dataclasses" - ], - "summary": "`dataclass_transform` violations when the transform is applied via a base class", - "summaryHtml": "<code>dataclass_transform</code> violations when the transform is applied via a base class", - "body": [ - { - "type": "text", - "html": "When a class is decorated with <code>@dataclass_transform(...)</code>, subclasses that inherit from it behave like dataclasses with the transform's default settings overridable by keyword arguments on the class definition." - }, - { - "type": "text", - "html": "This rule detects: 1. A non-frozen subclass inheriting from a frozen transform-class (line 51). 2. Attribute assignment on a frozen transform-class instance (lines 63, 122). 3. Positional arguments to a <code>kw_only</code> transform-class constructor (lines 66, 82). 4. Comparison operators on transform-class instances that lack <code>order=True</code> (line 72)." - }, - { - "type": "code", - "lang": "python", - "code": "from typing import dataclass_transform\n\n@dataclass_transform(kw_only_default=True)\nclass ModelBase: ...\n\nclass Customer(ModelBase, frozen=True):\n id: int\n\nc = Customer(3) # E \u2014 kw_only requires keyword args\nc.id = 4 # E \u2014 frozen instance is immutable" - } - ], - "group": "Type System", - "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", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep", - "dataclasses" - ], - "summary": "`dataclass_transform` metaclass violations", - "summaryHtml": "<code>dataclass_transform</code> metaclass violations", - "body": [ - { - "type": "text", - "html": "Detects type errors in classes whose metaclass is decorated with <code>@dataclass_transform(...)</code>. Four violation kinds are covered:" - }, - { - "type": "text", - "html": "1. **Frozen inheritance**: a non-frozen subclass inheriting from a frozen one. 2. **Frozen attribute assignment**: mutating an attribute of a frozen instance. 3. **Positional argument to kw-only constructor**: all fields are keyword-only when <code>kw_only_default=True</code> on the transform. 4. **Ordering comparison without <code>order</code>**: using <code><</code>/<code><=</code>/<code>></code>/<code>>=</code> on instances of a class that did not opt in to <code>order=True</code>." - }, - { - "type": "code", - "lang": "python", - "code": "from typing import dataclass_transform\n\n@dataclass_transform(kw_only_default=True)\nclass ModelMeta(type): ...\n\nclass ModelBase(metaclass=ModelMeta): ...\n\nclass Customer(ModelBase, frozen=True):\n id: int\n\nc = Customer(id=1)\nc.id = 2 # E \u2014 frozen\nv = c < c # E \u2014 no ordering methods" - } - ], - "group": "Type System", - "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", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep", - "dataclasses" - ], - "summary": "Type mismatch between a dataclass `field(default_factory=\u2026)` and the field's declared type annotation", - "summaryHtml": "Type mismatch between a dataclass <code>field(default_factory=\u2026)</code> and the field's declared type annotation", - "body": [ - { - "type": "text", - "html": "When a dataclass field uses <code>field(default_factory=T)</code> where <code>T</code> is a known callable that constructs instances of a simple built-in type, but the field's annotation declares a different incompatible built-in type, Basilisk reports an error." - }, - { - "type": "code", - "lang": "python", - "code": "from dataclasses import dataclass, field\n\n@dataclass\nclass DC:\n a: int = field(default_factory=str) # E: str() \u2192 str, not int" - } - ], - "group": "Type System", - "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", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep" - ], - "summary": "Unhashable type used as a dict key", - "summaryHtml": "Unhashable type used as a dict key", - "body": [ - { - "type": "text", - "html": "Lists, sets, and plain dicts are not hashable and cannot be used as dictionary keys at runtime. Basilisk detects these statically." - }, - { - "type": "code", - "lang": "python", - "code": "def bad_key() -> None:\n mapping = {[1, 2]: \"value\"} # list as key \u2192 E0022" - } - ], - "group": "Type System", - "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", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep", - "directives" - ], - "summary": "Invalid `assert_type()` call", - "summaryHtml": "Invalid <code>assert_type()</code> call", - "body": [ - { - "type": "text", - "html": "<code>assert_type(expr, Type)</code> must be called with exactly 2 positional arguments." - }, - { - "type": "text", - "html": "- <code>assert_type()</code> \u2014 too few arguments (0 given) - <code>assert_type(x)</code> \u2014 too few arguments (1 given) - <code>assert_type(x, int, extra)</code> \u2014 too many arguments (3 given)" - } - ], - "group": "Type System", - "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", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep", - "directives" - ], - "summary": "`assert_type()` type mismatch", - "summaryHtml": "<code>assert_type()</code> type mismatch", - "body": [ - { - "type": "text", - "html": "<code>assert_type(expr, Type)</code> is a static-analysis directive that verifies the inferred type of <code>expr</code> equals <code>Type</code>. Two judgments feed it (NARROWPLAN-INTEGRATION Step 5):" - }, - { - "type": "text", - "html": "- the resolver's flow-narrowed comparison of declared parameter types (<code>type_mismatch</code> on basilisk_resolver::AssertTypeCallInfo), and - the module's span-indexed oracle \u2014 the SAME engine behind hover \u2014 for expressions the resolver cannot type (call results, attributes). The oracle verdict fires only when both sides are fully known and provably DISJOINT (neither assignable to the other), so spelling variance and literal widening can never manufacture a false positive (CHKARCH-CONFORMANCE-MODE)." - }, - { - "type": "code", - "lang": "python", - "code": "from typing import assert_type\n\ndef f(a: int | str) -> None:\n assert_type(a, int) # E \u2014 int | str is not int" - } - ], - "group": "Type System", - "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", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep", - "directives" - ], - "summary": "Invalid `cast()` call", - "summaryHtml": "Invalid <code>cast()</code> call", - "body": [ - { - "type": "text", - "html": "<code>typing.cast(typ, val)</code> must be called with exactly two positional arguments, and the first argument must be a type expression, not a value literal. A <em>quoted</em> first argument (<code>cast("Widget", x)</code>) is NOT a value literal \u2014 it is the standard <a href=\"https://peps.python.org/pep-0484/\">PEP 484</a> forward-reference spelling, which typeshed admits directly (<code>cast(typ: type_T | str | Any, val)</code>) and which ruff's <code>TC006</code> actively requires \u2014 so only genuine non-string value literals are rejected (issue #335)." - }, - { - "type": "text", - "html": "A <code>cast()</code> is invalid wherever it appears, so every expression position is checked \u2014 <code>return cast(1, x)</code> and <code>print(cast(1, x))</code> are as wrong as <code>y = cast(1, x)</code> (issue #335)." - }, - { - "type": "text", - "html": "- <code>cast()</code> \u2014 too few arguments - <code>cast(1, x)</code> \u2014 first argument is a value literal, not a type - <code>cast("Widget", x)</code> \u2014 OK: string forward reference - <code>cast(int, x, y)</code> \u2014 too many arguments" - } - ], - "group": "Type System", - "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", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep", - "directives" - ], - "summary": "Use of deprecated class, function, or method", - "summaryHtml": "Use of deprecated class, function, or method", - "body": [ - { - "type": "text", - "html": "<a href=\"https://peps.python.org/pep-0702/\">PEP 702</a> introduces <code>@deprecated</code> from <code>typing</code> / <code>typing_extensions</code>. Using a deprecated entity (calling, importing, accessing) should produce a diagnostic so that developers migrate away from the deprecated API." - }, - { - "type": "code", - "lang": "python", - "code": "from typing_extensions import deprecated\n\n@deprecated(\"Use new_func instead\")\ndef old_func() -> None: ...\n\nold_func() # directives_deprecated" - } - ], - "group": "Type System", - "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", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep", - "directives" - ], - "summary": "PEP 800 disjoint bases", - "summaryHtml": "<a href=\"https://peps.python.org/pep-0800/\">PEP 800</a> disjoint bases", - "body": [ - { - "type": "text", - "html": "<a href=\"https://peps.python.org/pep-0800/\">PEP 800</a> introduces <code>typing.disjoint_base</code>. A class is a <em>disjoint base</em> when it is decorated <code>@disjoint_base</code> or defines a non-empty <code>__slots__</code>. A class definition must have a single <em>dominating</em> disjoint base among its bases:" - }, - { - "type": "code", - "lang": "python", - "code": "@disjoint_base\nclass Left: ...\n@disjoint_base\nclass Right: ...\n\nclass Both(Left, Right): ... # error \u2014 incompatible disjoint bases" - }, - { - "type": "text", - "html": "The decorator may be used only on nominal classes (including <code>NamedTuple</code>); it is an error to apply it to a function, a <code>TypedDict</code>, or a <code>Protocol</code>." - } - ], - "group": "Type System", - "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", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep", - "directives" - ], - "summary": "Invalid `reveal_type()` call", - "summaryHtml": "Invalid <code>reveal_type()</code> call", - "body": [ - { - "type": "text", - "html": "<code>reveal_type(expr)</code> must be called with exactly one positional argument." - }, - { - "type": "text", - "html": "- <code>reveal_type()</code> \u2014 too few arguments (0 given) - <code>reveal_type(a, b)</code> \u2014 too many arguments (2 given)" - } - ], - "group": "Type System", - "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", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep", - "directives" - ], - "summary": "Variable defined only in dead version/platform branch", - "summaryHtml": "Variable defined only in dead version/platform branch", - "body": [ - { - "type": "text", - "html": "When <code>sys.version_info</code>, <code>sys.platform</code>, or <code>os.name</code> is compared against a constant, one branch may be statically known to be dead for the <em>configured</em> target Python version (CHKARCH-VERSION-TARGET, issue #93) and platform. Variables defined exclusively in a dead branch are undefined outside that branch." - }, - { - "type": "code", - "lang": "python", - "code": "import sys\n\nif sys.version_info < (3, 8):\n val = \"\" # dead on Python 3.12\nelse:\n other = \"\"\n\nprint(val) # E: `val` is only defined in a dead branch\nprint(other) # OK" - } - ], - "group": "Type System", - "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", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep", - "enums" - ], - "summary": "Invalid Enum subclassing", - "summaryHtml": "Invalid Enum subclassing", - "body": [ - { - "type": "text", - "html": "An Enum class with one or more defined members is implicitly final and cannot be subclassed. Only Enum subclasses with no members can be used as bases for other Enum classes." - }, - { - "type": "code", - "lang": "python", - "code": "class Color(Enum):\n RED = 1\n GREEN = 2\n\nclass ExtendedColor(Color): # E \u2014 Color has members and is implicitly final\n BLUE = 3" - } - ], - "group": "Type System", - "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", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep", - "enums" - ], - "summary": "access to an enum member that does not exist for the target", - "summaryHtml": "access to an enum member that does not exist for the target", - "body": [ - { - "type": "text", - "html": "Enum members may be defined conditionally on a statically-known check such as the Python version:" - }, - { - "type": "code", - "lang": "python", - "code": "class Color(Enum):\n RED = 1\n if sys.version_info >= (4, 0):\n BLUE = 3 # absent when checking for 3.12\n\nColor.BLUE # error \u2014 BLUE does not exist at the target version" - }, - { - "type": "text", - "html": "This rule flags access to a member that was defined only under an <code>if</code>-guard that is statically false at the configured target. It is intentionally narrow \u2014 it never touches unconditional members, inherited attributes, or functional <code>Enum(...)</code> calls \u2014 so it cannot fire on valid code." - } - ], - "group": "Type System", - "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", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep", - "enums" - ], - "summary": "`assert_type` with `Literal[Enum.MEMBER]` on enum-typed param", - "summaryHtml": "<code>assert_type</code> with <code>LiteralEnum.MEMBER</code> on enum-typed param", - "body": [ - { - "type": "text", - "html": "This rule detects when <code>assert_type()</code> is used with a <code>LiteralEnum.MEMBER</code> type on a parameter that is already typed as the enum itself. This is redundant and indicates a misunderstanding of enum typing semantics." - }, - { - "type": "code", - "lang": "python", - "code": "from enum import Enum\nfrom typing import assert_type, Literal\n\nclass Status(Enum):\n ACTIVE = 1\n INACTIVE = 2\n\ndef process(status: Status) -> None:\n assert_type(status, Literal[Status.ACTIVE]) # E0061 \u2014 redundant narrowing\n assert_type(status, Status) # OK \u2014 correct usage" - } - ], - "group": "Type System", - "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", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep", - "enums" - ], - "summary": "Enum member value incompatible with `_value_` type annotation", - "summaryHtml": "Enum member value incompatible with <code>_value_</code> type annotation", - "body": [ - { - "type": "text", - "html": "When an enum class declares <code>_value_: T</code> (annotation-only, no value), all member values assigned in the class body must be compatible with <code>T</code>. Additionally, if <code>self._value_ = param</code> appears in <code>__init__</code>, the parameter's type annotation must be compatible with the declared <code>_value_: T</code>." - }, - { - "type": "code", - "lang": "python", - "code": "from enum import Enum\n\nclass Color(Enum):\n _value_: int\n RED = 1 # OK \u2014 int matches int\n GREEN = \"green\" # E \u2014 str is not compatible with int\n\nclass Planet(Enum):\n _value_: str\n\n def __init__(self, value: int, mass: float, radius: float):\n self._value_ = value # E \u2014 int is not compatible with str" - } - ], - "group": "Type System", - "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", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep", - "enums" - ], - "summary": "Enum member annotated with an explicit type", - "summaryHtml": "Enum member annotated with an explicit type", - "body": [ - { - "type": "text", - "html": "In an Enum class, members should NOT carry explicit type annotations. If an attribute inside an Enum class body has both a type annotation and an assigned value, it is treated as an annotated member \u2014 which is an error because the type checker infers a <code>LiteralEnumClass.member</code> type for all members automatically." - }, - { - "type": "text", - "html": "A type annotation without an assigned value (e.g. <code>genus: str</code>) is a **non-member attribute** and is valid." - }, - { - "type": "code", - "lang": "python", - "code": "from enum import Enum\n\nclass Pet(Enum):\n genus: str # OK \u2014 non-member attribute (annotation only, no value)\n CAT = \"felis\" # OK \u2014 member without annotation\n DOG: int = 2 # E \u2014 member with explicit type annotation" - } - ], - "group": "Type System", - "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", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep", - "enums" - ], - "summary": "Non-member referenced in `Literal[EnumClass.X]` annotation", - "summaryHtml": "Non-member referenced in <code>LiteralEnumClass.X</code> annotation", - "body": [ - { - "type": "text", - "html": "The <code>LiteralEnumClass.X</code> type is only valid when <code>X</code> is an actual enum member. Using it with a non-member (a method, property, lambda, nested class, private attribute, or <code>nonmember()</code>-wrapped attribute) is a type error." - }, - { - "type": "code", - "lang": "python", - "code": "from enum import Enum, nonmember\nfrom typing import Literal\n\nclass Pet4(Enum):\n CAT = 1\n converter = lambda x: str(x) # Non-member (lambda)\n\n def speak(self) -> None: ... # Non-member (method)\n\nconverter: Literal[Pet4.converter] # E \u2014 converter is not an enum member\nspeak: Literal[Pet4.speak] # E \u2014 speak is not an enum member" - } - ], - "group": "Type System", - "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", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep", - "generics" - ], - "summary": "Duplicate `TypeVar` in a `Generic[...]` base", - "summaryHtml": "Duplicate <code>TypeVar</code> in a <code>Generic...</code> base", - "body": [ - { - "type": "text", - "html": "Each type parameter in <code>GenericT1, T2, ...</code> must be unique. <code>GenericT, T</code> is an error per <a href=\"https://peps.python.org/pep-0484/\">PEP 484</a>." - } - ], - "group": "Type System", - "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", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep", - "generics" - ], - "summary": "Inconsistent `TypeVar` ordering across base classes", - "summaryHtml": "Inconsistent <code>TypeVar</code> ordering across base classes", - "body": [ - { - "type": "text", - "html": "When a class inherits from multiple generic bases that share a common generic ancestor, the <code>TypeVar</code> argument orderings must be consistent." - }, - { - "type": "code", - "lang": "python", - "code": "class Grandparent(Generic[T1, T2]): ...\nclass Parent(Grandparent[T1, T2]): ...\nclass BadChild(Parent[T1, T2], Grandparent[T2, T1]): ... # E" - }, - { - "type": "text", - "html": "<code>BadChild</code> inherits <code>Grandparent</code> twice \u2014 once via <code>ParentT1, T2</code> (which maps to <code>GrandparentT1, T2</code>) and once directly as <code>GrandparentT2, T1</code>. The orderings conflict." - } - ], - "group": "Type System", - "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", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep", - "generics" - ], - "summary": "Invariant generic type mismatch at call site", - "summaryHtml": "Invariant generic type mismatch at call site", - "body": [ - { - "type": "text", - "html": "When a function parameter expects a parameterised generic like <code>dictstr, list[object]</code> and a subclass whose base parameterisation differs in an invariant position is passed, the call is invalid." - }, - { - "type": "code", - "lang": "python", - "code": "class SymbolTable(dict[str, list[Node]]): ...\n\ndef takes(x: dict[str, list[object]]): ...\n\ndef test(s: SymbolTable):\n takes(s) # E -- list is invariant, list[Node] != list[object]" - } - ], - "group": "Type System", - "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", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep", - "generics" - ], - "summary": "`TypeVar` declared with exactly one constraint", - "summaryHtml": "<code>TypeVar</code> declared with exactly one constraint", - "body": [ - { - "type": "text", - "html": "<a href=\"https://peps.python.org/pep-0484/\">PEP 484</a> requires a <code>TypeVar</code> 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", - "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", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep", - "generics" - ], - "summary": "Non-TypeVar argument in `Generic[...]` or `Protocol[...]`", - "summaryHtml": "Non-TypeVar argument in <code>Generic...</code> or <code>Protocol...</code>", - "body": [ - { - "type": "text", - "html": "<a href=\"https://peps.python.org/pep-0484/\">PEP 484</a> requires that all arguments to <code>Generic...</code> and <code>Protocol...</code> be type variable names (<code>TypeVar</code>, <code>TypeVarTuple</code>, or <code>ParamSpec</code>). Passing a concrete type (e.g. <code>Genericint</code>) is a type error." - }, - { - "type": "code", - "lang": "python", - "code": "class Bad1(Generic[int]): ... # E \u2014 `int` is not a TypeVar\nclass Bad2(Protocol[int]): ... # E \u2014 `int` is not a TypeVar" - } - ], - "group": "Type System", - "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", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep", - "generics" - ], - "summary": "Generic type argument violations", - "summaryHtml": "Generic type argument violations", - "body": [ - { - "type": "text", - "html": "paths in TYPEINF-GENERICS, TYPEINF-GENERICS-TYPEVAR, and TYPEINF-GENERICS-CONSTRAINED." - }, - { - "type": "text", - "html": "Detects several generic-type errors:" - }, - { - "type": "text", - "html": "1. **Constrained <code>TypeVar</code> constraint mismatch**: When a function parameter is typed with a constrained <code>TypeVar</code> (e.g. <code>AnyStr = TypeVar("AnyStr", str, bytes)</code>), all arguments bound to the same type variable must belong to the same constraint. Passing <code>(str_val, bytes_val)</code> for <code>(x: AnyStr, y: AnyStr)</code> is an error." - }, - { - "type": "text", - "html": "2. **Mapping subscript key type mismatch**: When a <code>Mapping</code>-derived type has a known key type (e.g. <code>MyMapstr, int</code>), indexing with a literal of the wrong type (e.g. <code>my_map0</code>) is an error." - }, - { - "type": "text", - "html": "3. **Generic metaclass usage**: Using a parameterized generic class as a metaclass (<code>metaclass=SomeGenericT</code>) is not supported by the Python type system." - } - ], - "group": "Type System", - "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", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep", - "generics" - ], - "summary": "Non-default `TypeVar` follows a default `TypeVar` in `Generic[...]`", - "summaryHtml": "Non-default <code>TypeVar</code> follows a default <code>TypeVar</code> in <code>Generic...</code>", - "body": [ - { - "type": "text", - "html": "<a href=\"https://peps.python.org/pep-0696/\">PEP 696</a> \u00a7Ordering defines two ordering rules for type parameters in <code>Generic...</code>:" - }, - { - "type": "text", - "html": "1. Once a <code>TypeVar</code> with a <code>default=</code> argument appears, all subsequent type variables must also have defaults." - }, - { - "type": "text", - "html": "2. A <code>TypeVar</code> with a <code>default=</code> cannot immediately follow a <code>TypeVarTuple</code> in <code>Generic...</code> because it would be ambiguous whether a type argument should be bound to the <code>TypeVarTuple</code> or the defaulted <code>TypeVar</code>. (<code>ParamSpec</code> with a default is allowed after a <code>TypeVarTuple</code>.)" - } - ], - "group": "Type System", - "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", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep", - "generics" - ], - "summary": "Incompatible `TypeVar` bound or constraint with its default", - "summaryHtml": "Incompatible <code>TypeVar</code> bound or constraint with its default", - "body": [ - { - "type": "text", - "html": "<a href=\"https://peps.python.org/pep-0696/\">PEP 696</a> specifies two constraints on <code>TypeVar</code> defaults:" - }, - { - "type": "text", - "html": "1. If both <code>bound</code> and <code>default</code> are specified, the default must be a subtype of the bound. The numeric subtype hierarchy is <code>bool <: int <: float <: complex</code>." - }, - { - "type": "text", - "html": "2. For constrained <code>TypeVar</code>s, the default must be one of the constraints exactly. (Even a subtype is disallowed \u2014 <code>float</code> is a subtype of <code>complex</code> but if the constraints are <code>float, str</code> and the default is <code>complex</code>, that is an error.)" - }, - { - "type": "code", - "lang": "python", - "code": "from typing import TypeVar\n\nOk1 = TypeVar(\"Ok1\", bound=float, default=int) # OK \u2014 int <: float\nInvalid1 = TypeVar(\"Invalid1\", bound=str, default=int) # E \u2014 int is not <: str\n\nOk2 = TypeVar(\"Ok2\", float, str, default=float) # OK\nInvalid2 = TypeVar(\"Invalid2\", float, str, default=int) # E \u2014 int not in {float, str}" - } - ], - "group": "Type System", - "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", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep", - "generics" - ], - "summary": "Invalid `TypeVar` default referencing another `TypeVar`", - "summaryHtml": "Invalid <code>TypeVar</code> default referencing another <code>TypeVar</code>", - "body": [ - { - "type": "text", - "html": "<a href=\"https://peps.python.org/pep-0696/\">PEP 696</a> specifies constraints on <code>TypeVar</code> defaults that reference other <code>TypeVars</code>:" - }, - { - "type": "text", - "html": "1. **Ordering**: When <code>TypeVar</code> T2 has default=T1, T1 must appear before T2 in generic parameter list 2. **Outer scope references**: <code>TypeVar</code> cannot use a <code>TypeVar</code> from outer scope as default 3. **Bound compatibility**: When T2 has default=T1, T1's bound must be a subtype of T2's bound 4. **Constraint superset**: When T2 has default=T1 and T2 has constraints, T1's constraints must be a subset of T2's constraints" - }, - { - "type": "code", - "lang": "python", - "code": "from typing import TypeVar\n\n# Ordering violation\nT2 = TypeVar(\"T2\", default=T1) # E \u2014 T1 not defined yet\nT1 = TypeVar(\"T1\")\n\n# Outer scope violation\nclass Outer:\n T1 = TypeVar(\"T1\")\n class Inner:\n T2 = TypeVar(\"T2\", default=T1) # E \u2014 T1 from outer scope\n\n# Bound compatibility violation\nX1 = TypeVar(\"X1\", bound=int)\nInvalid1 = TypeVar(\"Invalid1\", default=X1, bound=str) # E \u2014 int is not a subtype of str\n\n# Constraint superset violation\nY1 = TypeVar(\"Y1\", int, str)\nInvalid2 = TypeVar(\"Invalid2\", bool, complex, default=Y1) # E \u2014 {bool, complex} is not a superset of {int, str}" - } - ], - "group": "Type System", - "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", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep", - "generics" - ], - "summary": "```TypeVar``` default referential violations", - "summaryHtml": "``<code>TypeVar</code>`` default referential violations", - "body": [ - { - "type": "text", - "html": "<a href=\"https://peps.python.org/pep-0696/\">PEP 696</a> defines rules for when a <code>TypeVar</code> default references another <code>TypeVar</code>:" - }, - { - "type": "text", - "html": "1. **Ordering**: The referenced <code>TypeVar</code> must appear <em>before</em> the referencing <code>TypeVar</code> in <code>Generic...</code>. 2. **Scope**: A <code>TypeVar</code> default must not reference <code>TypeVar</code>ar from an outer class scope. 3. **Bound/constraint compatibility**: When <code>TypeVar</code> <code>T2</code> defaults to <code>TypeVar</code> <code>T1</code>, <code>T1</code>'s bound must be a subtype of <code>T2</code>'s bound, and <code>T2</code>'s constraints (if any) must be a superset of <code>T1</code>'s constraints." - }, - { - "type": "code", - "lang": "python", - "code": "from typing import TypeVar, Generic\n\nS1 = TypeVar(\"S1\")\nS2 = TypeVar(\"S2\", default=S1)\n\nStart2T = TypeVar(\"Start2T\", default=\"StopT\")\nStop2T = TypeVar(\"Stop2T\", default=int)\nclass slice2(Generic[Start2T, Stop2T]): ... # E: bad ordering\n\nclass Foo3(Generic[S1]):\n class Bar2(Generic[S2]): ... # E: outer scope\n\nY1 = TypeVar(\"Y1\", bound=int)\nInvalid2 = TypeVar(\"Invalid2\", float, str, default=Y1) # E" - } - ], - "group": "Type System", - "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", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep", - "generics" - ], - "summary": "Wrong number of type arguments to a generic class or type alias", - "summaryHtml": "Wrong number of type arguments to a generic class or type alias", - "body": [ - { - "type": "text", - "html": "When a user-defined generic class has both required (non-default) and optional (defaulted) type parameters, the minimum number of type arguments that must be supplied when subscripting the class is the count of required parameters." - }, - { - "type": "text", - "html": "Also detects when too many type arguments are supplied to a user-defined generic class (one that has no <code>TypeVarTuple</code> and therefore a fixed maximum arity), or to a <code>TypeAlias</code> that has a fixed number of free type variables." - }, - { - "type": "text", - "html": "Additionally detects when a class that has fully specialised its generic base (e.g. <code>class Foo(Barint)</code>) is subscripted further, since it has no free type variables." - }, - { - "type": "code", - "lang": "python", - "code": "from typing import Generic, TypeVar, TypeAlias\nfrom typing_extensions import TypeVar as TypeVarExt\n\nT1 = TypeVar(\"T1\")\nT2 = TypeVar(\"T2\")\nDefaultStrT = TypeVarExt(\"DefaultStrT\", default=str)\n\nclass AllTheDefaults(Generic[T1, T2, DefaultStrT]): ...\n\nAllTheDefaults[int] # E \u2014 1 arg but at least 2 required\nAllTheDefaults[int, str] # OK\nAllTheDefaults[int, str, bytes] # OK\n\nclass LinkedList(Generic[T]): ...\n\nLinkedList[int, str] # E \u2014 2 args but at most 1 allowed\n\nMyAlias: TypeAlias = LinkedList[T2]\nMyAlias[int, str] # E \u2014 2 args but at most 1 allowed for the alias\n\nclass Foo(LinkedList[int]): ...\nFoo[str] # E \u2014 Foo has no free type variables" - } - ], - "group": "Type System", - "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", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep", - "generics" - ], - "summary": "Unbound type variable in scope", - "summaryHtml": "Unbound type variable in scope", - "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 (<code>GenericT</code>), <a href=\"https://peps.python.org/pep-0695/\">PEP 695</a> type parameter, or function signature parameter." - }, - { - "type": "text", - "html": "Unbound usages include: - <code>TypeVar</code> in a local variable annotation when the function does not bind it - <code>TypeVar</code> in a class body attribute when the class does not include it in <code>Generic...</code> - Inner class reusing an outer class's <code>TypeVar</code> in <code>GenericT</code> or body annotations - <code>TypeVar</code> at module level in annotations - <code>TypeAlias</code> at class level referencing the class's own <code>TypeVar</code>s" - }, - { - "type": "code", - "lang": "python", - "code": "T = TypeVar(\"T\")\nS = TypeVar(\"S\")\n\ndef fun(x: T) -> list[T]:\n z: list[S] = [] # E \u2014 S is not bound in this function\n\nclass Bar(Generic[T]):\n an_attr: list[S] = [] # E \u2014 S is not bound in Bar" - } - ], - "group": "Type System", - "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", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep", - "generics" - ], - "summary": "Incompatible type for `Self`-typed attribute", - "summaryHtml": "Incompatible type for <code>Self</code>-typed attribute", - "body": [ - { - "type": "text", - "html": "When a class declares an attribute annotated with <code>Self</code> (or <code>Self | None</code>, <code>OptionalSelf</code>, etc.), that attribute's type is bound to the <em>concrete</em> subclass at each usage site. Passing or assigning a parent-class instance where the subclass is expected is a type error." - }, - { - "type": "code", - "lang": "python", - "code": "from typing import Self, TypeVar, Generic\nfrom dataclasses import dataclass\n\nT = TypeVar(\"T\")\n\n@dataclass\nclass LinkedList(Generic[T]):\n value: T\n next: Self | None = None\n\n@dataclass\nclass OrdinalLinkedList(LinkedList[int]):\n def ordinal_value(self) -> str:\n return str(self.value)\n\nxs = OrdinalLinkedList(value=1, next=LinkedList[int](value=2)) # E\nxs.next = LinkedList[int](value=3, next=None) # E" - }, - { - "type": "text", - "html": "Specification: <https://typing.readthedocs.io/en/latest/spec/generics.html#use-in-attribute-annotations>" - } - ], - "group": "Type System", - "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", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep", - "generics" - ], - "summary": "`Self` type violations in generics", - "summaryHtml": "<code>Self</code> type violations in generics", - "body": [ - { - "type": "text", - "html": "This rule detects two kinds of <code>Self</code> type violations:" - }, - { - "type": "text", - "html": "1. **Return type mismatch**: A method (or classmethod) annotated <code>-> Self</code> returns a concrete class constructor call (e.g. <code>return Shape()</code>) instead of <code>self</code>, <code>cls()</code>, or another <code>Self</code>-compatible expression. In a subclass, <code>Self</code> resolves to the subclass type, so returning the parent class constructor is a type error." - }, - { - "type": "text", - "html": "2. **<code>Self</code> is not subscriptable**: <code>Self</code> cannot be parameterized (e.g. <code>Selfint</code>). It already captures the full generic specialization of the enclosing class." - }, - { - "type": "code", - "lang": "python", - "code": "from typing import Self\n\nclass Shape:\n def method2(self) -> Self:\n return Shape() # E \u2014 should return self, not Shape()\n\n @classmethod\n def cls_method2(cls) -> Self:\n return Shape() # E \u2014 should return cls(), not Shape()\n\nclass Container(Generic[T]):\n def foo(self, other: Self[int]) -> None: # E \u2014 Self is not subscriptable\n pass" - } - ], - "group": "Type System", - "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", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep", - "generics" - ], - "summary": "Protocol `Self`-return conformance violation", - "summaryHtml": "Protocol <code>Self</code>-return conformance violation", - "body": [ - { - "type": "text", - "html": "When a <code>Protocol</code> declares a method returning <code>Self</code>, any class passed where that protocol is expected must have the corresponding method return <code>Self</code> or the class itself. If the method returns a completely different type (e.g. <code>int</code> or a different class), the class does not satisfy the protocol." - }, - { - "type": "code", - "lang": "python", - "code": "class ShapeProtocol(Protocol):\n def set_scale(self, scale: float) -> Self: ...\n\nclass BadReturn:\n def set_scale(self, scale: float) -> int:\n return 42\n\ndef accepts(s: ShapeProtocol) -> None: ...\n\ndef main(bad: BadReturn) -> None:\n accepts(bad) # E \u2014 BadReturn.set_scale returns int, not Self" - } - ], - "group": "Type System", - "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", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep", - "generics" - ], - "summary": "`Self` type used in an invalid location", - "summaryHtml": "<code>Self</code> type used in an invalid location", - "body": [ - { - "type": "text", - "html": "<a href=\"https://peps.python.org/pep-0673/\">PEP 673</a> defines <code>Self</code> as a special type that refers to the current class. It is only valid in specific locations:" - }, - { - "type": "text", - "html": "- Method parameter annotations (including <code>self</code> and <code>cls</code>) - Method return type annotations - Class variable annotations inside the class body - Nested within other types at those locations" - }, - { - "type": "text", - "html": "Invalid locations (detected here):" - }, - { - "type": "text", - "html": "- Return types or parameter annotations of module-level functions - Module-level variable annotations (<code>bar: Self</code>) - <code>TypeAlias</code> definitions whose RHS contains <code>Self</code> - Base class expressions (<code>class Foo(BarSelf)</code> or <code>class Foo(Self)</code>) - <code>@staticmethod</code> method annotations (no <code>self</code> to bind to) - Method annotations in metaclasses (classes inheriting from <code>type</code>) - Return type annotation when <code>self</code> is explicitly annotated with a <code>TypeVar</code> (e.g. <code>def f(self: TFoo2) -> Self:</code> \u2014 binding is ambiguous)" - }, - { - "type": "code", - "lang": "python", - "code": "# E \u2014 not within a class\ndef foo(bar: Self) -> Self: ...\nbar: Self\n\nclass Base:\n @staticmethod\n def make() -> Self: ... # E \u2014 staticmethod has no Self binding\n\nclass MyMeta(type):\n def __new__(cls, *args: Any) -> Self: ... # E \u2014 metaclass" - } - ], - "group": "Type System", - "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", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep", - "generics" - ], - "summary": "PEP 695 type parameter syntax mixed with traditional `TypeVars`", - "summaryHtml": "<a href=\"https://peps.python.org/pep-0695/\">PEP 695</a> type parameter syntax mixed with traditional <code>TypeVars</code>", - "body": [ - { - "type": "text", - "html": "<a href=\"https://peps.python.org/pep-0695/\">PEP 695</a> introduced a new syntax for declaring type parameters (<code>class FooT</code> and <code>def fooT()</code>). When a class or function uses this new syntax, it must not reference traditional <code>TypeVar</code> instances from an outer scope in its base classes or parameter annotations." - }, - { - "type": "code", - "lang": "python", - "code": "from typing import TypeVar\n\nK = TypeVar(\"K\")\n\nclass ClassA[V](dict[K, V]): # E: traditional TypeVar K used with PEP 695 syntax\n ..." - } - ], - "group": "Type System", - "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", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep", - "generics" - ], - "summary": "Invalid PEP 695 type parameter bound or constraint", - "summaryHtml": "Invalid <a href=\"https://peps.python.org/pep-0695/\">PEP 695</a> type parameter bound or constraint", - "body": [ - { - "type": "text", - "html": "<a href=\"https://peps.python.org/pep-0695/\">PEP 695</a> introduced a new syntax for declaring type parameters in class and function definitions. The bound/constraint expression after <code>:</code> is restricted to specific forms; invalid forms are caught by this rule." - }, - { - "type": "code", - "lang": "python", - "code": "# BAD\nclass Foo[T: [str, int]]: # E: list literal is not a valid bound\n ...\n\nclass Bar[T: ()]: # E: constraint tuple must have two or more types\n ...\n\nclass Baz[T: (str,)]: # E: constraint tuple must have two or more types\n ...\n\nt1 = (bytes, str)\nclass Qux[T: t1]: # E: constraint must be a literal tuple expression\n ...\n\nclass Bad[T: (3, bytes)]: # E: 3 is not a valid type expression\n ..." - } - ], - "group": "Type System", - "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", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep", - "generics" - ], - "summary": "Invalid attribute access on bounded type variable", - "summaryHtml": "Invalid attribute access on bounded type variable", - "body": [ - { - "type": "text", - "html": "When a <a href=\"https://peps.python.org/pep-0695/\">PEP 695</a> type parameter has a bound (e.g., <code>T: str</code>), attribute accesses on parameters typed as <code>T</code> must be valid for the bound type." - }, - { - "type": "code", - "lang": "python", - "code": "class C[T: str]:\n def method(self, x: T):\n x.capitalize() # OK - str has capitalize\n x.is_integer() # E - str does NOT have is_integer" - } - ], - "group": "Type System", - "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", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep", - "generics" - ], - "summary": "PEP 695 generic type parameter scoping violations", - "summaryHtml": "<a href=\"https://peps.python.org/pep-0695/\">PEP 695</a> generic type parameter scoping violations", - "body": [ - { - "type": "text", - "html": "Detects violations of <a href=\"https://peps.python.org/pep-0695/\">PEP 695</a> type-parameter scoping rules, driven entirely by <code>ruff_python_ast</code> nodes (via basilisk_resolver::Pep695Scoping) \u2014 never by raw <code>source.lines()</code> scanning, so docstring/comment/string content is never mistaken for real <code>class</code> / <code>def</code> / <code>type</code> declarations." - }, - { - "type": "text", - "html": "1. A type parameter's bound references another type parameter in the same list (forward or backward reference). 2. A type parameter is used at module scope (2a) or in a decorator applied to the generic construct that declares it (2b). 3. A method re-declares an enclosing class's type parameter (shadowing). 4. A <code>type</code> statement references an old-style <code>TypeVar</code>. 5. A <code>type</code> statement appears inside a function body. 6. A <code>type</code> alias is circular. 7. A <code>type</code> alias is misused (called, subclassed, <code>isinstance</code>, attribute). 8. A type argument violates a bounded alias type parameter." - }, - { - "type": "code", - "lang": "python", - "code": "class ClassA[S, T: Sequence[S]]: ... # E \u2014 T's bound references S\nprint(T) # E \u2014 T not defined at module scope\n\n@decorator(Foo[T]) # E \u2014 T not in scope in the decorator\nclass ClassD[T]: ...\n\nclass ClassE[T]:\n def method1[T](self): ... # E \u2014 method re-defines class type param" - }, - { - "type": "text", - "html": "Reference: <https://peps.python.org/pep-0695/#type-parameter-scopes>" - } - ], - "group": "Type System", - "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", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep", - "generics" - ], - "summary": "Access to instance attribute on a class object", - "summaryHtml": "Access to instance attribute on a class object", - "body": [ - { - "type": "text", - "html": "Instance attributes (annotations without <code>ClassVar</code> in the class body that lack a default value) exist only on instances, not on the class object itself. Accessing or assigning such attributes on the class (including parameterised generics like <code>Nodeint</code>) is an error." - }, - { - "type": "code", - "lang": "python", - "code": "from typing import Generic, TypeVar\n\nT = TypeVar(\"T\")\n\nclass Node(Generic[T]):\n label: T\n\nNode[int].label = 1 # E: instance attribute on class\nNode[int].label # E\nNode.label = 1 # E\nNode.label # E\ntype(n1).label # E" - } - ], - "group": "Type System", - "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", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep", - "generics" - ], - "summary": "`TypeVarTuple` argument count mismatch", - "summaryHtml": "<code>TypeVarTuple</code> argument count mismatch", - "body": [ - { - "type": "text", - "html": "When a constructor with <code>TypeVarTuple</code> parameters is called, the number of arguments must match the expected count inferred from the <code>TypeVarTuple</code>." - }, - { - "type": "code", - "lang": "python", - "code": "Ts = TypeVarTuple(\"Ts\")\n\nclass Array(Generic[*Ts]):\n def __init__(self, shape: tuple[*Ts]) -> None: ...\n\nArray[Height, Width]((Height(1), Width(2))) # OK\nArray[Height, Width](Height(1)) # E: expected 2 arguments, got 1" - } - ], - "group": "Type System", - "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", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep", - "generics" - ], - "summary": "Invalid `TypeVar` / `TypeVarTuple` / `ParamSpec` keyword argument combination", - "summaryHtml": "Invalid <code>TypeVar</code> / <code>TypeVarTuple</code> / <code>ParamSpec</code> keyword argument combination", - "body": [ - { - "type": "text", - "html": "<a href=\"https://peps.python.org/pep-0484/\">PEP 484</a> / <a href=\"https://peps.python.org/pep-0695/\">PEP 695</a> forbid certain combinations of keyword arguments in <code>TypeVar(...)</code> calls, and <a href=\"https://peps.python.org/pep-0646/\">PEP 646</a> / <a href=\"https://peps.python.org/pep-0612/\">PEP 612</a> restrict what kwargs <code>TypeVarTuple</code> and <code>ParamSpec</code> accept:" - }, - { - "type": "text", - "html": "1. <code>covariant=True</code> and <code>contravariant=True</code> together \u2014 a <code>TypeVar</code> cannot be both covariant and contravariant. 2. <code>infer_variance=True</code> with <code>covariant=True</code> or <code>contravariant=True</code> \u2014 when variance is inferred, the explicit flags are redundant and disallowed. 3. Constraints (2+ positional type args) combined with <code>bound=</code> \u2014 a <code>TypeVar</code> may have one or the other, but not both. 4. <code>TypeVarTuple</code> and <code>ParamSpec</code> do not support <code>covariant</code>, <code>contravariant</code>, <code>bound</code>, or type constraint arguments." - }, - { - "type": "code", - "lang": "python", - "code": "from typing import TypeVar, TypeVarTuple\nT1 = TypeVar(\"T1\", covariant=True, contravariant=True) # E\nT2 = TypeVar(\"T2\", covariant=True, infer_variance=True) # E\nT3 = TypeVar(\"T3\", str, int, bound=\"int\") # E\nTs = TypeVarTuple(\"Ts\", covariant=True) # E\nTs2 = TypeVarTuple(\"Ts2\", int, float) # E" - } - ], - "group": "Type System", - "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", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep", - "generics" - ], - "summary": "`TypeVarTuple` must be unpacked with `*` operator", - "summaryHtml": "<code>TypeVarTuple</code> must be unpacked with <code>*</code> operator", - "body": [ - { - "type": "text", - "html": "When a <code>TypeVarTuple</code> is used in a generic class base list or as a direct type annotation, it must be unpacked using the <code>*</code> operator. Using a <code>TypeVarTuple</code> without unpacking is invalid per <a href=\"https://peps.python.org/pep-0646/\">PEP 646</a>." - }, - { - "type": "code", - "lang": "python", - "code": "from typing import Generic, TypeVarTuple\n\nTs = TypeVarTuple(\"Ts\")\n\n# BAD\nclass Cls(Generic[Ts]): # E: TypeVarTuple must be unpacked with *\n ...\n\ndef f(*args: Ts) -> None: # E: TypeVarTuple must be unpacked with *\n ...\n\n# GOOD\nclass Cls2(Generic[*Ts]): # OK\n ...\n\ndef f2(*args: *Ts) -> None: # OK\n ..." - } - ], - "group": "Type System", - "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", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep", - "generics" - ], - "summary": "`TypeVarTuple` variance/bounds/constraints violation", - "summaryHtml": "<code>TypeVarTuple</code> variance/bounds/constraints violation", - "body": [ - { - "type": "text", - "html": "<code>TypeVarTuple</code> does not support specification of variance, bounds, or constraints. Using these parameters with <code>TypeVarTuple</code> is invalid." - }, - { - "type": "code", - "lang": "python", - "code": "# BAD\nTs = TypeVarTuple(\"Ts\", covariant=True) # E: TypeVarTuple does not support variance\nTs = TypeVarTuple(\"Ts\", int, float) # E: TypeVarTuple does not support constraints\nTs = TypeVarTuple(\"Ts\", bound=int) # E: TypeVarTuple does not support bounds\n\n# GOOD\nTs = TypeVarTuple(\"Ts\") # OK" - } - ], - "group": "Type System", - "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", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep", - "generics" - ], - "summary": "`TypeVarTuple` callable/tuple argument mismatch", - "summaryHtml": "<code>TypeVarTuple</code> callable/tuple argument mismatch", - "body": [ - { - "type": "text", - "html": "When a constructor (or function) links two parameters via a <code>TypeVarTuple</code> -- one as <code>Callable[<em>Ts, R]</code> and the other as <code>tuple</em>Ts</code> -- passing a known function as the callable infers the expected element types for the tuple. If the tuple literal has elements whose types do not match the inferred order, Basilisk reports the mismatch." - }, - { - "type": "code", - "lang": "python", - "code": "Ts = TypeVarTuple(\"Ts\")\n\nclass Process:\n def __init__(self, target: Callable[[*Ts], None], args: tuple[*Ts]) -> None: ...\n\ndef func1(arg1: int, arg2: str) -> None: ...\n\nProcess(target=func1, args=(0, \"\")) # OK\nProcess(target=func1, args=(\"\", 0)) # E -- str, int does not match int, str" - } - ], - "group": "Type System", - "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", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep", - "generics" - ], - "summary": "Multiple `TypeVarTuple` unpacks in generic or tuple type", - "summaryHtml": "Multiple <code>TypeVarTuple</code> unpacks in generic or tuple type", - "body": [ - { - "type": "text", - "html": "Only a single <code>TypeVarTuple</code> unpack (<code>*Ts</code>) may appear in a type parameter list or in a <code>tuple...</code> type expression." - }, - { - "type": "code", - "lang": "python", - "code": "# BAD \u2014 multiple TypeVarTuples in class\nclass Array3(Generic[*Ts1, *Ts2]): # E\n ...\n\n# BAD \u2014 multiple unpacks in tuple type\nTA5 = tuple[T1, *Ts, T2, *Ts] # E\nTA6 = tuple[T1, *Ts, T2, *tuple[int, ...]] # E\n\n# GOOD\nclass Array(Generic[*Ts]): ...\nTA1 = tuple[*Ts, T1, T2] # OK \u2014 single unpack" - } - ], - "group": "Type System", - "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", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep", - "generics" - ], - "summary": "Invalid `TypeVarTuple` specialization of generic alias", - "summaryHtml": "Invalid <code>TypeVarTuple</code> specialization of generic alias", - "body": [ - { - "type": "text", - "html": "Two related violations are detected:" - }, - { - "type": "text", - "html": "1. **Unpack in non-TypeVarTuple generic**: When a generic alias is defined using only regular <code>TypeVar</code>s (no <code>TypeVarTuple</code>), you cannot specialise it with an unpacked <code>TypeVarTuple</code> (<code><em>Ts</code>) or an unpacked homogeneous tuple (<code></em>tupleT, ...</code>)." - }, - { - "type": "code", - "lang": "python", - "code": "T = TypeVar(\"T\")\nIntTupleGeneric = tuple[int, T]\n\nIntTupleGeneric[str] # OK\nIntTupleGeneric[*Ts] # E \u2014 Ts is a TypeVarTuple, not a TypeVar\nIntTupleGeneric[*tuple[float, ...]] # E \u2014 unpacked tuple not allowed here" - }, - { - "type": "text", - "html": "2. **Too few type arguments for TypeVarTuple+TypeVar alias**: When a generic alias contains both a <code>TypeVarTuple</code> and one or more regular <code>TypeVar</code>s, every specialisation must supply at least as many arguments as there are regular <code>TypeVar</code>s (the <code>TypeVarTuple</code> absorbs the rest)." - }, - { - "type": "code", - "lang": "python", - "code": "T1, T2 = TypeVar(\"T1\"), TypeVar(\"T2\")\nTs = TypeVarTuple(\"Ts\")\nTA7 = tuple[*Ts, T1, T2]\n\nv1: TA7[int] # E \u2014 requires at least two type arguments (T1, T2)\nv2: TA7[int, str] # OK \u2014 T1=int, T2=str, Ts=()" - } - ], - "group": "Type System", - "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", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep", - "generics" - ], - "summary": "`TypeVarTuple` unpack minimum type argument violation", - "summaryHtml": "<code>TypeVarTuple</code> unpack minimum type argument violation", - "body": [ - { - "type": "text", - "html": "When a function parameter has a type annotation containing a <code>TypeVarTuple</code> unpack pattern like <code>ArrayBatch, *tuple[Any, ..., Channels]</code>, the type has fixed prefix and suffix type arguments around a variadic middle. Any value passed to that parameter must have at least <code>prefix_count + suffix_count</code> type arguments." - }, - { - "type": "code", - "lang": "python", - "code": "Ts = TypeVarTuple(\"Ts\")\n\nclass Array(Generic[*Ts]): ...\n\ndef process(x: Array[Batch, *tuple[Any, ...], Channels]) -> None: ...\n\ndef func(z: Array[Batch]):\n process(z) # E -- Array[Batch] has 1 type arg, need at least 2" - } - ], - "group": "Type System", - "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", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep", - "generics" - ], - "summary": "`TypeVar` upper bound violation at call site", - "summaryHtml": "<code>TypeVar</code> upper bound violation at call site", - "body": [ - { - "type": "text", - "html": "When a function parameter is annotated with a <code>TypeVar</code> that has an upper bound (e.g. <code>bound=Sized</code>), and the call site passes a literal value whose type does not satisfy that bound, Basilisk reports the violation." - }, - { - "type": "code", - "lang": "python", - "code": "from typing import Sized, TypeVar\n\nST = TypeVar(\"ST\", bound=Sized)\n\ndef longer(x: ST, y: ST) -> ST:\n if len(x) > len(y):\n return x\n return y\n\nlonger(3, 3) # E -- int does not implement Sized (__len__)" - } - ], - "group": "Type System", - "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", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep", - "generics" - ], - "summary": "`TypeVar` bound violation at call site", - "summaryHtml": "<code>TypeVar</code> bound violation at call site", - "body": [ - { - "type": "text", - "html": "When a function has a parameter typed with a <code>TypeVar</code> that has a <code>bound</code>, and a call passes an argument whose type is not a subtype of that bound, this rule reports the mismatch." - }, - { - "type": "code", - "lang": "python", - "code": "TLiteral = TypeVar(\"TLiteral\", bound=LiteralString)\n\ndef literal_identity(s: TLiteral) -> TLiteral:\n return s\n\ndef func5(s: str):\n literal_identity(s) # E \u2014 str is not a subtype of LiteralString" - } - ], - "group": "Type System", - "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", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep", - "generics" - ], - "summary": "Variance incompatibility in base class parameterisation", - "summaryHtml": "Variance incompatibility in base class parameterisation", - "body": [ - { - "type": "text", - "html": "When a class inherits from a generic base class (directly or through a type alias), the <code>TypeVar</code> arguments must have compatible variance with the corresponding type parameters declared by the base class." - }, - { - "type": "code", - "lang": "python", - "code": "from typing import Generic, TypeVar\n\nT = TypeVar(\"T\") # invariant\nT_co = TypeVar(\"T_co\", covariant=True)\n\nclass Base(Generic[T]): ...\n\nclass Bad(Base[T_co]): ... # E \u2014 invariant param gets covariant arg" - } - ], - "group": "Type System", - "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", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep", - "generics" - ], - "summary": "`TypeVar` scoping violation", - "summaryHtml": "<code>TypeVar</code> scoping violation", - "body": [ - { - "type": "text", - "html": "Detects uses of <code>TypeVar</code> instances outside their valid scope:" - }, - { - "type": "text", - "html": "1. A nested class inside a generic class using the outer class's <code>TypeVar</code> in its base classes or body (the outer class's type params don't cover the inner class scope). 2. A class nested inside a generic function re-using the function's <code>TypeVar</code> in <code>Generic...</code>. 3. A <code>TypeVar</code> used in a module-level expression (subscript call like <code>listT()</code>). 4. A method call on a generic class instance where the argument type does not match the substituted <code>TypeVar</code> type (e.g., <code>a: MyClassint</code>, calling <code>a.meth('str')</code> when <code>meth</code> expects <code>T</code> which is bound to <code>int</code>)." - }, - { - "type": "text", - "html": "Per <a href=\"https://peps.python.org/pep-0484/\">PEP 484</a>: "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", - "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", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep", - "historical" - ], - "summary": "Historical positional-only parameter violations", - "summaryHtml": "Historical positional-only parameter violations", - "body": [ - { - "type": "text", - "html": "Before <a href=\"https://peps.python.org/pep-0570/\">PEP 570</a> (Python 3.8), the convention for marking parameters as positional-only was to prefix their names with <code>__</code> (double underscore) without a trailing <code>__</code>. Type checkers must support this historical mechanism." - }, - { - "type": "text", - "html": "Two violations are detected:" - }, - { - "type": "text", - "html": "1. **<code>PositionalOnlyAfterKeyword</code>**: A <code>__</code>-prefixed positional-only parameter appears after a regular positional-or-keyword parameter in a function that does not use <a href=\"https://peps.python.org/pep-0570/\">PEP 570</a> <code>/</code> syntax." - }, - { - "type": "text", - "html": "2. **<code>KeywordPassedToPositionalOnly</code>**: A <code>__</code>-prefixed keyword argument is passed at a call site (e.g. <code>f(__x=3)</code>), which is invalid because <code>__x</code> is positional-only and cannot be passed by keyword." - }, - { - "type": "code", - "lang": "python", - "code": "def f1(__x: int) -> None: ...\n\nf1(__x=3) # E \u2014 __x is positional-only\n\ndef f2(x: int, __y: int) -> None: ... # E \u2014 __y after positional-or-keyword x" - } - ], - "group": "Type System", - "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_missing_name", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep" - ], - "summary": "Importing a name the resolved module does not define", - "summaryHtml": "Importing a name the resolved module does not define", - "body": [ - { - "type": "text", - "html": "<code>from M import name</code> only proves that the module path <code>M</code> resolves to a file; it says nothing about <code>name</code>. When <code>M</code> is a workspace <code>.py</code> source Basilisk can see every module-level binding, so importing a name that is neither bound in <code>M</code>, nor an existing submodule of the package, is an <code>ImportError</code> waiting for runtime (GitHub #55)." - }, - { - "type": "code", - "lang": "python", - "code": "from demo.late_module import provide_value # late_module.py defines nothing" - }, - { - "type": "text", - "html": "The rule is deliberately conservative \u2014 silence over guessing:" - }, - { - "type": "text", - "html": "- Every module-level binding form counts as defined: <code>def</code>/<code>class</code>, every assignment form, <code>import</code>/<code>from</code> re-exports, <code>for</code>/<code>with</code>/<code>match</code>/ <code>except</code> targets, walrus expressions, and <code>type</code> alias statements. - A module-level <code>__getattr__</code> (<a href=\"https://peps.python.org/pep-0562/\">PEP 562</a>) permits any name. - A target containing <code>from x import *</code> has an unknowable member set and suppresses the rule for that module. - <code>from pkg import mod</code> is satisfied by an existing <code>pkg/mod.py</code>, <code>pkg/mod.pyi</code>, or <code>pkg/mod/</code> submodule." - }, - { - "type": "text", - "html": "Scope: <code>from</code>-imports resolved to workspace <code>.py</code> sources. Stub-backed imports are covered by <code>imports_module_attribute</code>; site-packages sources stay with <code>missing_type_stubs</code> (<a href=\"https://peps.python.org/pep-0561/\">PEP 561</a> draws the trust boundary there)." - } - ], - "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/imports_missing_name", - "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/" - }, - { - "label": "PEP 562", - "url": "https://peps.python.org/pep-0562/" - } - ] - }, - { - "code": "imports_module_attribute", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep" - ], - "summary": "Access to a module attribute the local stub does not declare", - "summaryHtml": "Access to a module attribute the local stub does not declare", - "body": [ - { - "type": "text", - "html": "When <code>import X</code> resolves to an authoritative user/local or selected Typeshed stub, Basilisk sees the declarations and re-exports in that stub. <code>X.attr</code> where <code>attr</code> is not declared is an error." - }, - { - "type": "text", - "html": "The escape hatch is the module-level <code>def __getattr__(name: str) -> Any: ...</code> that the "Create local type stub" quick fix ships by default: keep it and every attribute is allowed (the module stays <code>Any</code>); remove it and declare specific symbols, and undeclared access is flagged." - }, - { - "type": "code", - "lang": "python", - "code": "import cowsay # resolves to .basilisk/stubs/cowsay.pyi\ncowsay.get_output_string(...) # E0154 if the stub declares neither this nor __getattr__" - }, - { - "type": "text", - "html": "Scope: plain imports backed by a user stub or the active step-3 Typeshed source. Untyped and inline third-party imports remain outside this rule." - } - ], - "group": "Type System", - "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", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep" - ], - "summary": "Unresolved import", - "summaryHtml": "Unresolved import", - "body": [ - { - "type": "text", - "html": "Fires when an import cannot be resolved and the module is not part of the Python standard library. When uv package-registry context is available the diagnostic message explains <em>why</em> the import failed (not installed, transitive-only, needs sync, wrong Python version). Without that context a generic fallback message is used." - }, - { - "type": "text", - "html": "This is where the static resolution model surfaces its terminal state (STUBRES-STATIC-MODEL): an import the static filesystem search could not follow \u2014 a missing dependency, but equally a computed/dynamic import or a module only a runtime <code>sys.meta_path</code> hook could supply \u2014 carries an implicit <code>Any</code>, and default-strict reports it here rather than silently accepting it." - } - ], - "group": "Type System", - "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", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep", - "literals" - ], - "summary": "`LiteralString` and `Literal` assignment incompatibilities", - "summaryHtml": "<code>LiteralString</code> and <code>Literal</code> assignment incompatibilities", - "body": [ - { - "type": "text", - "html": "Detects annotated local variables inside function bodies where the declared type is incompatible with the assigned value, specifically for <code>LiteralString</code> and <code>Literal...</code> types." - }, - { - "type": "text", - "html": "Covered cases:" - }, - { - "type": "text", - "html": "1. Assigning a <code>Literal"X"</code>-typed parameter to a <code>Literal"Y"</code> variable where the literal values differ. 2. Assigning an f-string containing non-<code>LiteralString</code> interpolations to a <code>LiteralString</code>-annotated variable. 3. Assigning a generic parameterised with <code>str</code> where <code>LiteralString</code> is required (invariant generics like <code>list</code>, <code>Container</code>). 4. Assigning a <code>listLiteralString</code> to <code>liststr</code> \u2014 lists are invariant." - }, - { - "type": "code", - "lang": "python", - "code": "def func(b: Literal[\"two\"], non_literal: str):\n x1: Literal[\"\"] = b # E \u2014 different literal values\n x2: LiteralString = f\"{non_literal}\" # E \u2014 non-literal in f-string\n x3: Container[LiteralString] = Container(s) # E \u2014 str \u2260 LiteralString\n x4: list[str] = val # E \u2014 invariant mismatch" - } - ], - "group": "Type System", - "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", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep", - "literals" - ], - "summary": "Invalid `Literal` parameterization", - "summaryHtml": "Invalid <code>Literal</code> parameterization", - "body": [ - { - "type": "text", - "html": "<a href=\"https://peps.python.org/pep-0586/\">PEP 586</a> restricts what values may appear inside <code>Literal...</code>. Only these are legal: - Integer literals (decimal, hex, binary, octal; optionally signed) - String literals (<code>str</code> and <code>bytes</code>) - Boolean literals (<code>True</code>, <code>False</code>) - <code>None</code> - Enum member access (<code>Color.RED</code>) - Nested <code>Literal...</code>" - }, - { - "type": "text", - "html": "Everything else is illegal, including: - Arithmetic / unary expressions (<code>3 + 4</code>, <code>~5</code>, <code>not False</code>) - Function calls (<code>"foo".replace(...)</code>) - Containers (<code>(1, 2)</code>, <code>{"a": "b"}</code>) - Type objects, <code>TypeVar</code>s, <code>Any</code> (<code>Literalint</code>, <code>LiteralT</code>) - Float literals (<code>3.14</code>) - Ellipsis (<code>...</code>) - Bare <code>Literal</code> with no arguments - Variables and function objects" - } - ], - "group": "Type System", - "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", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep", - "literals" - ], - "summary": "`Literal[\"EnumClass.MEMBER\"]` (string) used where `Literal[EnumClass.MEMBER]` (enum member reference) is required", - "summaryHtml": "<code>Literal"EnumClass.MEMBER"</code> (string) used where <code>LiteralEnumClass.MEMBER</code> (enum member reference) is required", - "body": [ - { - "type": "text", - "html": "A quoted string like <code>"Color.RED"</code> is a <code>str</code> literal \u2014 it is NOT the same as the enum member <code>Color.RED</code>. When a variable is declared as <code>LiteralColor.RED</code> but assigned from a parameter typed as <code>Literal"Color.RED"</code>, the types are incompatible." - }, - { - "type": "code", - "lang": "python", - "code": "from enum import Enum\nfrom typing import Literal\n\nclass Color(Enum):\n RED = 1\n\ndef func2(a: Literal[Color.RED]) -> None:\n x1: Literal[\"Color.RED\"] = a # E \u2014 string literal != enum member" - } - ], - "group": "Type System", - "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", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep", - "literals" - ], - "summary": "Augmented assignment widens `Literal` type", - "summaryHtml": "Augmented assignment widens <code>Literal</code> type", - "body": [ - { - "type": "text", - "html": "augmented assignment keeps the declared target type and validates whether the operation widens out of it." - }, - { - "type": "text", - "html": "When a function parameter is annotated with <code>Literal...</code>, augmented assignment (<code>+=</code>, <code>-=</code>, etc.) effectively reassigns the variable to a widened type (e.g. <code>int</code> instead of <code>Literal3, 4, 5</code>), violating the declared <code>Literal</code> constraint." - }, - { - "type": "code", - "lang": "python", - "code": "def func(a: Literal[3, 4, 5]):\n a += 3 # E0100 \u2014 augmented assign widens Literal type" - } - ], - "group": "Type System", - "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", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep", - "literals" - ], - "summary": "Literal value assignment incompatibility", - "summaryHtml": "Literal value assignment incompatibility", - "body": [ - { - "type": "text", - "html": "Detects two classes of Literal-related assignment errors inside function bodies:" - }, - { - "type": "text", - "html": "1. **<code>Literal0</code> vs <code>LiteralFalse</code> non-equivalence (<a href=\"https://peps.python.org/pep-0586/\">PEP 586</a>)**: <code>Literal0</code> and <code>LiteralFalse</code> are distinct types despite <code>0 == False</code> in Python. Assigning a <code>Literal0</code>-typed parameter to a <code>LiteralFalse</code> local (or vice versa) is a type error." - }, - { - "type": "text", - "html": "2. **Augmented assignment widens a Literal type**: <code>a += 3</code> where <code>a</code> is typed <code>Literal3, 4, 5</code> produces an <code>int</code> result, which is not assignable back to <code>Literal3, 4, 5</code>." - }, - { - "type": "code", - "lang": "python", - "code": "def func(a: Literal[0], b: Literal[False]):\n x1: Literal[False] = a # E \u2014 int 0 \u2260 bool False in Literal\n x2: Literal[0] = b # E \u2014 bool False \u2260 int 0 in Literal\n\ndef func2(a: Literal[3, 4, 5]):\n a += 3 # E \u2014 result type is `int`, not `Literal[3, 4, 5]`" - } - ], - "group": "Type System", - "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", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep" - ], - "summary": "Non-exhaustive `match` statement", - "summaryHtml": "Non-exhaustive <code>match</code> statement", - "body": [ - { - "type": "text", - "html": "A value-dispatch <code>match</code> statement that has no irrefutable branch may fail to handle certain runtime values, leading to a silent fall-through (Python does not raise an error for unmatched <code>match</code> subjects). Basilisk reports this as an error." - }, - { - "type": "text", - "html": "Two cases are <em>not</em> flagged, matching the reference checkers: <em> a bare capture <code>case name:</code> (no guard) is irrefutable \u2014 like <code>case _:</code>, it makes the match exhaustive; </em> a structural match (sequence/mapping patterns) decomposes open-ended shapes \u2014 e.g. narrowing a tuple union of mixed arity \u2014 where a catch-all is not required for correctness." - } - ], - "group": "Type System", - "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", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep", - "namedtuples" - ], - "summary": "`NamedTuple` class definition errors", - "summaryHtml": "<code>NamedTuple</code> class definition errors", - "body": [ - { - "type": "text", - "html": "Detects several categories of <code>NamedTuple</code> definition errors:" - }, - { - "type": "text", - "html": "1. **Underscore field names**: Field names starting with <code>_</code> are illegal in <code>NamedTuple</code> definitions (the runtime raises <code>ValueError</code>)." - }, - { - "type": "text", - "html": "2. **Default ordering**: Fields with default values must come after all fields without defaults (same rule as the runtime enforces)." - }, - { - "type": "text", - "html": "3. **Subclass field conflict**: A <code>NamedTuple</code> subclass cannot redefine fields that exist in the base <code>NamedTuple</code>." - }, - { - "type": "text", - "html": "4. **Multiple inheritance**: <code>NamedTuple</code> does not support inheriting from multiple bases (other than <code>Generic...</code>)." - } - ], - "group": "Type System", - "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", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep", - "namedtuples" - ], - "summary": "Invalid argument in a `NamedTuple` constructor call", - "summaryHtml": "Invalid argument in a <code>NamedTuple</code> constructor call", - "body": [ - { - "type": "text", - "html": "When a <code>NamedTuple</code> is instantiated using keyword arguments, Basilisk validates each argument against the field names and field types declared in the <code>NamedTuple(...)</code> definition." - }, - { - "type": "text", - "html": "Two kinds of violation are caught:" - }, - { - "type": "text", - "html": "1. **Unknown field** \u2014 a keyword whose name is not among the declared fields. 2. **Type mismatch** \u2014 a keyword whose literal value is incompatible with the declared field type (e.g. passing a <code>str</code> literal for an <code>int</code> field)." - }, - { - "type": "code", - "lang": "python", - "code": "X: Final = \"x\"\nY: Final = \"y\"\nN = NamedTuple(\"N\", [(X, int), (Y, int)])\n\nN(x=3, y=4) # OK\nN(a=1) # E: unknown field `a`\nN(x=\"\", y=\"\") # E: field `x` expects `int` but got `str`" - } - ], - "group": "Type System", - "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", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep", - "namedtuples" - ], - "summary": "`NamedTuple`-to-tuple type incompatibility", - "summaryHtml": "<code>NamedTuple</code>-to-tuple type incompatibility", - "body": [ - { - "type": "text", - "html": "When a <code>NamedTuple</code> instance is assigned to a variable annotated with a fixed-length <code>tuple...</code> type, Basilisk verifies:" - }, - { - "type": "text", - "html": "1. The element count matches the number of fields in the <code>NamedTuple</code>. 2. Each element type in the tuple annotation is compatible with the corresponding <code>NamedTuple</code> field type (with covariance)." - }, - { - "type": "code", - "lang": "python", - "code": "class Point(NamedTuple):\n x: int\n y: int\n units: str = \"meters\"\n\np = Point(x=1, y=2, units=\"inches\")\nv1: tuple[int, int, str] = p # OK\nv2: tuple[int, int] = p # E -- too few elements (2 vs 3 fields)\nv3: tuple[int, str, str] = p # E -- incompatible element type" - } - ], - "group": "Type System", - "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", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep", - "namedtuples" - ], - "summary": "`NamedTuple` usage violations", - "summaryHtml": "<code>NamedTuple</code> usage violations", - "body": [ - { - "type": "text", - "html": "Detects invalid usage of <code>NamedTuple</code> instances:" - }, - { - "type": "text", - "html": "1. **Out-of-bounds index access**: <code>p3</code> on a 3-field <code>NamedTuple</code> (valid: 0..2 or -3..-1). 2. **Attribute assignment**: <code>p.x = 3</code> \u2014 <code>NamedTuple</code> fields are read-only. 3. **Subscript assignment**: <code>p0 = 3</code> \u2014 <code>NamedTuple</code> elements are read-only. 4. **Attribute deletion**: <code>del p.x</code> \u2014 <code>NamedTuple</code> fields cannot be deleted. 5. **Subscript deletion**: <code>del p0</code> \u2014 <code>NamedTuple</code> elements cannot be deleted. 6. **Wrong-count tuple unpack**: <code>x, y = p</code> when <code>p</code> has 3 fields." - }, - { - "type": "code", - "lang": "python", - "code": "class Point(NamedTuple):\n x: int\n y: int\n units: str = \"meters\"\n\np = Point(1, 2)\nprint(p[3]) # E: out-of-bounds index\nprint(p[-4]) # E: out-of-bounds negative index\np.x = 3 # E: NamedTuple fields are read-only\np[0] = 3 # E: NamedTuple elements are read-only\ndel p.x # E: NamedTuple fields cannot be deleted\ndel p[0] # E: NamedTuple elements cannot be deleted\nx, y = p # E: too few values to unpack (expected 3)" - } - ], - "group": "Type System", - "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", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep" - ], - "summary": "possibly-unbound variable at a `return`", - "summaryHtml": "possibly-unbound variable at a <code>return</code>", - "body": [ - { - "type": "text", - "html": "NARROWPLAN-INTEGRATION Step 8 (#285(https://github.com/Nimblesite/Basilisk/issues/285)): definite assignment is tracked over ALL paths, and divergence is the walker's inference-driven analysis (NARROWPLAN-FLOW, crate::narrow::stmt_diverges) \u2014 a branch that provably never falls through (<code>return</code>, <code>raise</code>, a <code>NoReturn</code>-typed call, <code>while True:</code> without <code>break</code>) cannot leave the name unbound, so it drops out of the merge instead of poisoning it." - }, - { - "type": "code", - "lang": "python", - "code": "def maybe_assign(flag: bool) -> int:\n if flag:\n result = 42\n return result # result may be unbound if flag is False \u2192 names_unbound\n\ndef guarded(flag: bool) -> int:\n if flag:\n result = 42\n else:\n return 0 # this path never reaches the return below\n return result # bound on every live path \u2014 silent" - }, - { - "type": "text", - "html": "Gradual posture (TYPEINF-TARGET-GRADUAL): a read the walk cannot prove bound on every live path fires only where the walk is exact (straight lines, <code>if</code>/<code>elif</code>/<code>else</code>, <code>try</code> success paths, <code>match</code> cases, <code>with</code> bodies); inside loop bodies, <code>except</code> handlers, and <code>finally</code> blocks \u2014 where an earlier iteration or a mid-statement exception makes "bound" path-dependent \u2014 the walk abstains." - } - ], - "group": "Type System", - "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", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep" - ], - "summary": "Reference to a name with no visible definition", - "summaryHtml": "Reference to a name with no visible definition", - "body": [ - { - "type": "text", - "html": "Flags any name referenced in a <code>return</code> expression \u2014 bare (<code>return x</code>), the base of an attribute/subscript chain (<code>return x.y</code>), a call argument, or the **callee of a call** (<code>return x()</code>) \u2014 that is not defined in scope. A name is considered defined if it is a parameter, a local assignment (<code>=</code>, <code>for</code>, <code>with</code>), a module-level function, class, variable, import, or <a href=\"https://peps.python.org/pep-0695/\">PEP 695</a> <code>type</code> alias, an enclosing scope's binding, a cross-module imported symbol, or a builtin." - }, - { - "type": "text", - "html": "Also flags a module-level statement that calls a name bound nowhere in the module (issue #397), and a class that lists **its own name among its bases** (issue #398) \u2014 Python evaluates the bases tuple before binding the class name, so both raise <code>NameError</code> the moment the module is imported. Shadowing stays legal: <code>class D(D)</code> is only flagged when the class statement is the SOLE binding of that name (no earlier class, import, assignment, or builtin to inherit from). A <code>from m import *</code> disables both module-level passes: the star can bind any name." - }, - { - "type": "code", - "lang": "python", - "code": "def compute() -> int:\n return undefined_name # never defined \u2192 E0018\n return undefined_fn() # undefined callee \u2192 E0018\n\n\na: int = print2(\"abc\") # no `print2` anywhere \u2192 E0018\n\nclass D(D): # `D` unbound in its own bases \u2192 E0018\n pass" - } - ], - "group": "Type System", - "docsUrl": "https://www.basilisk-python.dev/errors/names_undefined", - "references": [ - { - "label": "PEP 695", - "url": "https://peps.python.org/pep-0695/" - }, - { - "label": "Python language reference: Naming and binding", - "url": "https://docs.python.org/3/reference/executionmodel.html#naming-and-binding" - } - ] - }, - { - "code": "narrowing_typeguard", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep", - "narrowing" - ], - "summary": "`TypeGuard` or `TypeIs` on method with no narrowing parameter", - "summaryHtml": "<code>TypeGuard</code> or <code>TypeIs</code> on method with no narrowing parameter", - "body": [ - { - "type": "text", - "html": "The typing spec requires that a <code>TypeGuard</code> or <code>TypeIs</code> function must have at least one user-facing parameter to narrow. When a method returns <code>TypeGuardX</code> or <code>TypeIsX</code> but only has <code>self</code> or <code>cls</code>, there is no parameter to narrow and the guard is invalid." - } - ], - "group": "Type System", - "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", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep", - "narrowing" - ], - "summary": "TypeGuard/TypeIs return type incompatibility in callable arguments", - "summaryHtml": "TypeGuard/TypeIs return type incompatibility in callable arguments", - "body": [ - { - "type": "text", - "html": "When a function returning <code>TypeGuardX</code> or <code>TypeIsX</code> is passed as an argument where the expected callable return type is NOT <code>bool</code>, this rule reports the mismatch. <code>TypeGuard</code> and <code>TypeIs</code> are subtypes of <code>bool</code> in callable context, so passing them where <code>Callable..., bool</code> is expected is valid, but passing them where e.g. <code>Callable..., str</code> is expected is an error." - }, - { - "type": "code", - "lang": "python", - "code": "def takes_callable_str(f: Callable[[object], str]) -> None: ...\ndef simple_typeguard(val: object) -> TypeGuard[int]: ...\n\ntakes_callable_str(simple_typeguard) # E0112 \u2014 TypeGuard is bool, not str" - } - ], - "group": "Type System", - "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", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep", - "narrowing" - ], - "summary": "`TypeIs` narrows to a type inconsistent with the input type", - "summaryHtml": "<code>TypeIs</code> narrows to a type inconsistent with the input type", - "body": [ - { - "type": "text", - "html": "Per the typing spec: "It is an error to narrow to a type that is not consistent with the input type." For <code>TypeIs</code>, the narrowed type must be a subtype of the input type." - } - ], - "group": "Type System", - "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", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep", - "overloads" - ], - "summary": "No matching overload for subscript indexing", - "summaryHtml": "No matching overload for subscript indexing", - "body": [ - { - "type": "text", - "html": "When a class defines overloaded <code>__getitem__</code> methods and a module-level subscript expression (e.g. <code>b""</code>) passes an argument whose type is incompatible with all overload signatures, Basilisk reports the error." - }, - { - "type": "code", - "lang": "python", - "code": "from typing import overload\n\nclass Bytes:\n @overload\n def __getitem__(self, __i: int) -> int: ...\n @overload\n def __getitem__(self, __s: slice) -> bytes: ...\n def __getitem__(self, __i_or_s: int | slice) -> int | bytes: ...\n\nb = Bytes()\nb[\"\"] # E0072 -- no overload of __getitem__ accepts str" - } - ], - "group": "Type System", - "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", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep", - "overloads" - ], - "summary": "Overlapping `@overload` signatures", - "summaryHtml": "Overlapping <code>@overload</code> signatures", - "body": [ - { - "type": "text", - "html": "Within a group of <code>@overload</code> functions for the same name, every overload must be distinguishable. This rule uses a structural heuristic: two overloads are considered overlapping when they have the same parameter count AND identical parameter names in the same order." - }, - { - "type": "text", - "html": "A diagnostic is emitted for the <em>later</em> overload in each conflicting pair, pointing at its name span." - } - ], - "group": "Type System", - "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", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep", - "overloads" - ], - "summary": "Inconsistent decorators across an overloaded method", - "summaryHtml": "Inconsistent decorators across an overloaded method", - "body": [ - { - "type": "text", - "html": "The typing spec constrains how decorators may be spread across an <code>@overload</code> group and its implementation:" - }, - { - "type": "text", - "html": "<em> If any signature is <code>@staticmethod</code> / <code>@classmethod</code>, </em>all<em> signatures and the implementation must carry the same decorator. </em> <code>@final</code> and <code>@override</code> apply to the <em>implementation only</em> (or, in a stub, the first overload). Placing either on an <code>@overload</code> signature when an implementation is present is an error; in a stub (no implementation), placing either on any but the first overload is an error." - } - ], - "group": "Type System", - "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", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep", - "overloads" - ], - "summary": "Overload implementation is inconsistent with its signatures", - "summaryHtml": "Overload implementation is inconsistent with its signatures", - "body": [ - { - "type": "text", - "html": "When an overload implementation is present the spec requires: <em> the return type of every overload is assignable to the implementation's return type, and </em> the implementation's parameter types are assignable <em>from</em> every overload's parameter types (the implementation must accept them all)." - }, - { - "type": "text", - "html": "To remain false-positive free this only compares **known primitive types** (<code>int</code>/<code>str</code>/<code>bytes</code>/<code>float</code>/<code>bool</code>/<code>complex</code>/<code>object</code>/<code>None</code> and unions of them). Any <code>TypeVar</code>, generic (<code>listint</code>), <code>Callable</code>, or otherwise non-primitive annotation is skipped, since text-level assignability cannot be decided for it." - } - ], - "group": "Type System", - "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", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep", - "overloads" - ], - "summary": "Missing `@overload` implementation", - "summaryHtml": "Missing <code>@overload</code> implementation", - "body": [ - { - "type": "text", - "html": "When a function name is defined multiple times and every definition carries the <code>@overload</code> decorator, there is no concrete implementation body. Python's <code>typing.overload</code> protocol requires exactly one implementation function without <code>@overload</code>." - }, - { - "type": "text", - "html": "This rule fires once per overload group that lacks a plain implementation." - } - ], - "group": "Type System", - "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", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep", - "overloads" - ], - "summary": "Overload union expansion failure", - "summaryHtml": "Overload union expansion failure", - "body": [ - { - "type": "text", - "html": "When a function-body call passes a union-typed argument to an overloaded function and, after expanding the union, at least one member fails to match any overload signature, Basilisk reports the error." - }, - { - "type": "code", - "lang": "python", - "code": "@overload\ndef example(x: int, y: str, z: int) -> str: ...\n@overload\ndef example(x: int, y: int, z: int) -> int: ...\ndef example(x: int, y: int | str, z: int) -> int | str:\n return 1\n\ndef check(v: int | str) -> None:\n example(v, v, 1) # E -- str not assignable to int in any overload" - } - ], - "group": "Type System", - "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", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep", - "protocols" - ], - "summary": "Protocol class used where `type[Proto]` is expected", - "summaryHtml": "Protocol class used where <code>typeProto</code> is expected", - "body": [ - { - "type": "text", - "html": "The typing spec states: "Variables and parameters annotated with <code>TypeProto</code> accept only concrete (non-protocol) subtypes of Proto."" - }, - { - "type": "text", - "html": "Passing the Protocol class itself (rather than a concrete subtype) violates this constraint." - }, - { - "type": "code", - "lang": "python", - "code": "class Proto(Protocol):\n def meth(self) -> int: ...\n\nclass Concrete:\n def meth(self) -> int: return 42\n\ndef fun(cls: type[Proto]) -> int:\n return cls().meth()\n\nfun(Proto) # E0106 \u2014 Protocol class passed to type[Proto]\nfun(Concrete) # OK \u2014 concrete subtype\n\nvar: type[Proto]\nvar = Proto # E0106 \u2014 Protocol class assigned to type[Proto]\nvar = Concrete # OK" - } - ], - "group": "Type System", - "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", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep", - "protocols" - ], - "summary": "Protocol class object violations", - "summaryHtml": "Protocol class object violations", - "body": [ - { - "type": "text", - "html": "Detects two related violations involving Protocol classes and class objects:" - }, - { - "type": "text", - "html": "1. A Protocol class itself is passed/assigned where <code>typeProto</code> is expected. Only concrete (non-Protocol) subtypes may be used." - }, - { - "type": "text", - "html": "2. A class object is assigned to a variable typed as a Protocol instance, but the class does not structurally satisfy the protocol when treated as an object (i.e. class-level access to protocol members gives incompatible types)." - }, - { - "type": "code", - "lang": "python", - "code": "class Proto(Protocol):\n def meth(self) -> int: ...\n\nclass Concrete:\n def meth(self) -> int: return 42\n\ndef fun(cls: type[Proto]) -> int:\n return cls().meth()\n\nfun(Proto) # E0146 \u2014 Protocol class itself passed to type[Proto]\nfun(Concrete) # OK\n\nvar: type[Proto]\nvar = Proto # E0146 \u2014 Protocol class assigned to type[Proto]\nvar = Concrete # OK\n\npa1: ProtoA1 = ConcreteA # E0146 \u2014 class object can't satisfy instance protocol\npa2: ProtoA2 = ConcreteA # OK \u2014 protocol uses _self/self pattern" - } - ], - "group": "Type System", - "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", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep", - "protocols" - ], - "summary": "Protocol method sets self-attributes not declared in the Protocol", - "summaryHtml": "Protocol method sets self-attributes not declared in the Protocol", - "body": [ - { - "type": "text", - "html": "When a Protocol class defines a method (including <code>__init__</code>/<code>__new__</code>) that assigns to <code>self.attr</code> where <code>attr</code> is not a declared member of the Protocol, this is a violation: per the typing spec, "additional attributes only defined in the body of a method by assignment via self are not allowed". Protocol members must be explicitly declared at the class level." - }, - { - "type": "code", - "lang": "python", - "code": "from typing import Protocol\n\nclass MyProto(Protocol):\n x: int\n def __init__(self) -> None:\n self.y = 0 # E \u2014 `y` is not declared in the Protocol\n def method(self) -> None:\n self.z: int = 0 # E \u2014 `z` is not declared in the Protocol" - }, - { - "type": "text", - "html": "<code>@staticmethod</code>/<code>@classmethod</code> members have no instance receiver, so their first parameter is not <code>self</code> and is not analysed here." - } - ], - "group": "Type System", - "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", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep", - "protocols" - ], - "summary": "Protocol conformance violation in annotated assignment", - "summaryHtml": "Protocol conformance violation in annotated assignment", - "body": [ - { - "type": "text", - "html": "Detects errors in annotated assignments at module level:" - }, - { - "type": "text", - "html": "1. **Missing protocol members**: the annotation names a Protocol class and the RHS constructs a class that does not implement all required methods." - }, - { - "type": "text", - "html": "2. **Non-protocol structural assignment**: the annotation names a class that inherits from a Protocol but does <em>not</em> itself include <code>Protocol</code> in its bases (i.e. it is a concrete/abstract class, not a protocol). In this case structural subtyping does not apply and only nominal subclasses are allowed." - }, - { - "type": "text", - "html": "3. **Member-kind mismatch** (see conformance): a member is present but in an incompatible <em>form</em> \u2014 a read-write protocol property satisfied by a read-only/immutable member, or a writable protocol instance variable satisfied by a <code>ClassVar</code>, read-only property, or wrong-typed attribute." - }, - { - "type": "code", - "lang": "python", - "code": "class P(Protocol):\n def method(self) -> None: ...\n\nclass NotP(P): # Note: no Protocol \u2014 this is a concrete class\n def method(self) -> None: pass\n\nclass C:\n pass\n\nx: P = C() # E \u2014 C does not implement `method` (case 1)\ny: NotP = C() # E \u2014 NotP is not a Protocol, no structural subtyping (case 2)" - } - ], - "group": "Type System", - "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", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep", - "protocols" - ], - "summary": "Direct instantiation of a Protocol class", - "summaryHtml": "Direct instantiation of a Protocol class", - "body": [ - { - "type": "text", - "html": "Protocol classes define structural interfaces and cannot be instantiated directly. Only concrete classes that satisfy the protocol may be instantiated." - }, - { - "type": "code", - "lang": "python", - "code": "from typing import Protocol\n\nclass MyProto(Protocol):\n def method(self) -> int: ...\n\nobj = MyProto() # E \u2014 cannot instantiate a Protocol" - } - ], - "group": "Type System", - "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", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep", - "protocols" - ], - "summary": "Calling `super().method()` on an abstract method with no default implementation", - "summaryHtml": "Calling <code>super().method()</code> on an abstract method with no default implementation", - "body": [ - { - "type": "text", - "html": "When a Protocol (or ABC) declares a method as <code>@abstractmethod</code> with only an ellipsis (<code>...</code>) or <code>pass</code> body, calling <code>super().method()</code> from a subclass is invalid because there is no concrete implementation to delegate to." - }, - { - "type": "code", - "lang": "python", - "code": "from typing import Protocol\nfrom abc import abstractmethod\n\nclass PColor(Protocol):\n @abstractmethod\n def draw(self) -> str:\n ...\n\nclass BadColor(PColor):\n def draw(self) -> str:\n return super().draw() # E \u2014 no default implementation" - } - ], - "group": "Type System", - "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", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep", - "protocols" - ], - "summary": "`super()` call on abstract protocol method with no default implementation", - "summaryHtml": "<code>super()</code> call on abstract protocol method with no default implementation", - "body": [ - { - "type": "text", - "html": "When a class explicitly implements a <code>Protocol</code> and one of its methods calls <code>super().method_name()</code>, the parent protocol method must provide a default implementation. If the parent method is abstract (its body is only <code>...</code> or <code>pass</code>), calling <code>super()</code> on it is an error because there is no concrete implementation to dispatch to." - }, - { - "type": "code", - "lang": "python", - "code": "from typing import Protocol\nfrom abc import abstractmethod\n\nclass PColor(Protocol):\n @abstractmethod\n def draw(self) -> str:\n ...\n\nclass BadColor(PColor):\n def draw(self) -> str:\n return super().draw() # E \u2014 no default implementation" - } - ], - "group": "Type System", - "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", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep", - "protocols" - ], - "summary": "Generic protocol violations", - "summaryHtml": "Generic protocol violations", - "body": [ - { - "type": "text", - "html": "Detects violations related to generic protocol usage:" - }, - { - "type": "text", - "html": "1. **ProtocolT combined with GenericT**: The <code>ProtocolT, S, ...</code> shorthand is already equivalent to <code>Protocol, GenericT, S, ...</code>. It is an error to combine the shorthand with an explicit <code>Generic...</code> base." - }, - { - "type": "text", - "html": "2. **Incompatible generic protocol assignment**: When a module-level variable is annotated with a concrete generic protocol specialisation like <code>Protoint, str</code> and the RHS is a concrete class, the concrete class's method signatures must be compatible with the substituted type arguments." - }, - { - "type": "text", - "html": "3. **Self-typed protocol method incompatibility**: When a protocol declares methods using a <code>self: T</code> annotation (making the return type depend on the concrete receiver), concrete classes that implement those methods with incompatible signatures are flagged." - }, - { - "type": "code", - "lang": "python", - "code": "from typing import Generic, Protocol, TypeVar\n\nT_co = TypeVar(\"T_co\", covariant=True)\n\nclass Proto2(Protocol[T_co], Generic[T_co]): # E \u2014 shorthand + Generic\n ..." - }, - { - "type": "text", - "html": "<a href=\"https://peps.python.org/pep-0544/\">PEP 544</a>: <https://typing.readthedocs.io/en/latest/spec/protocol.html#generic-protocols>" - } - ], - "group": "Type System", - "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", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep", - "protocols" - ], - "summary": "Non-Protocol base class in a Protocol definition", - "summaryHtml": "Non-Protocol base class in a Protocol definition", - "body": [ - { - "type": "text", - "html": "Per <a href=\"https://peps.python.org/pep-0544/\">PEP 544</a>, a Protocol class may only inherit from other Protocol classes (with the exception of <code>object</code>). Inheriting from a non-Protocol concrete class is a violation." - }, - { - "type": "code", - "lang": "python", - "code": "from typing import Protocol\n\nclass Base:\n x: int = 0\n\nclass BadProto(Base, Protocol): # E \u2014 Base is not a Protocol\n def method(self) -> int: ..." - } - ], - "group": "Type System", - "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", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep", - "protocols" - ], - "summary": "Module assigned to incompatible protocol type", - "summaryHtml": "Module assigned to incompatible protocol type", - "body": [ - { - "type": "text", - "html": "When a module object is assigned to a variable typed as a <code>Protocol</code>, the module's public interface must be compatible with the protocol. This rule detects assignments of the form:" - }, - { - "type": "code", - "lang": "python", - "code": "import some_module\n\nclass MyProtocol(Protocol):\n timeout: str\n\nx: MyProtocol = some_module # E \u2014 some_module.timeout is int, not str" - }, - { - "type": "text", - "html": "This is a simplified check: if the annotation names a class that inherits from <code>Protocol</code> and the RHS is a module name, the assignment is flagged when the module is known to be incompatible." - }, - { - "type": "text", - "html": "Specification: <https://typing.readthedocs.io/en/latest/spec/protocol.html#modules-as-implementations-of-protocols>" - } - ], - "group": "Type System", - "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", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep", - "protocols" - ], - "summary": "Protocol `isinstance`/`issubclass` violations", - "summaryHtml": "Protocol <code>isinstance</code>/<code>issubclass</code> violations", - "body": [ - { - "type": "text", - "html": "Per <a href=\"https://peps.python.org/pep-0544/\">PEP 544</a>: - A protocol can be used as the second argument to <code>isinstance()</code> or <code>issubclass()</code> **only** if it is decorated with <code>@runtime_checkable</code>. - <code>issubclass()</code> can only be used with **non-data** protocols (protocols that define only methods, not data attributes)." - }, - { - "type": "code", - "lang": "python", - "code": "from typing import Protocol, runtime_checkable\n\nclass Proto1(Protocol):\n name: str\n\n@runtime_checkable\nclass Proto2(Protocol):\n name: str\n def method(self) -> int: ...\n\nisinstance(x, Proto1) # E \u2014 not @runtime_checkable\nissubclass(x, Proto2) # E \u2014 data protocol in issubclass\nissubclass(x, (Proto2, Proto1)) # E \u2014 tuple contains violating protocol" - } - ], - "group": "Type System", - "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", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep", - "protocols" - ], - "summary": "Protocol `isinstance`/`issubclass` violations", - "summaryHtml": "Protocol <code>isinstance</code>/<code>issubclass</code> violations", - "body": [ - { - "type": "text", - "html": "Per <a href=\"https://peps.python.org/pep-0544/\">PEP 544</a>: - A protocol can be used as the second argument to <code>isinstance()</code> or <code>issubclass()</code> **only** if it is decorated with <code>@runtime_checkable</code>. - <code>issubclass()</code> can only be used with **non-data** protocols (protocols that define only methods, not data attributes). - Type checkers should reject an <code>isinstance()</code> or <code>issubclass()</code> call if there is an unsafe overlap between the type of the first argument and the protocol." - }, - { - "type": "code", - "lang": "python", - "code": "from typing import Protocol, runtime_checkable\n\nclass Proto1(Protocol):\n name: str\n\n@runtime_checkable\nclass Proto2(Protocol):\n name: str\n def method(self) -> int: ...\n\nisinstance(x, Proto1) # E \u2014 not @runtime_checkable\nissubclass(x, Proto2) # E \u2014 data protocol in issubclass\nisinstance(Concrete(), Proto3) # E \u2014 unsafe overlap" - } - ], - "group": "Type System", - "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", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep", - "protocols" - ], - "summary": "Protocol attribute tuple element type mismatch", - "summaryHtml": "Protocol attribute tuple element type mismatch", - "body": [ - { - "type": "text", - "html": "When a class explicitly implements a <code>Protocol</code> and assigns to a <code>self.attr</code> in <code>__init__</code> where <code>attr</code> is declared as <code>tupleT1, T2, ...</code> in the protocol, each element of the assigned tuple must have a compatible type. If a parameter used in the tuple has a different type than the corresponding element type in the protocol's annotation, Basilisk reports the mismatch." - }, - { - "type": "code", - "lang": "python", - "code": "from typing import Protocol\n\nclass RGB(Protocol):\n rgb: tuple[int, int, int]\n\nclass Point(RGB):\n def __init__(self, red: int, green: int, blue: str) -> None:\n self.rgb = red, green, blue # E \u2014 'blue' must be 'int'" - } - ], - "group": "Type System", - "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", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep", - "protocols" - ], - "summary": "Protocol variance violation", - "summaryHtml": "Protocol variance violation", - "body": [ - { - "type": "text", - "html": "Detects when a Protocol class declares <code>TypeVar</code>s with incorrect variance based on how they are used in method signatures:" - }, - { - "type": "text", - "html": "- A <code>TypeVar</code> used only in output positions (return types) should be covariant. - A <code>TypeVar</code> used only in input positions (parameters) should be contravariant. - A covariant <code>TypeVar</code> used in input position is a violation. - A contravariant <code>TypeVar</code> used in output position is a violation." - }, - { - "type": "text", - "html": "<code>__init__</code> and <code>__new__</code> methods are exempt from variance inference." - } - ], - "group": "Type System", - "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", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep", - "protocols" - ], - "summary": "Protocol `TypeVar` variance mismatch", - "summaryHtml": "Protocol <code>TypeVar</code> variance mismatch", - "body": [ - { - "type": "text", - "html": "When a generic protocol class declares a <code>TypeVar</code> as invariant but the inferred variance (from method parameter and return positions) is strictly covariant or contravariant, a diagnostic is emitted recommending the more specific variance." - }, - { - "type": "text", - "html": "<a href=\"https://peps.python.org/pep-0544/\">PEP 544</a> 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", - "lang": "python", - "code": "from typing import Protocol, TypeVar\n\nT = TypeVar(\"T\") # invariant\n\nclass MyProto(Protocol[T]): # E \u2014 T should be covariant\n def method(self) -> T: ..." - } - ], - "group": "Type System", - "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", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep", - "qualifiers" - ], - "summary": "Invalid first argument to `Annotated[...]`", - "summaryHtml": "Invalid first argument to <code>Annotated...</code>", - "body": [ - { - "type": "text", - "html": "<a href=\"https://peps.python.org/pep-0593/\">PEP 593</a> requires that the first argument to <code>Annotated...</code> be a valid type expression. The following are errors:" - }, - { - "type": "text", - "html": "- List literals: <code>Annotated[int, str, ""]</code> - Tuple literals: <code>Annotated((int, str),), ""</code> - Dict literals: <code>Annotated{"a": "b"}, ""</code> - List comprehensions: <code>Annotated[x for x in ..., ""]</code> - Lambda calls: <code>Annotated(lambda: int)(), ""</code> - Conditional expressions: <code>Annotatedint if cond else str, ""</code> - Boolean literals: <code>AnnotatedTrue, ""</code> - Integer literals: <code>Annotated1, ""</code> - Binary boolean operators: <code>Annotatedlist or set, ""</code> - F-strings: <code>Annotatedf"...", ""</code> - Subscript-into-subscript: <code>Annotated[int0, ""]</code>" - }, - { - "type": "text", - "html": "Additionally, <code>Annotatedint</code> with fewer than 2 arguments is an error, and calling <code>Annotated</code> directly (bare or parameterized) is always invalid." - }, - { - "type": "code", - "lang": "python", - "code": "Bad1: Annotated[[int, str], \"\"] # E \u2014 list literal not valid type\nBad9: Annotated[True, \"\"] # E \u2014 bool literal not valid type\nBad13: Annotated[int] # E \u2014 requires at least two arguments\nAnnotated() # E \u2014 Annotated is not callable\nSmallInt(1) # E \u2014 TypeAlias is not callable" - } - ], - "group": "Type System", - "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", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep", - "qualifiers" - ], - "summary": "`Annotated[...]` requires at least two arguments", - "summaryHtml": "<code>Annotated...</code> requires at least two arguments", - "body": [ - { - "type": "text", - "html": "<a href=\"https://peps.python.org/pep-0593/\">PEP 593</a> requires <code>Annotated</code> to be subscripted with at least two arguments: a type and one or more metadata values. <code>Annotatedint</code> with only a single argument is a type error." - }, - { - "type": "code", - "lang": "python", - "code": "from typing import Annotated\nbad: Annotated[int] # E \u2014 only one argument" - } - ], - "group": "Type System", - "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", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep", - "qualifiers" - ], - "summary": "`Final` used in an invalid position", - "summaryHtml": "<code>Final</code> used in an invalid position", - "body": [ - { - "type": "text", - "html": "<a href=\"https://peps.python.org/pep-0591/\">PEP 591</a> restricts <code>FinalT</code> to:" - }, - { - "type": "text", - "html": "- Module-level variable annotations (<code>x: Finalint = 1</code>) - Class body attribute annotations (<code>VALUE: Finalint = 1</code>) - Instance attribute annotations in <code>__init__</code> (<code>self.x: Finalint = 1</code>)" - }, - { - "type": "text", - "html": "The following are all errors:" - }, - { - "type": "text", - "html": "1. <code>Final</code> used in a function parameter annotation 2. <code>Final</code> nested inside another type constructor (e.g. <code>listFinal[int]</code>) 3. <code>FinalClassVar[...]</code> or <code>ClassVarFinal[...]</code> \u2014 mutually exclusive 4. <code>FinalT1, T2</code> \u2014 more than one type argument 5. Bare <code>Final</code> (no type arg, no initializer) at module level" - }, - { - "type": "code", - "lang": "python", - "code": "x: list[Final[int]] = [] # E \u2014 Final nested in list\ndef f(x: Final[int]): ... # E \u2014 Final in param\nVALUE2: ClassVar[Final] = 1 # E \u2014 Final with ClassVar\nBAD1: Final # E \u2014 bare Final, no assignment\nBAD2: Final[str, int] = \"\" # E \u2014 too many type args" - } - ], - "group": "Type System", - "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", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep", - "qualifiers" - ], - "summary": "`Final` type qualifier annotation violations", - "summaryHtml": "<code>Final</code> type qualifier annotation violations", - "body": [ - { - "type": "text", - "html": "Detects violations of <a href=\"https://peps.python.org/pep-0591/\">PEP 591</a>'s rules for the <code>Final</code> qualifier, beyond the positional errors handled by E0044. Specifically:" - }, - { - "type": "text", - "html": "1. **Class attribute <code>Final</code> without init** \u2014 <code>ID2: Final</code> / <code>ID3: Finalint</code> in a class body without an initializer and not assigned in <code>__init__</code>." - }, - { - "type": "text", - "html": "2. **Instance <code>Final</code> outside <code>__init__</code>** \u2014 <code>self.id3: Final = 1</code> in a method other than <code>__init__</code>." - }, - { - "type": "text", - "html": "3. **Re-assignment to already-initialized Final** \u2014 <code>self.ID5 = 0</code> when <code>ID5: Finalint = 0</code> is already given a value in the class body." - }, - { - "type": "text", - "html": "4. **Modification of Final class attribute** \u2014 <code>self.ID7 = 0</code> / <code>self.ID7 += 1</code> when <code>ID7</code> is declared <code>Final</code> in the class body." - }, - { - "type": "text", - "html": "5. **Module-level Final re-assignment** \u2014 <code>RATE = 300</code> after <code>RATE: Final = 3000</code>." - }, - { - "type": "text", - "html": "6. **Class attribute re-assignment** \u2014 <code>ClassB.DEFAULT_ID = 0</code> when <code>DEFAULT_ID</code> is declared <code>Final</code> in <code>ClassB</code>." - }, - { - "type": "text", - "html": "7. **Subclass override of Final** \u2014 <code>BORDER_WIDTH = 2.5</code> in a subclass when the parent declares <code>BORDER_WIDTH: Final = 2.5</code>." - }, - { - "type": "text", - "html": "8. **Function-local Final modification** \u2014 <code>x += 1</code> when <code>x: Final = 3</code>, or walrus/for/with/tuple-unpack on a <code>Final</code> variable." - }, - { - "type": "text", - "html": "9. **Global Final modification** \u2014 <code>global ID1; ID1 = 2</code> inside a function when <code>ID1</code> is a module-level <code>Final</code>." - } - ], - "group": "Type System", - "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", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep", - "qualifiers" - ], - "summary": "`@final` decorator violations", - "summaryHtml": "<code>@final</code> decorator violations", - "body": [ - { - "type": "text", - "html": "Three violations are detected:" - }, - { - "type": "text", - "html": "1. **Inheriting from a <code>@final</code> class** \u2014 a class decorated with <code>@final</code> cannot be subclassed." - }, - { - "type": "text", - "html": "2. **<code>@final</code> on a non-method function** \u2014 <code>@final</code> is only valid on methods defined inside a class body, not on module-level functions." - }, - { - "type": "text", - "html": "3. **Overriding a <code>@final</code> method** \u2014 a method decorated with <code>@final</code> in a base class cannot be overridden in a subclass." - } - ], - "group": "Type System", - "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", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep" - ], - "summary": "Return type mismatch", - "summaryHtml": "Return type mismatch", - "body": [ - { - "type": "text", - "html": "Emitted as an <code>Error</code> when the literal value returned by a function is clearly incompatible with the declared return type annotation (e.g. returning an <code>int</code> literal from a <code>-> str</code> function)." - }, - { - "type": "code", - "lang": "python", - "code": "# BAD (return type mismatch)\ndef count() -> str:\n return 42 # E: int literal is not assignable to str\n\n# GOOD\ndef count() -> int:\n return 42" - } - ], - "group": "Type System", - "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", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep" - ], - "summary": "Return type mismatch \u2014 inferred return type incompatible with annotation", - "summaryHtml": "Return type mismatch \u2014 inferred return type incompatible with annotation", - "body": [ - { - "type": "text", - "html": "When a function has a return type annotation, the inferred return type must be assignable to the declared type. This extends the original <code>-> None</code> check to handle all return type mismatches using the inference system." - } - ], - "group": "Type System", - "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", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep", - "specialtypes" - ], - "summary": "`-> NoReturn` / `-> Never` function can fall through", - "summaryHtml": "<code>-> NoReturn</code> / <code>-> Never</code> function can fall through", - "body": [ - { - "type": "text", - "html": "A function declared with a return type of <code>NoReturn</code> or <code>Never</code> must unconditionally raise an exception or call another <code>NoReturn</code> function on every code path. If the function can reach the end of its body without raising (e.g. via an <code>if</code> without an <code>else</code>), the annotation is wrong." - }, - { - "type": "code", - "lang": "python", - "code": "import sys\nfrom typing import NoReturn\n\ndef stop() -> NoReturn: # OK \u2014 always raises\n raise RuntimeError(\"no way\")\n\ndef bad(x: int) -> NoReturn: # E \u2014 can fall through when x == 0\n if x != 0:\n sys.exit(1)" - }, - { - "type": "text", - "html": "## Conservative scope" - }, - { - "type": "text", - "html": "The check is conservative: it only flags a function when **all** of the following hold:" - }, - { - "type": "text", - "html": "1. The declared return type is exactly <code>NoReturn</code> or <code>Never</code> (checked by extracting the annotation text from the span). 2. The function body is not a stub (<code>...</code> or <code>pass</code>). 3. The last top-level statement is **not** a <code>raise</code> statement and is **not** a standalone call expression (which may itself be <code>NoReturn</code>)." - }, - { - "type": "text", - "html": "This avoids false positives for valid patterns such as <code>raise RuntimeError(...)</code> or <code>sys.exit(1)</code> as the terminating statement." - } - ], - "group": "Type System", - "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", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep", - "specialtypes" - ], - "summary": "`Never` type compatibility violations", - "summaryHtml": "<code>Never</code> type compatibility violations", - "body": [ - { - "type": "text", - "html": "Detects type compatibility errors involving the <code>Never</code> bottom type:" - }, - { - "type": "text", - "html": "1. Assigning a parameter typed <code>ContainerNever</code> to a local annotated <code>ContainerT</code> where <code>T</code> is not <code>Never</code> or <code>Any</code> (invariant violation) 2. Returning <code>ClassCNever()</code> from a function annotated <code>-> ClassCU</code> where the class's type parameter is invariant (not covariant)" - }, - { - "type": "code", - "lang": "python", - "code": "from typing import Never, Any, Generic, TypeVar\n\nT = TypeVar(\"T\")\nU = TypeVar(\"U\")\n\ndef func(c: list[Never]):\n v: list[int] = c # E0070 \u2014 list is invariant, list[Never] != list[int]\n\nclass ClassC(Generic[T]):\n pass\n\ndef func2(x: U) -> ClassC[U]:\n return ClassC[Never]() # E0070 \u2014 ClassC is invariant" - } - ], - "group": "Type System", - "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", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep", - "specialtypes" - ], - "summary": "Access to an `int`-only attribute on a `float`-typed parameter", - "summaryHtml": "Access to an <code>int</code>-only attribute on a <code>float</code>-typed parameter", - "body": [ - { - "type": "text", - "html": "The Python typing spec (<a href=\"https://peps.python.org/pep-0484/\">PEP 484</a> / typing spec \u00a7Special cases for float and complex) states that <code>int</code> is not a subtype of <code>float</code> for static type-checking purposes. Attributes such as <code>numerator</code> and <code>denominator</code> are defined on <code>int</code> but NOT on <code>float</code>. Accessing them on a parameter declared as <code>float</code> is therefore a static type error." - }, - { - "type": "text", - "html": "The check is deliberately conservative \u2014 it only fires on **top-level** statements inside a function body, skipping any access inside an <code>if</code>/<code>for</code>/<code>while</code>/<code>match</code>/ <code>with</code>/<code>try</code> block. This means that accesses protected by an <code>isinstance</code> guard (where the parameter has been narrowed to <code>int</code>) are never flagged." - }, - { - "type": "code", - "lang": "python", - "code": "def func1(f: float):\n f.numerator # E \u2014 float does not have .numerator\n\n if not isinstance(f, float):\n f.numerator # OK \u2014 narrowed to int inside the branch" - } - ], - "group": "Type System", - "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", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep", - "specialtypes" - ], - "summary": "Invalid `type[X]` usage violations", - "summaryHtml": "Invalid <code>typeX</code> usage violations", - "body": [ - { - "type": "text", - "html": "Detects several categories of invalid use of <code>typeX</code> (or <code>TypeX</code>):" - }, - { - "type": "text", - "html": "1. **Callable passed as <code>typeT</code> argument** \u2014 <code>Callable</code> and other special forms are not valid class objects and cannot be passed where <code>typeT</code> is expected." - }, - { - "type": "text", - "html": "2. **Incompatible class passed to <code>typeA | B</code>** \u2014 when a function expects <code>typeA | B</code>, passing a class that is neither <code>A</code> nor <code>B</code> is an error." - }, - { - "type": "text", - "html": "3. **Unknown attribute access on <code>typeobject</code>** \u2014 unlike <code>typeAny</code>, <code>typeobject</code> only exposes <code>object</code>'s own attributes; accessing any other member is an error." - }, - { - "type": "text", - "html": "4. **Unknown attribute access on a <code>TypeAlias</code> bound to <code>type</code> / <code>Type</code>** \u2014 a bare alias such as <code>TA1: TypeAlias = Type</code> resolves to <code>typeAny</code>, but the alias <em>name itself</em> (used at module scope like <code>TA1.unknown</code>) does not expose arbitrary attributes." - } - ], - "group": "Type System", - "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", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep", - "tuples" - ], - "summary": "Tuple index out of bounds", - "summaryHtml": "Tuple index out of bounds", - "body": [ - { - "type": "text", - "html": "When a fixed-length <code>tupleT1, T2, ...</code> variable is indexed with a literal integer or a <code>LiteralN</code>-typed variable that is outside the valid range <code>[-len, len)</code>, this is a static error." - }, - { - "type": "code", - "lang": "python", - "code": "v: tuple[int, str, list[bool]] = (3, \"hi\", [True])\nv[4] # E0103 \u2014 index 4 out of range for 3-element tuple\nv[-4] # E0103 \u2014 index -4 out of range for 3-element tuple" - }, - { - "type": "text", - "html": "The parameter of a <code>key=</code> lambda passed to <code>sorted</code>/<code>min</code>/<code>max</code>/<code>list.sort</code> receives one element of the iterable, so when the iterable is provably a collection of fixed-length tuples \u2014 from its annotation or from a literal of uniform tuples \u2014 the same range check applies inside the lambda:" - }, - { - "type": "code", - "lang": "python", - "code": "items = [(\"a\", 1, 2), (\"b\", 3, 4)]\nsorted(items, key=lambda pair: pair[4]) # E \u2014 4 out of range for 3-tuple" - } - ], - "group": "Type System", - "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", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep", - "tuples" - ], - "summary": "Tuple index out of range", - "summaryHtml": "Tuple index out of range", - "body": [ - { - "type": "text", - "html": "Detects subscript access on a fixed-length <code>tupleT1, T2, ...</code> parameter where the index is a known integer literal (either an inline <code>int</code> literal or a parameter typed as <code>LiteralN</code>) that falls outside the valid range <code>-len, len-1</code>." - }, - { - "type": "code", - "lang": "python", - "code": "def f(v: tuple[int, str, list[bool]], b: Literal[5]):\n v[b] # E \u2014 index 5 out of range for 3-element tuple\n v[4] # E \u2014 index 4 out of range\n v[-4] # E \u2014 index -4 out of range (valid: -3..-1)" - } - ], - "group": "Type System", - "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", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep", - "tuples" - ], - "summary": "Tuple starred-unpack type compatibility violation", - "summaryHtml": "Tuple starred-unpack type compatibility violation", - "body": [ - { - "type": "text", - "html": "Detects assignments where a tuple literal or a tuple-typed variable is assigned to a target whose annotation contains a starred unpack expression (<code><em>tupleT, ...</code> or <code></em>tupleT</code>) and the assignment is incompatible with that annotation." - }, - { - "type": "text", - "html": "Covers module-level bare reassignments of annotated tuple variables and function-body variable assignments." - }, - { - "type": "text", - "html": "## Examples" - }, - { - "type": "code", - "lang": "python", - "code": "t1: tuple[int, *tuple[str]] = (1, \"\") # OK\nt1 = (1, \"\", \"\") # E \u2014 too many elements for *tuple[str]\n\nt2: tuple[int, *tuple[str, ...]] = (1, \"\") # OK\nt2 = (1, 1, \"\") # E \u2014 second element must be str\n\ndef f(t1: tuple[int], t2: tuple[int, *tuple[int, ...]], t3: tuple[int, ...]):\n v2: tuple[int, *tuple[int, ...]]\n v2 = t3 # E \u2014 homogeneous tuple[int,...] not assignable to mixed starred form\n v3: tuple[int]\n v3 = t2 # E \u2014 t2 may have more elements than v3 allows\n v3 = t3 # E \u2014 t3 is unbounded, v3 is fixed length 1" - }, - { - "type": "text", - "html": "# Specification" - }, - { - "type": "text", - "html": "<https://typing.readthedocs.io/en/latest/spec/tuples.html#type-compatibility-rules>" - } - ], - "group": "Type System", - "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", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep", - "tuples" - ], - "summary": "Multiple unbounded tuple components in a single tuple type", - "summaryHtml": "Multiple unbounded tuple components in a single tuple type", - "body": [ - { - "type": "text", - "html": "A <code>tuple...</code> type annotation may contain at most one unbounded component. An unbounded component is: - <code><em>tupleT, ...</code> \u2014 a starred subscript where the inner tuple is variadic - <code></em>Ts</code> / <code>*<Name></code> \u2014 a starred <code>TypeVarTuple</code> unpack - <code>Unpacktuple[T, ...]</code> \u2014 the legacy unpack form" - }, - { - "type": "text", - "html": "For example, <code>tuple<em>tuple[str, ..., </em>tupleint, ...]</code> is invalid because it has two unbounded components." - }, - { - "type": "code", - "lang": "python", - "code": "t: tuple[*tuple[str, ...], *tuple[int, ...]] # E \u2014 two unbounded components\nt: tuple[*tuple[str, ...], *Ts] # E \u2014 two unbounded components\nt: tuple[*tuple[str, ...], str] # OK \u2014 only one unbounded" - } - ], - "group": "Type System", - "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", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep", - "tuples" - ], - "summary": "Invalid tuple type syntax", - "summaryHtml": "Invalid tuple type syntax", - "body": [ - { - "type": "text", - "html": "Validates tuple type annotations according to <a href=\"https://peps.python.org/pep-0646/\">PEP 646</a> rules:" - }, - { - "type": "text", - "html": "- <code>tupleT, ...</code> must have exactly one type before <code>...</code> - <code>tuple...</code> is invalid (must specify a type) - <code>tupleT, ..., U</code> is invalid (<code>...</code> can only appear at the end) - <code>tupleT, U, ...</code> is invalid (can't have multiple fixed types before <code>...</code>) - Invalid unpack patterns like <code>tuple*tuple[str, ...]</code>" - }, - { - "type": "code", - "lang": "python", - "code": "t1: tuple[int, ...] # OK\nt2: tuple[int, int, ...] # E \u2014 multiple fixed types before ...\nt3: tuple[...] # E \u2014 missing type before ...\nt4: tuple[..., int] # E \u2014 ... must be at the end\nt5: tuple[int, ..., int] # E \u2014 ... must be at the end\nt6: tuple[*tuple[str], ...] # E \u2014 invalid unpack pattern" - } - ], - "group": "Type System", - "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", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep", - "typeddicts" - ], - "summary": "Invalid `TypedDict(...)` functional-syntax call", - "summaryHtml": "Invalid <code>TypedDict(...)</code> functional-syntax call", - "body": [ - { - "type": "text", - "html": "The <code>TypedDict(name, {...})</code> functional syntax has several constraints:" - }, - { - "type": "text", - "html": "1. The second positional argument must be a dict literal <code>{...}</code>. 2. All keys in the dict literal must be string literals. 3. The first positional argument (the declared name) must match the variable name on the left-hand side of the assignment. 4. Only <code>total=</code> is recognised as a keyword argument; anything else is an error." - } - ], - "group": "Type System", - "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", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep", - "typeddicts" - ], - "summary": "Method defined inside a `TypedDict` class", - "summaryHtml": "Method defined inside a <code>TypedDict</code> class", - "body": [ - { - "type": "text", - "html": "<code>TypedDict</code> classes (<a href=\"https://peps.python.org/pep-0589/\">PEP 589</a>) are restricted to key declarations only. Defining methods (other than <code>__init__</code> which is synthesised) is an error." - } - ], - "group": "Type System", - "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", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep", - "typeddicts" - ], - "summary": "Invalid keyword argument in `TypedDict` class definition", - "summaryHtml": "Invalid keyword argument in <code>TypedDict</code> class definition", - "body": [ - { - "type": "text", - "html": "<code>TypedDict</code> class syntax only accepts <code>total=True/False</code> as a keyword argument. Using <code>metaclass=</code> or any unrecognised keyword is an error per <a href=\"https://peps.python.org/pep-0589/\">PEP 589</a>." - }, - { - "type": "text", - "html": "Also fires when a <code>TypedDict</code> inherits from a non-<code>TypedDict</code> class (other than <code>Generic...</code>), which is forbidden." - } - ], - "group": "Type System", - "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", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep", - "typeddicts" - ], - "summary": "`TypedDict` `extra_items` / `closed` (PEP 728) violations", - "summaryHtml": "<code>TypedDict</code> <code>extra_items</code> / <code>closed</code> (<a href=\"https://peps.python.org/pep-0728/\">PEP 728</a>) violations", - "body": [ - { - "type": "text", - "html": "Validates class-definition legality, dict-literal construction, assignability between <code>TypedDict</code>s, and constructor calls against the <a href=\"https://peps.python.org/pep-0728/\">PEP 728</a> 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", - "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", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep", - "typeddicts" - ], - "summary": "Invalid `TypedDict` inheritance", - "summaryHtml": "Invalid <code>TypedDict</code> inheritance", - "body": [ - { - "type": "text", - "html": "<a href=\"https://peps.python.org/pep-0589/\">PEP 589</a> and the typing spec place constraints on <code>TypedDict</code> inheritance:" - }, - { - "type": "text", - "html": "1. A <code>TypedDict</code> cannot inherit from both a <code>TypedDict</code> and a non-TypedDict base class (except <code>Generic</code>)." - }, - { - "type": "text", - "html": "2. A <code>TypedDict</code> subclass cannot change the type of a field declared in a parent <code>TypedDict</code> class. <a href=\"https://peps.python.org/pep-0705/\">PEP 705</a> refines this for the <code>ReadOnly</code>, <code>Required</code>, and <code>NotRequired</code> qualifiers: - A writable (non-<code>ReadOnly</code>) item may not be redeclared <code>ReadOnly</code>. - A required item may not be redeclared as not-required. - A writable item's value type is invariant; a <code>ReadOnly</code> item's value type may be narrowed to a subtype." - }, - { - "type": "text", - "html": "3. Multiple <code>TypedDict</code> inheritance is not allowed when two bases declare the same field with conflicting types or qualifiers." - } - ], - "group": "Type System", - "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", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep", - "typeddicts" - ], - "summary": "Invalid key or value type in `TypedDict` assignment", - "summaryHtml": "Invalid key or value type in <code>TypedDict</code> assignment", - "body": [ - { - "type": "text", - "html": "<a href=\"https://peps.python.org/pep-0589/\">PEP 589</a> defines <code>TypedDict</code> as a typed dict with a fixed set of keys and associated types. This rule detects:" - }, - { - "type": "text", - "html": "1. Subscript assignments with invalid (non-existent) keys. 2. Subscript assignments where the value type is incompatible with the declared field type. 3. Annotated dict-literal assignments that contain invalid keys or are missing required keys." - }, - { - "type": "code", - "lang": "python", - "code": "from typing import TypedDict\n\nclass Movie(TypedDict):\n name: str\n year: int\n\nmovie: Movie = {\"name\": \"Blade Runner\", \"year\": 1982}\n\nmovie[\"director\"] = \"Ridley Scott\" # E: invalid key\nmovie[\"year\"] = \"1982\" # E: wrong value type\nmovie2: Movie = {\"title\": \"Blade Runner\", \"year\": 1982} # E: invalid/missing keys" - } - ], - "group": "Type System", - "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", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep", - "typeddicts" - ], - "summary": "Mutation of `ReadOnly` `TypedDict` fields", - "summaryHtml": "Mutation of <code>ReadOnly</code> <code>TypedDict</code> fields", - "body": [ - { - "type": "text", - "html": "Fields marked as <code>ReadOnly</code> in <code>TypedDict</code>s cannot be mutated through: - Direct assignment: <code>td"key" = value</code> - <code>.update()</code> calls" - }, - { - "type": "code", - "lang": "python", - "code": "from typing import TypedDict\nfrom typing_extensions import ReadOnly\n\nclass Config(TypedDict):\n name: str\n version: ReadOnly[str]\n\ncfg: Config = {\"name\": \"test\", \"version\": \"1.0\"}\ncfg[\"version\"] = \"2.0\" # E0056\ncfg.update(version=\"2.0\") # E0056" - } - ], - "group": "Type System", - "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", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep", - "typeddicts" - ], - "summary": "`Required` / `NotRequired` used in an invalid context", - "summaryHtml": "<code>Required</code> / <code>NotRequired</code> used in an invalid context", - "body": [ - { - "type": "text", - "html": "<a href=\"https://peps.python.org/pep-0655/\">PEP 655</a> and the typing spec restrict <code>RequiredT</code> and <code>NotRequiredT</code> to:" - }, - { - "type": "text", - "html": "- Annotations of <code>TypedDict</code> fields" - }, - { - "type": "text", - "html": "Using them outside of a <code>TypedDict</code> body (in regular classes, function parameters, variable annotations, etc.) is an error." - }, - { - "type": "text", - "html": "Additionally, nesting <code>Required</code> or <code>NotRequired</code> inside each other is forbidden even within a <code>TypedDict</code>." - }, - { - "type": "code", - "lang": "python", - "code": "class NotTypedDict:\n x: Required[int] # E0035 \u2014 not a TypedDict\n\ndef func(x: NotRequired[int]) -> None: # E0035 \u2014 not a TypedDict field\n ...\n\nclass TD(TypedDict):\n a: Required[Required[int]] # E0035 \u2014 nested Required" - } - ], - "group": "Type System", - "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", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep", - "typeddicts" - ], - "summary": "`TypedDict` runtime violation", - "summaryHtml": "<code>TypedDict</code> runtime violation", - "body": [ - { - "type": "text", - "html": "<a href=\"https://peps.python.org/pep-0589/\">PEP 589</a> defines constraints on what you can do with <code>TypedDict</code> type objects at runtime:" - }, - { - "type": "text", - "html": "- <code>TypedDict</code> type objects cannot be used in <code>isinstance()</code> tests." - }, - { - "type": "code", - "lang": "python", - "code": "from typing import TypedDict\n\nclass Movie(TypedDict):\n name: str\n year: int\n\nmovie: Movie = {\"name\": \"Blade Runner\", \"year\": 1982}\n\nif isinstance(movie, Movie): # E \u2014 TypedDict cannot be used in isinstance\n ..." - } - ], - "group": "Type System", - "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": "typeshed_source_license_changed", - "scope": "analyze", - "provenance": "basilisk", - "tags": [ - "basilisk", - "stubs" - ], - "summary": "The bundled typeshed's approved LICENSE/NOTICE changed and activation was blocked pending review", - "summaryHtml": "The bundled typeshed's approved LICENSE/NOTICE changed and activation was blocked pending review", - "body": [ - { - "type": "text", - "html": "Basilisk vets the LICENSE and NOTICE files of the typeshed snapshot it bundles at build time and records their exact identity. If those legal files no longer match what was approved, Basilisk refuses to serve the stubs rather than distribute content under unknown terms." - }, - { - "type": "text", - "html": "This condition is elevated: it defaults to <code>error</code>, and analysis for the affected root does not run until it is resolved. Update Basilisk to a build whose bundled typeshed license is approved again." - }, - { - "type": "text", - "html": "Like any Basilisk diagnostic it can be graded, though lowering it does not make the underlying license mismatch safe:" - }, - { - "type": "code", - "lang": "toml", - "code": "[tool.basilisk.rules]\n\"typeshed_source_license_changed\" = \"error\"" - }, - { - "type": "text", - "html": "It is reported out of band (CLI banner, an editor <code>window/showMessage</code>, MCP status), never as a Python diagnostic, so it can never affect conformance." - } - ], - "group": "Stubs", - "docsUrl": "https://www.basilisk-python.dev/errors/typeshed_source_license_changed", - "references": [ - { - "label": "python/typeshed LICENSE", - "url": "https://github.com/python/typeshed/blob/main/LICENSE" - }, - { - "label": "python/typeshed", - "url": "https://github.com/python/typeshed" - }, - { - "label": "Basilisk configuration: typeshed source", - "url": "https://www.basilisk-python.dev/docs/configuration/" - } - ] - }, - { - "code": "typeshed_source_unpinned", - "scope": "analyze", - "provenance": "basilisk", - "tags": [ - "basilisk", - "stubs" - ], - "summary": "The active typeshed source is not pinned to an exact commit, so type checks are not reproducible across machines and CI", - "summaryHtml": "The active typeshed source is not pinned to an exact commit, so type checks are not reproducible across machines and CI", - "body": [ - { - "type": "text", - "html": "Basilisk type-checks your code against <code>typeshed</code>, the community's standard-library and third-party type stubs. Which revision of typeshed is active decides which symbols and signatures exist, so two machines resolving different typeshed contents can disagree about whether the same code type-checks." - }, - { - "type": "text", - "html": "Basilisk bundles a vetted typeshed snapshot inside the binary and serves it by default. A build-time snapshot is not a <em>user</em> pin: upgrade Basilisk and the snapshot moves. When no <code>typeshed-commit</code> is set \u2014 or when a custom <code>typeshed-path</code> folder is used, whose contents can change on disk \u2014 Basilisk raises this advisory to say the type-checking baseline is not reproducible." - }, - { - "type": "text", - "html": "Pin an exact <code>python/typeshed</code> commit so every machine and CI run resolves byte-identical stubs. A pin fails closed \u2014 Basilisk never silently substitutes another commit:" - }, - { - "type": "code", - "lang": "toml", - "code": "[tool.basilisk]\ntypeshed-commit = \"\u2026full 40-character SHA\u2026\"" - }, - { - "type": "text", - "html": "This is an ordinary Basilisk diagnostic. Grade it like any rule \u2014 raise it to an error in CI, or silence it once you have accepted the unpinned default:" - }, - { - "type": "code", - "lang": "toml", - "code": "[tool.basilisk.rules]\n\"typeshed_source_unpinned\" = \"error\" # or \"off\" to silence" - }, - { - "type": "text", - "html": "It is reported out of band \u2014 on the CLI's stderr banner, in the editor's Server Info panel, and as MCP status \u2014 and never as a Python diagnostic, so it can never affect conformance." - } - ], - "group": "Stubs", - "docsUrl": "https://www.basilisk-python.dev/errors/typeshed_source_unpinned", - "references": [ - { - "label": "python/typeshed", - "url": "https://github.com/python/typeshed" - }, - { - "label": "Basilisk configuration: typeshed source", - "url": "https://www.basilisk-python.dev/docs/configuration/" - } - ] - }, - { - "code": "typeshed_source_user_managed", - "scope": "analyze", - "provenance": "basilisk", - "tags": [ - "basilisk", - "stubs" - ], - "summary": "A custom typeshed folder is user-managed: you supply its license and contents, so typeshed's license terms are not applied to it", - "summaryHtml": "A custom typeshed folder is user-managed: you supply its license and contents, so typeshed's license terms are not applied to it", - "body": [ - { - "type": "text", - "html": "When you point Basilisk at a custom <code>typeshed-path</code> folder, Basilisk treats it as user-managed: you supply both its contents and its license. Basilisk does not attach <code>python/typeshed</code>'s license terms to a tree it did not vet." - }, - { - "type": "text", - "html": "This advisory makes that explicit so you never unintentionally rely on a custom tree believing it carries typeshed's license, or skip the pin and content verification that the bundled and pinned sources enforce." - }, - { - "type": "text", - "html": "It composes with <code>typeshed_source_unpinned</code> \u2014 a custom folder is both unpinned and user-managed. Grade it like any rule:" - }, - { - "type": "code", - "lang": "toml", - "code": "[tool.basilisk.rules]\n\"typeshed_source_user_managed\" = \"warning\" # or \"off\" to silence" - }, - { - "type": "text", - "html": "It is reported out of band (CLI banner, Server Info, MCP status), never as a Python diagnostic, so it can never affect conformance." - } - ], - "group": "Stubs", - "docsUrl": "https://www.basilisk-python.dev/errors/typeshed_source_user_managed", - "references": [ - { - "label": "python/typeshed", - "url": "https://github.com/python/typeshed" - }, - { - "label": "Basilisk configuration: typeshed source", - "url": "https://www.basilisk-python.dev/docs/configuration/" - } - ] - }, - { - "code": "version_target_syntax", - "scope": "check", - "provenance": "pep", - "tags": [ - "pep" - ], - "summary": "PEP 695 syntax used below the configured target version", - "summaryHtml": "<a href=\"https://peps.python.org/pep-0695/\">PEP 695</a> syntax used below the configured target version", - "body": [ - { - "type": "text", - "html": "<code>type X = ...</code> aliases and <code>class FooT</code> / <code>def fT()</code> type-parameter lists are Python 3.12+ syntax (<a href=\"https://peps.python.org/pep-0695/\">PEP 695</a>). When the configured <code>python_version</code> targets anything older, the file cannot even be parsed by the target interpreter, so this fires as an error (issue #93)." - }, - { - "type": "code", - "lang": "python", - "code": "# python_version = \"3.11\"\ntype Alias = int # E0155 \u2014 `type` statement requires 3.12+\nclass Box[T]: ... # E0155 \u2014 PEP 695 type params require 3.12+\ndef first[T](x: T) -> T: # E0155 \u2014 PEP 695 type params require 3.12+" - } - ], - "group": "Type System", - "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.js b/website/src/_data/site.js new file mode 100644 index 000000000..b1343f97d --- /dev/null +++ b/website/src/_data/site.js @@ -0,0 +1,20 @@ +// Implements [WITHDRAWAL-COPY]. Site metadata is derived from the generated +// withdrawal copy rather than restated here, so the title, meta description, +// and social card can never say something the messaging spec does not. +import withdrawal from "./withdrawal.json" with { type: "json" }; + +export default { + name: "Basilisk", + title: withdrawal.title, + description: withdrawal.line, + url: "https://www.basilisk-python.dev", + themeColor: "#e8500a", + stylesheet: "/assets/css/styles.css", + github: "https://github.com/Nimblesite/Basilisk", + organization: { + name: "Basilisk", + url: "https://www.basilisk-python.dev", + logo: "/assets/images/favicon.png", + sameAs: ["https://github.com/Nimblesite/Basilisk"], + }, +}; diff --git a/website/src/_data/site.json b/website/src/_data/site.json deleted file mode 100644 index a3b969aee..000000000 --- a/website/src/_data/site.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "name": "Basilisk", - "title": "Basilisk — Python Type Checker & Language Server", - "description": "Open-source Python type checker and language server built in Rust. Conformance and benchmark results are withdrawn during an integrity review.", - "url": "https://www.basilisk-python.dev", - "keywords": "basilisk, python type checker, python type checking, python language server, typing conformance, type checker benchmark, rust, vs code, cursor, zed, neovim", - "themeColor": "#e8500a", - "stylesheet": "/assets/css/styles.css", - "github": "https://github.com/Nimblesite/Basilisk", - "ogImage": "/assets/images/og-image.png", - "ogImageWidth": "1200", - "ogImageHeight": "630", - "organization": { - "name": "Basilisk", - "url": "https://www.basilisk-python.dev", - "logo": "/assets/images/favicon.png", - "sameAs": [ - "https://github.com/Nimblesite/Basilisk" - ] - }, - "version": "0.0.0-PLACEHOLDER" -} diff --git a/website/src/_data/withdrawal.json b/website/src/_data/withdrawal.json new file mode 100644 index 000000000..4fb5fef7f --- /dev/null +++ b/website/src/_data/withdrawal.json @@ -0,0 +1,34 @@ +{ + "_generated": "Generated from docs/specs/DOCS-WITHDRAWAL-MESSAGING-SPEC.md by scripts/gen_withdrawal_copy.py — DO NOT EDIT.", + "line": "Basilisk's type checker produced incorrect results. Basilisk is unlisted and is being rebuilt from the ground up as a new product.", + "title": "Basilisk is unlisted", + "short": [ + "<strong>Basilisk's type checker was producing incorrect results. Basilisk is unlisted everywhere.</strong>", + "We asked for Basilisk to be removed from the <code>python/typing</code> conformance results, and it has been removed (<a href=\"https://github.com/python/typing/pull/2330\">python/typing#2330</a>). The code responsible is not isolated to a known set of rules, so we cannot say how many rules are affected. A code-quality tool that does not produce correct results is worse than useless.", + "<strong>Remove Basilisk from your pipeline.</strong> Every distribution channel is being unlisted, and the type checker is inert — it checks nothing and exits non-zero, so a build that still calls it fails loudly instead of reporting a clean run.", + "What comes next is a new product, rebuilt from the ground up, shipping only what can be trusted. That most likely will not include type checking. Nothing is relisted until it has been rebuilt from components we can vouch for.", + "Basilisk's author has published a full public account: <a href=\"https://www.christianfindlay.com/blog/basilisk-conformance-apology\">an apology</a>." + ], + "action": [ + "<strong>Remove Basilisk from your pipeline, your pre-commit hooks, and your editor.</strong> Uninstall the CLI and the extension.", + "The type checker is inert: it checks nothing, and every invocation fails. It prints this statement and exits non-zero, so a build that still calls it fails loudly rather than reporting a clean run. Do not treat that failure as a finding about your code.", + "<strong>Treat every result Basilisk gave you as unverified.</strong> A clean run was never evidence that your code was clean, and an error it reported may never have been real.", + "Every distribution channel is being unlisted. Nothing will be relisted until it has been rebuilt from components we can vouch for." + ], + "full": [ + "<strong>Basilisk's type checker was producing incorrect results.</strong> Rules decided from the way code was <em>spelled</em> rather than what it meant, so they could be wrong in both directions — a false error on correct code, or silence on a real bug.", + "<strong>We asked for Basilisk to be removed from the <code>python/typing</code> conformance results, and it has been removed</strong> (<a href=\"https://github.com/python/typing/pull/2330\">python/typing#2330</a>). That score did not demonstrate correctness.", + "<strong>We cannot tell you how much of the checker this affects.</strong> The code responsible is not isolated to a known set of rules. We will not estimate. That uncertainty is the reason for everything below.", + "<strong>A code-quality tool that does not produce correct results is worse than useless.</strong> Basilisk is being unlisted everywhere it was published — the VS Code Marketplace, Open VSX, the Zed registry, PyPI, the Homebrew tap, and the Scoop bucket — and the type checker is inert. Remove it from your pipeline; it checks nothing, and every invocation fails rather than reporting a clean run.", + "<strong>We are not fixing Basilisk's type checker code. We are rebuilding from the ground up as a new product.</strong> It will ship only what can be trusted. That most likely will not include type checking. Nothing is relisted until it has been rebuilt from components we can vouch for. If type checking ever returns, it will be externally audited before release.", + "Basilisk's author has published a full public account: <a href=\"https://www.christianfindlay.com/blog/basilisk-conformance-apology\">an apology</a>." + ], + "full_markdown": [ + "**Basilisk's type checker was producing incorrect results.** Rules decided from the way code was *spelled* rather than what it meant, so they could be wrong in both directions — a false error on correct code, or silence on a real bug.", + "**We asked for Basilisk to be removed from the `python/typing` conformance results, and it has been removed** ([python/typing#2330](https://github.com/python/typing/pull/2330)). That score did not demonstrate correctness.", + "**We cannot tell you how much of the checker this affects.** The code responsible is not isolated to a known set of rules. We will not estimate. That uncertainty is the reason for everything below.", + "**A code-quality tool that does not produce correct results is worse than useless.** Basilisk is being unlisted everywhere it was published — the VS Code Marketplace, Open VSX, the Zed registry, PyPI, the Homebrew tap, and the Scoop bucket — and the type checker is inert. Remove it from your pipeline; it checks nothing, and every invocation fails rather than reporting a clean run.", + "**We are not fixing Basilisk's type checker code. We are rebuilding from the ground up as a new product.** It will ship only what can be trusted. That most likely will not include type checking. Nothing is relisted until it has been rebuilt from components we can vouch for. If type checking ever returns, it will be externally audited before release.", + "Basilisk's author has published a full public account: [an apology](https://www.christianfindlay.com/blog/basilisk-conformance-apology)." + ] +} diff --git a/website/src/_includes/benchmark-section.njk b/website/src/_includes/benchmark-section.njk deleted file mode 100644 index 5cea1314e..000000000 --- a/website/src/_includes/benchmark-section.njk +++ /dev/null @@ -1,61 +0,0 @@ -{# - The benchmark fixture table. Every displayed timing comes directly from the - committed per-machine CSV parsed by `_data/benchmarks.js`. A row represents a - complete Python file, and its label links to that source file. -#} -{% macro resultsTable(b, repositoryUrl) %} - {% if b.hasData %} - <div class="table-wrapper benchmark-results"> - <table class="benchmark-table"> - <caption class="sr-only"> - Withdrawn historical mean wall-clock times in milliseconds for each checker to process each complete fixture file; not for tool comparison - </caption> - <thead> - <tr> - <th rowspan="2" scope="col">Fixture file</th> - <th colspan="6" scope="colgroup">Cold · no result cache</th> - <th colspan="2" scope="colgroup">Warm · cache hit</th> - </tr> - <tr> - <th class="col-basilisk" scope="col">Basilisk</th> - <th scope="col">Pyright</th> - <th scope="col">mypy</th> - <th scope="col">ty</th> - <th scope="col">Pyrefly</th> - <th scope="col">zuban</th> - <th class="col-basilisk" scope="col">Basilisk</th> - <th scope="col">mypy</th> - </tr> - </thead> - <tbody> - {% for row in b.rows %} - <tr> - <th scope="row"> - <a href="{{ repositoryUrl }}/blob/main/benchmarks/fixtures/{{ row.filename }}"><code>{{ row.filename }}</code></a> - </th> - <td class="col-basilisk">{{ row.valueText.basilisk }}</td> - <td>{{ row.valueText.pyright }}</td> - <td>{{ row.valueText.mypy }}</td> - <td>{{ row.valueText.ty }}</td> - <td>{{ row.valueText.pyrefly }}</td> - <td>{{ row.valueText.zuban }}</td> - <td class="col-basilisk">{{ row.valueText['basilisk-warm'] }}</td> - <td>{{ row.valueText['mypy-warm'] }}</td> - </tr> - {% endfor %} - </tbody> - </table> - </div> - {% endif %} -{% endmacro %} - -{% macro versions(b) %} - {% if b.meta.toolVersions.length %} - <dl class="bench-versions" aria-label="Versions benchmarked"> - <dt>Versions benchmarked</dt> - {% for version in b.meta.toolVersions %} - <dd><code{% if version.tool == 'basilisk' %} class="col-basilisk"{% endif %}>{{ version.tool }} {{ version.version }}</code></dd> - {% endfor %} - </dl> - {% endif %} -{% endmacro %} diff --git a/website/src/_includes/components/blog.njk b/website/src/_includes/components/blog.njk deleted file mode 100644 index afb4bb011..000000000 --- a/website/src/_includes/components/blog.njk +++ /dev/null @@ -1,41 +0,0 @@ -{% macro masthead(title, subtitle, lang) %} -<header class="blog-header"> - <p class="blog-kicker">{{ "blog.eyebrow" | t(lang) | default("The Basilisk Journal") }}</p> - <h1>{{ title }}</h1> - {% if subtitle %}<p class="blog-subtitle">{{ subtitle }}</p>{% endif %} -</header> -{% endmacro %} - -{% macro navigation(lang, defaultLanguage, active) %} -{% set langPrefix = "/" + lang if lang and lang != defaultLanguage else "" %} -<nav class="blog-nav" aria-label="{{ "blog.browse" | t(lang) | default("Browse the blog") }}"> - <a href="{{ langPrefix }}/blog/" class="blog-nav-link{% if active == 'all' %} active{% endif %}">{{ "blog.allStories" | t(lang) | default("All stories") }}</a> - <a href="{{ langPrefix }}/blog/categories/" class="blog-nav-link{% if active == 'categories' %} active{% endif %}">{{ "blog.categories" | t(lang) | default("Categories") }}</a> - <a href="{{ langPrefix }}/blog/tags/" class="blog-nav-link{% if active == 'tags' %} active{% endif %}">{{ "blog.tags" | t(lang) | default("Tags") }}</a> -</nav> -{% endmacro %} - -{% macro postCard(post, lang, defaultLanguage, featured=false) %} -{% set langPrefix = "/" + lang if lang and lang != defaultLanguage else "" %} -<article class="post-card{% if featured %} post-card--featured{% endif %}"> - {% if post.data.image %} - <a href="{{ post.url }}" class="post-card-media" aria-label="{{ "blog.readMore" | t(lang) | default("Read article") }}: {{ post.data.title }}"> - <img src="{{ post.data.image }}" - alt="{{ post.data.imageAlt | default(post.data.title) }}" - width="{{ post.data.imageWidth | default(1200) }}" - height="{{ post.data.imageHeight | default(675) }}" - loading="{% if featured %}eager{% else %}lazy{% endif %}" - decoding="async"{% if featured %} fetchpriority="high"{% endif %}> - </a> - {% endif %} - <div class="post-card-body"> - <div class="post-meta"> - {% if post.data.category %}<a href="{{ langPrefix }}/blog/categories/{{ post.data.category | slugify }}/">{{ post.data.category | blogCategoryLabel(lang) }}</a><span aria-hidden="true">/</span>{% endif %} - <time datetime="{{ post.date | isoDate }}">{{ post.date | dateFormat(lang) }}</time> - </div> - <h2><a href="{{ post.url }}" class="post-title">{{ post.data.title }}</a></h2> - {% if post.data.excerpt or post.data.description %}<p class="post-excerpt">{{ post.data.excerpt | default(post.data.description) }}</p>{% endif %} - <a href="{{ post.url }}" class="post-card-cta">{{ "blog.readMore" | t(lang) | default("Read article") }} <span aria-hidden="true">→</span></a> - </div> -</article> -{% endmacro %} diff --git a/website/src/_includes/components/rules.njk b/website/src/_includes/components/rules.njk deleted file mode 100644 index 7da4ea510..000000000 --- a/website/src/_includes/components/rules.njk +++ /dev/null @@ -1,51 +0,0 @@ -{% macro groupGrid(groups, lang = "en") %} -<div class="rule-group-grid"> - {% for group in groups %} - <a class="rule-group-card" href="{% if lang == 'zh' %}{{ group.zhUrl }}{% else %}{{ group.url }}{% endif %}"> - <span>{% if lang == 'zh' %}{{ group.labelZh }}{% else %}{{ group.label }}{% endif %}</span> - <strong>{{ group.count }}</strong> - </a> - {% endfor %} -</div> -{% endmacro %} - -{% macro groupPage(group, lang = "en") %} -{% set groupLabel = group.labelZh if lang == "zh" else group.label %} -{% set provenanceLabel = "Basilisk 可选规则" if lang == "zh" else "Basilisk rules (opt-in)" %} -{% if group.provenance == "pep" %} - {% set provenanceLabel = "Python 类型规范规则" if lang == "zh" else "Python typing-spec rules" %} -{% endif %} -<nav class="breadcrumb" aria-label="Breadcrumb"> - <a href="{% if lang == 'zh' %}/zh/docs/rules/{% else %}/docs/rules/{% endif %}">{% if lang == "zh" %}规则{% else %}Rules{% endif %}</a> - <span aria-hidden="true">/</span> - <span>{{ provenanceLabel }}</span> -</nav> - -<h1>{{ groupLabel }}</h1> -<p> - {% if group.tag == "core" and lang == "zh" %} - {{ group.count }} 条横跨多个主题、因此没有更细分类标签的 Python 类型规范核心规则。 - {% elif group.tag == "core" %} - {{ group.count }} cross-cutting Python typing-spec rules without a narrower category tag. - {% elif lang == "zh" %} - {{ group.count }} 条带有 <code>{{ group.tag }}</code> 标签的{{ provenanceLabel }}。 - {% else %} - {{ group.count }} {{ provenanceLabel | lower }} tagged <code>{{ group.tag }}</code>. - {% endif %} -</p> - -{% if group.provenance == "basilisk" %} -<blockquote> - <p>{% if lang == "zh" %}这些规则默认关闭,只有在项目配置中选择相应标签后才会启用。{% else %}These Basilisk-specific rules are off by default and activate only when your project opts into the corresponding tag.{% endif %}</p> -</blockquote> -{% endif %} - -<ul class="error-list"> - {% for rule in group.items %} - <li> - <a href="/errors/{{ rule.code }}/"><code>{{ rule.code }}</code></a> - <span class="error-list__summary">{{ rule.summaryHtml | safe }}</span> - </li> - {% endfor %} -</ul> -{% endmacro %} diff --git a/website/src/_includes/conformance-chart.njk b/website/src/_includes/conformance-chart.njk deleted file mode 100644 index 341668169..000000000 --- a/website/src/_includes/conformance-chart.njk +++ /dev/null @@ -1,50 +0,0 @@ -{# - PEP-conformance over-time chart, rendering the history of - conformance/conformance_status.csv in EVERY locale. Pure inline SVG (no JS, no - chart library), data-driven from _data/conformance.js (which reads the file's - real git history). Pages supply only translated prose. - - NO PAGE RENDERS THIS TODAY. The score it charts is withdrawn, so both locales' - conformance pages dropped the import; the macro and its `historical.chart` data - are retained for the integrity audit and for whatever replaces the withdrawn - figure. Delete both together if that replacement never needs a chart. - - WHITESPACE: this macro is embedded inside MARKDOWN pages. markdown-it ends a - raw-HTML block at the first blank line, so the rendered SVG MUST contain no - blank lines or it gets shredded (text nodes leak out of <svg>). Every njk - control tag therefore uses `{%- ... -%}` trimming to keep the output contiguous. - - Args: - c — the global `conformance` data object (from _data/conformance.js) - t — locale strings: { label, heading, subhead, prevLegend, officialLegend, - dropNote, caption } — rendered with `| safe` (may contain inline HTML). -#} -{%- macro chart(c, t) -%} -{%- if c.chart -%} -{%- set ch = c.chart -%} -<figure class="conf-chart"> -<figcaption class="conf-chart__head"><span class="conf-chart__label">{{ t.label }}</span><span class="conf-chart__title">{{ t.heading | safe }}</span></figcaption> -<svg class="conf-chart__svg" viewBox="0 0 {{ ch.width }} {{ ch.height }}" role="img" aria-label="{{ t.heading }} — {{ ch.peak.score }}% on {{ ch.peak.shortDate }} corrected to {{ ch.current.score }}% on {{ ch.current.shortDate }}"> -{%- for tick in ch.yTicks %} -<line class="conf-chart__grid" x1="{{ ch.left }}" y1="{{ tick.y }}" x2="{{ ch.width - ch.right }}" y2="{{ tick.y }}"></line><text class="conf-chart__axis" x="{{ ch.left - 8 }}" y="{{ tick.y + 4 }}" text-anchor="end">{{ tick.value }}%</text> -{%- endfor %} -<polyline class="conf-chart__line conf-chart__line--prev" points="{{ ch.prevPolyline }}"></polyline> -{%- if ch.drop %} -<line class="conf-chart__drop" x1="{{ ch.drop.x1 }}" y1="{{ ch.drop.y1 }}" x2="{{ ch.drop.x2 }}" y2="{{ ch.drop.y2 }}"></line> -{%- endif %} -<polyline class="conf-chart__line conf-chart__line--official" points="{{ ch.officialPolyline }}"></polyline> -{%- for p in ch.pts %} -<circle class="conf-chart__dot {{ 'conf-chart__dot--official' if p.official else 'conf-chart__dot--prev' }}" cx="{{ p.x }}" cy="{{ p.y }}" r="{{ 4.5 if p.official else 3 }}"><title>{{ p.shortDate }} ({{ p.hash }}): {{ p.score }}% — {{ p.pass }}/{{ p.total }}, {{ p.fp }} false positives{{ ' · official calculator' if p.official else ' · earlier in-repo harness' }} -{%- if p.showDate %} -{{ p.shortDate }} -{%- endif %} -{%- endfor %} -{{ ch.peak.score }}% -{{ ch.current.score }}% - -

{{ t.dropNote | safe }}

-
  • {{ t.prevLegend | safe }}
  • {{ t.officialLegend | safe }}
-

{{ t.caption | safe }}

- -{%- endif -%} -{%- endmacro -%} diff --git a/website/src/_includes/layouts/base.njk b/website/src/_includes/layouts/base.njk index b2433d276..6bb16dd69 100644 --- a/website/src/_includes/layouts/base.njk +++ b/website/src/_includes/layouts/base.njk @@ -1,14 +1,10 @@ -{#- Locale-safe i18n: derive the effective language and a locale-stripped base - path straight from the URL, so language alternates never double-prefix - (/zh/zh/...) even when an auto-generated page reports the wrong `lang`. - Set `noTranslation: true` in a page's front matter to opt it out of the - language cluster entirely (e.g. the English-only Releases page). -#} -{%- set effLang = 'zh' if (page.url == '/zh/' or page.url.startsWith('/zh/')) else (lang | default('en')) -%} -{%- set basePath = (page.url | replace('/zh/', '/')) if effLang == 'zh' else page.url -%} -{%- set alternatePath = basePath if effLang == 'zh' else '/zh' + basePath -%} -{%- set hasTranslation = (not noTranslation) and (collections.all | hasPageUrl(alternatePath)) -%} - +{#- The site serves one statement and a notice at every retired URL + ([WITHDRAWAL-SURFACES]). It is monolingual and dark-only, so this layout + carries no language cluster, no theme switcher, and no social card: the + approved copy exists in English only, and there is no product image left to + share. -#} + @@ -20,81 +16,35 @@ gtag('config', 'G-JM4GYVKF8C'); - - {% set metaAuthor = author | default(site.author) %} - {% set metaKeywords = keywords | default(site.keywords) %} {{ title | default(site.title) }} - - {% if metaAuthor %}{% endif %} - {% if metaKeywords %}{% endif %} - - - + {#- [WITHDRAWAL-SURFACES]: the notice pages are byte-identical copies of one + paragraph served at every retired URL. They must stay reachable so an old + link explains itself, but must not be offered for indexing. -#} + + - + - - - - - {%- if not hasTranslation %} - - - {%- else %} - {% for langCode in supportedLanguages %} - - {% endfor %} - - {%- endif %} - - - {% set metaImage = image | default(site.ogImage) %} - {% set metaImageWidth = imageWidth | default(site.ogImageWidth) | default('1200') %} - {% set metaImageHeight = imageHeight | default(site.ogImageHeight) | default('630') %} - {% set metaImageAlt = imageAlt | default(title) | default(site.name) %} - + + - - - {%- if hasTranslation %} - {% for langCode in supportedLanguages %}{% if langCode != effLang %} - - {% endif %}{% endfor %} - {%- endif %} - {% if metaImage %} - - - - - {% endif %} - - - - + + - {% if site.twitterSite %}{% endif %} - {% if site.twitterCreator %}{% endif %} - {% if metaImage %} - {% endif %} @@ -185,8 +87,7 @@ - - {% if site.stylesheet %}{% endif %} + {% block head %}{% endblock %} @@ -194,60 +95,22 @@