From fa53b1633cacae205de3385808b8419124a60b37 Mon Sep 17 00:00:00 2001 From: abdushakoor12 Date: Tue, 21 Jul 2026 16:36:21 +0500 Subject: [PATCH 1/9] Propagate declared return type into tuple element positions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `literal_collection_assignable_to` ([TYPEINF-SPECIAL-LITERAL-CONTEXT]) had arms for Union/Optional/List/Set/Dict but none for Tuple, so a declared `tuple[list[int], str]` fell through to `_ => None`. The caller then fell back to the invariant `is_assignable_to`, where the bottom-up `list[Never]` synthesised for `[]` fails invariance against `list[int]` — a false `returns_compatibility` on `return [], "lit"`. Add the Tuple arm plus a `tuple_literal_assignable_to` helper that carries the declared element type inward per position, reusing the existing `homogeneous_tuple_elem` / `is_unpacked_tuple_elem` shape decomposition rather than re-deriving it. PEP 646 unpacked segments and arity mismatches still return `None`, leaving the invariant fallback untouched. This closes the asymmetry with the assignment path, whose gate already included `RhsKind::Tuple` — `x: tuple[list[int], str] = ([], "a")` was accepted while the identical `return` was rejected. Conformance is unchanged at 141/141, 0 missed, 0 false positives. Refs #337 --- crates/basilisk-checker/src/inference.rs | 42 +++++++++ .../checker/returns_compatibility_tests.rs | 89 +++++++++++++++++++ docs/specs/CHECKER-TYPE-INFERENCE-SPEC.md | 5 +- 3 files changed, 135 insertions(+), 1 deletion(-) diff --git a/crates/basilisk-checker/src/inference.rs b/crates/basilisk-checker/src/inference.rs index 9c594d00..aa4bdadf 100644 --- a/crates/basilisk-checker/src/inference.rs +++ b/crates/basilisk-checker/src/inference.rs @@ -113,10 +113,52 @@ pub fn literal_collection_assignable_to(rhs: &RhsKind, declared: &InferredType) })), _ => None, }, + InferredType::Tuple(declared_elems) => match rhs { + RhsKind::Tuple(elements) => tuple_literal_assignable_to(elements, declared_elems), + _ => None, + }, _ => None, } } +/// Contextual typing for a tuple display against a declared tuple type: each +/// position carries the declared element type inward, so an empty `[]`/`{}` +/// nested in the tuple constructs the declared container instead of a +/// `list[Never]` / `dict[Never, Never]` that then fails invariance (#337). +/// +/// Implements [TYPEINF-SPECIAL-LITERAL-CONTEXT] for the tuple shapes of +/// [TYPEINF-COLLECTIONS-TUPLES]. Returns `None` for shapes this cannot judge — +/// a PEP 646 unpacked segment or an arity mismatch — leaving those to the +/// invariant fallback unchanged. +fn tuple_literal_assignable_to( + elements: &[RhsKind], + declared_elems: &[InferredType], +) -> Option { + if declared_elems + .iter() + .any(crate::types_star_tuples::is_unpacked_tuple_elem) + { + return None; + } + // `tuple[X, ...]` (PEP 484 homogeneous): every position is typed against `X`. + if let Some(elem) = crate::types_star_tuples::homogeneous_tuple_elem(declared_elems) { + return Some( + elements + .iter() + .all(|element| literal_element_assignable_to(element, elem)), + ); + } + if elements.len() != declared_elems.len() { + return None; + } + Some( + elements + .iter() + .zip(declared_elems) + .all(|(element, declared)| literal_element_assignable_to(element, declared)), + ) +} + /// Assignability of a single literal element: a nested collection literal stays /// covariant/contextual; anything else uses its ordinary inferred type. fn literal_element_assignable_to(rhs: &RhsKind, declared: &InferredType) -> bool { diff --git a/crates/basilisk-checker/tests/checker/returns_compatibility_tests.rs b/crates/basilisk-checker/tests/checker/returns_compatibility_tests.rs index ef695a17..0662db0f 100644 --- a/crates/basilisk-checker/tests/checker/returns_compatibility_tests.rs +++ b/crates/basilisk-checker/tests/checker/returns_compatibility_tests.rs @@ -253,3 +253,92 @@ fn empty_dict_return_is_checked_in_declared_context() -> Result<(), Box Result<(), Box> { + // Refs #337. [TYPEINF-SPECIAL-LITERAL-CONTEXT] must reach the tuple's element + // positions: `return [], "lit"` against `-> tuple[list[int], str]` constructs + // a `list[int]` instead of a `list[Never]` that then fails invariance. Locks + // the Tuple arm of `literal_collection_assignable_to` (inference.rs). + let source = "def f() -> tuple[list[int], str]:\n return [], \"lit\"\n"; + let diags = run(source)?; + assert!( + !codes(&diags).contains(&"returns_compatibility"), + "empty list inside a returned tuple must be typed against tuple[list[int], str], got: {:?}", + codes(&diags) + ); + Ok(()) +} + +#[test] +fn empty_dict_inside_returned_tuple_uses_declared_element_context( +) -> Result<(), Box> { + // Refs #337. Same propagation for the `{}` display: `dict[Never, Never]` + // must never be synthesised when a declared element type is available. + let source = "def f() -> tuple[dict[str, int], str]:\n return {}, \"a\"\n"; + let diags = run(source)?; + assert!( + !codes(&diags).contains(&"returns_compatibility"), + "empty dict inside a returned tuple must be typed against dict[str, int], got: {:?}", + codes(&diags) + ); + Ok(()) +} + +#[test] +fn tuple_literal_nested_in_returned_list_uses_declared_context( +) -> Result<(), Box> { + // Refs #337. The propagation nests to arbitrary depth: the tuple context + // has to be reached through the enclosing list literal. + let source = "def f() -> list[tuple[list[int], str]]:\n return [([], \"a\")]\n"; + let diags = run(source)?; + assert!( + !codes(&diags).contains(&"returns_compatibility"), + "tuple literal inside a returned list must be typed contextually, got: {:?}", + codes(&diags) + ); + Ok(()) +} + +#[test] +fn empty_list_valid_for_homogeneous_tuple_return() -> Result<(), Box> { + // Refs #337. The PEP 484 variable-length form `tuple[X, ...]` distributes + // the declared element type over every position of the literal. + let source = "def f() -> tuple[list[int], ...]:\n return [], []\n"; + let diags = run(source)?; + assert!( + !codes(&diags).contains(&"returns_compatibility"), + "empty lists must be valid for a `tuple[list[int], ...]` return, got: {:?}", + codes(&diags) + ); + Ok(()) +} + +#[test] +fn tuple_literal_with_wrong_element_still_errors() -> Result<(), Box> { + // Contextual tuple typing is covariant per position, not a blanket pass: a + // genuine element mismatch inside the tuple must still fire. + let source = "def f() -> tuple[list[str], str]:\n return [1], \"a\"\n"; + let diags = run(source)?; + assert!( + codes(&diags).contains(&"returns_compatibility"), + "`list[int]` literal in a `tuple[list[str], str]` return must still error, got: {:?}", + codes(&diags) + ); + Ok(()) +} + +#[test] +fn homogeneous_tuple_literal_with_wrong_element_still_errors( +) -> Result<(), Box> { + // The `tuple[X, ...]` arm must reject a position that does not fit `X`. + let source = "def f() -> tuple[int, ...]:\n return 1, \"a\"\n"; + let diags = run(source)?; + assert!( + codes(&diags).contains(&"returns_compatibility"), + "a str element in a `tuple[int, ...]` return must still error, got: {:?}", + codes(&diags) + ); + Ok(()) +} diff --git a/docs/specs/CHECKER-TYPE-INFERENCE-SPEC.md b/docs/specs/CHECKER-TYPE-INFERENCE-SPEC.md index 2fa222b1..82d68b8b 100644 --- a/docs/specs/CHECKER-TYPE-INFERENCE-SPEC.md +++ b/docs/specs/CHECKER-TYPE-INFERENCE-SPEC.md @@ -891,9 +891,12 @@ def rows() -> Iterator[dict[str, int]]: def pick() -> list[int] | list[str]: return [] # OK — [] fits either arm of the union + +def split() -> tuple[list[int], str]: + return [], "lit" # OK — the declared type reaches each tuple position ``` -Each literal element need only be assignable *to* the declared element type (covariant, recursing through nested literals and distributing over unions/`Optional`). Genuine element mismatches still fire — `return [1]` against `list[str]` is an error — so no required diagnostic is lost. Non-literal returns (a name, a call) keep the invariant check. Implemented by `literal_collection_assignable_to` in `crates/basilisk-checker/src/inference.rs`, consulted by `returns_compatibility`, `returns_compatibility_2`, and `annotations_generators` before their invariant fallback. +Each literal element need only be assignable *to* the declared element type (covariant, recursing through nested literals and distributing over unions/`Optional`). A **tuple display** carries the declared type inward per position — positionally against a fixed-length `tuple[X, Y]`, and over every position against the homogeneous `tuple[X, ...]` form ([TYPEINF-COLLECTIONS-TUPLES](#TYPEINF-COLLECTIONS-TUPLES)); a PEP 646 unpacked segment or an arity mismatch is left to the invariant fallback. Genuine element mismatches still fire — `return [1]` against `list[str]`, or `return [1], "a"` against `tuple[list[str], str]`, is an error — so no required diagnostic is lost. Non-literal returns (a name, a call) keep the invariant check. Implemented by `literal_collection_assignable_to` in `crates/basilisk-checker/src/inference.rs`, consulted by `returns_compatibility`, `returns_compatibility_2`, and `annotations_generators` before their invariant fallback. ### [TYPEINF-SPECIAL-SELF] `Self` {#TYPEINF-SPECIAL-SELF} From 5fbe4984f1e7441c7af5466f1ec1cb0f509f8ae5 Mon Sep 17 00:00:00 2001 From: abdushakoor12 Date: Tue, 21 Jul 2026 17:23:41 +0500 Subject: [PATCH 2/9] Tell the user which configured rules `check` never ran MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `basilisk check` filters every analyze-scope diagnostic at the CLI edge ([CHKARCH-COMMANDS]), so a project that grades non-`pep` rule tags `error` could see "All checked. No issues found." while none of those rules were ever evaluated. A silent clean run was indistinguishable from a clean project — the same class of failure as a skipped CI gate reporting success. One reported repo hid 66 configured errors this way for the life of its pipeline. A text-mode `check` now closes with one line naming how many rules the configuration selected that this command does not run, and pointing at `basilisk analyze` ([CHKARCH-CLI-SCOPE-NOTICE]). - `analyze_selected_rules` is the counterpart to the adjacent `pep_disable_violations`: the non-`pep` rules a config resolves to a non-disabled severity. It keeps the bare-tree fast path used by `EffectiveRuleConfig::severity`, so conformance and benchmark runs never walk the rule catalog. - The pipeline counts them across the base config and every per-directory config, so one file's config never speaks for the whole run. The union walk it shares with `pep_disable_config_error` is now a single `codes_across_configs` helper rather than two copies. - The notice is a fact about the project, not boilerplate: a tree that selects no analyze-scope rule prints nothing extra, and `analyze` — which just ran them — never prints it. Exit codes and the JSON contract are unchanged; machine consumers see no new field. The issue's other two parts: `basilisk --help` already lists `analyze` on this branch, and the check/analyze split is now documented next to `rule-tags` in the configuration reference. Conformance is unchanged at 141/141, 0 missed, 0 false positives. Refs #334 --- crates/basilisk-checker/src/lib.rs | 31 +++++++ .../tests/config_override_tests.rs | 41 +++++++++- crates/basilisk-cli/src/main.rs | 30 ++++++- crates/basilisk-cli/src/pipeline/mod.rs | 62 ++++++++++---- crates/basilisk-cli/tests/e2e_scope.rs | 81 +++++++++++++++++++ docs/specs/CHECKER-ARCHITECTURE-SPEC.md | 16 ++++ website/src/docs/configuration.md | 25 ++++++ 7 files changed, 265 insertions(+), 21 deletions(-) diff --git a/crates/basilisk-checker/src/lib.rs b/crates/basilisk-checker/src/lib.rs index 8181da84..65b296f9 100644 --- a/crates/basilisk-checker/src/lib.rs +++ b/crates/basilisk-checker/src/lib.rs @@ -236,6 +236,37 @@ pub fn pep_disable_violations(config: &basilisk_config::BasiliskConfig) -> Vec<& .collect() } +/// Analyze-scope rules this configuration selects — the rules `check` will +/// never evaluate, however strictly they are graded. +/// +/// Implements [CHKARCH-CLI-SCOPE-NOTICE] (GitHub #334). The counterpart to +/// [`pep_disable_violations`]: a rule is analyze-scope iff it is not +/// `pep`-tagged ([CHKARCH-COMMANDS]), and configuration selects it by +/// resolving it to a non-disabled severity ([CHKARCH-CONFIG-MODEL]). Callers +/// report the count so a clean `check` on such a project is never mistaken for +/// a clean project. +#[must_use] +pub fn analyze_selected_rules(config: &basilisk_config::BasiliskConfig) -> Vec<&'static str> { + // Bare-tree fast path, as in [`EffectiveRuleConfig::severity`]: with no + // table anywhere nothing is selected, so the conformance and benchmark + // hot paths never walk the catalog. + if !config.has_config_table() { + return Vec::new(); + } + rule_catalog() + .into_iter() + .map(|descriptor| descriptor.code) + .filter(|code| !is_pep_rule(code)) + .filter(|code| { + let tags = rule_tags::tags_for_code(code); + matches!( + config.resolve_severity(code, &tags), + Some(severity) if severity != basilisk_config::RuleSeverity::Disabled + ) + }) + .collect() +} + /// Per-file rule selection and grading over the configuration model. /// /// Implements [CHKARCH-CONFIG-MODEL]: the nearest deciding table wins, a rule diff --git a/crates/basilisk-checker/tests/config_override_tests.rs b/crates/basilisk-checker/tests/config_override_tests.rs index 2f6b0aa5..cb0b86bd 100644 --- a/crates/basilisk-checker/tests/config_override_tests.rs +++ b/crates/basilisk-checker/tests/config_override_tests.rs @@ -13,9 +13,10 @@ //! entry over tag entry, strictest matching tag), the command partition //! [CHKARCH-COMMANDS] (`pep` rules always run and can never be disabled; //! everything else runs only when configuration decides it), and the -//! severity values of [CHKARCH-STRICTNESS-SEVERITY]. Code under test: +//! severity values of [CHKARCH-STRICTNESS-SEVERITY], and the unrun-rule count +//! behind [CHKARCH-CLI-SCOPE-NOTICE]. Code under test: //! `basilisk-checker/src/lib.rs` (`EffectiveRuleConfig`, -//! `pep_disable_violations`, `is_pep_rule`). +//! `pep_disable_violations`, `analyze_selected_rules`, `is_pep_rule`). use std::collections::HashMap; @@ -251,6 +252,42 @@ fn pep_rule_disable_is_invalid_and_never_applied() { ); } +/// [CHKARCH-CLI-SCOPE-NOTICE] (Refs #334): `analyze_selected_rules` reports +/// exactly the rules configuration selects that `check` will never evaluate — +/// the count a clean `check` run tells the user about. +#[test] +fn analyze_selected_rules_reports_what_check_will_not_run() { + assert!( + basilisk_checker::analyze_selected_rules(&BasiliskConfig::default()).is_empty(), + "a bare tree selects no analyze rule, so `check` hides nothing" + ); + + let tagged = config_with(&[], &[("basilisk", RuleSeverity::Error)]); + let selected = basilisk_checker::analyze_selected_rules(&tagged); + assert!( + selected.contains(&"BSK-0001"), + "one `basilisk` tag entry selects the house rules: {selected:?}" + ); + assert!( + selected + .iter() + .all(|code| !basilisk_checker::is_pep_rule(code)), + "a `pep` rule always runs under check, so it is never reported unrun: {selected:?}" + ); + + let graded_pep = config_with(&[("imports_unresolved", RuleSeverity::Warning)], &[]); + assert!( + basilisk_checker::analyze_selected_rules(&graded_pep).is_empty(), + "grading a pep rule configures a rule `check` does run" + ); + + let disabled = config_with(&[("BSK-0001", RuleSeverity::Disabled)], &[]); + assert!( + !basilisk_checker::analyze_selected_rules(&disabled).contains(&"BSK-0001"), + "a disabled rule is not selected, so it is not something `check` hid" + ); +} + /// [CHKARCH-COMMANDS]: the partition is the `pep` provenance tag. #[test] fn partition_is_the_pep_tag() { diff --git a/crates/basilisk-cli/src/main.rs b/crates/basilisk-cli/src/main.rs index 956f0c1b..2373a771 100644 --- a/crates/basilisk-cli/src/main.rs +++ b/crates/basilisk-cli/src/main.rs @@ -378,6 +378,30 @@ fn run_scoped_check(args: &CheckArgs, scope: DiagnosticScope) -> u8 { } } +/// 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 { @@ -393,7 +417,7 @@ fn render_outcome(outcome: &pipeline::CheckOutcome, format: OutputFormat) -> u8 OutputFormat::Text => { let error_count = render_diagnostics(&outcome.diagnostics, &outcome.sources); let total = outcome.diagnostics.len(); - if total == 0 && outcome.failures.is_empty() { + let exit_code = if total == 0 && outcome.failures.is_empty() { println!("{}", "All checked. No issues found.".green().bold()); 0 } else if total == 0 { @@ -412,7 +436,9 @@ fn render_outcome(outcome: &pipeline::CheckOutcome, format: OutputFormat) -> u8 println!("{}", summary.yellow().bold()); } u8::from(error_count > 0) - } + }; + print_scope_notice(outcome.unrun_selected_rules); + exit_code } } } diff --git a/crates/basilisk-cli/src/pipeline/mod.rs b/crates/basilisk-cli/src/pipeline/mod.rs index 085be8dc..814944b9 100644 --- a/crates/basilisk-cli/src/pipeline/mod.rs +++ b/crates/basilisk-cli/src/pipeline/mod.rs @@ -78,6 +78,10 @@ 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]: @@ -100,25 +104,37 @@ pub(crate) fn effective_check_paths( .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: &std::collections::BTreeMap< - std::path::PathBuf, - std::sync::Arc, - >, + dir_configs: &DirConfigs, base: &basilisk_config::BasiliskConfig, ) -> Option { - let mut violations: std::collections::BTreeSet<&'static str> = - basilisk_checker::pep_disable_violations(base) - .into_iter() - .collect(); - for config in dir_configs.values() { - violations.extend(basilisk_checker::pep_disable_violations(config)); - } + let violations = + codes_across_configs(dir_configs, base, basilisk_checker::pep_disable_violations); if violations.is_empty() { return None; } @@ -246,6 +262,21 @@ where 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, + }; + let cache_context = cache_check::build_context(cache, &dir_configs, &search_paths, &project_root); @@ -276,6 +307,7 @@ where diagnostics: all_diagnostics, sources, failures, + unrun_selected_rules, }) } @@ -376,8 +408,7 @@ pub(crate) fn parent_dir_of(path: &str) -> std::path::PathBuf { pub(crate) fn resolve_dir_configs( python_files: &[String], fallback: &basilisk_config::BasiliskConfig, -) -> std::collections::BTreeMap> -{ +) -> DirConfigs { let mut dir_configs = std::collections::BTreeMap::new(); for path in python_files { let _ = dir_configs @@ -399,10 +430,7 @@ pub(crate) fn resolve_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: &std::collections::BTreeMap< - std::path::PathBuf, - std::sync::Arc, - >, + dir_configs: &DirConfigs, path: &str, fallback: &basilisk_config::BasiliskConfig, ) -> std::sync::Arc { diff --git a/crates/basilisk-cli/tests/e2e_scope.rs b/crates/basilisk-cli/tests/e2e_scope.rs index ddf0949e..d3f7cb06 100644 --- a/crates/basilisk-cli/tests/e2e_scope.rs +++ b/crates/basilisk-cli/tests/e2e_scope.rs @@ -37,6 +37,16 @@ fn run(subcommand: &str, path: &Path) -> 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() } @@ -171,6 +181,77 @@ fn check_emits_pep_diagnostics_with_stable_json_shape() { } } +// ── the check/analyze split is never silent ([CHKARCH-CLI-SCOPE-NOTICE]) ───── + +/// 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. diff --git a/docs/specs/CHECKER-ARCHITECTURE-SPEC.md b/docs/specs/CHECKER-ARCHITECTURE-SPEC.md index c3150dcc..688d9b91 100644 --- a/docs/specs/CHECKER-ARCHITECTURE-SPEC.md +++ b/docs/specs/CHECKER-ARCHITECTURE-SPEC.md @@ -898,6 +898,22 @@ Compatibility anchors: supported methods {#CHKARCH-LSP-METHODS}; custom commands `check` and `analyze` support human-readable text and structured JSON. Other formats are not part of the current contract. +#### Scope notice {#CHKARCH-CLI-SCOPE-NOTICE} + +`check` drops every analyze-scope diagnostic at the edge +([CHKARCH-COMMANDS](#CHKARCH-COMMANDS)), so on a project that grades non-`pep` +rules it can report "All checked. No issues found." while none of those rules +were ever evaluated. A silent clean run is indistinguishable from a clean +project — the same class of failure as a skipped CI gate reporting success. + +A `check` run in text format therefore closes with one line naming how many +rules the configuration selected that this command never runs, and pointing at +`basilisk analyze`. The notice is a fact about the project, not boilerplate: a +tree whose configuration selects no analyze-scope rule prints nothing extra, +and `analyze` — which just ran them — never prints it. Exit codes +([CHKARCH-CLI-EXITCODES](#CHKARCH-CLI-EXITCODES)) and the JSON contract are +unchanged; machine consumers see no new field. + ### Exit codes {#CHKARCH-CLI-EXITCODES} | Code | Meaning | diff --git a/website/src/docs/configuration.md b/website/src/docs/configuration.md index 75cdbb00..d6c33e1e 100644 --- a/website/src/docs/configuration.md +++ b/website/src/docs/configuration.md @@ -145,6 +145,31 @@ hidden switch. The canonical tag vocabulary: The [rule reference](/docs/rules/) lists each rule's tags; the configuration editor's tag actions write these same `rule-tags` lines. +### Which command runs what you configured + +Grading a rule does not decide *which command evaluates it*. The partition is +fixed ([`CHKARCH-COMMANDS`](https://github.com/Nimblesite/Basilisk/blob/main/docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-COMMANDS)): + +- **`basilisk check`** runs the `pep`-tagged rules, always — and *only* those. +- **`basilisk analyze`** runs every other rule — `basilisk`, `style`, + `redundancy`, `strictness`, `dependencies`, `imports`, `stubs`, + `suppressions` — and only where your configuration selects them. + +So a `[tool.basilisk.rule-tags]` line reading `"strictness" = "error"` is +evaluated by `analyze`, not by `check`. A pipeline that runs only +`basilisk check` will never fail on it. Run both: + +```bash +basilisk check src/ tests/ +basilisk analyze src/ tests/ +``` + +A text-mode `check` on a project that configures non-`pep` rules closes with a +one-line note saying how many rules it did not run, so a clean `check` is never +mistaken for a clean project +([`CHKARCH-CLI-SCOPE-NOTICE`](https://github.com/Nimblesite/Basilisk/blob/main/docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-CLI-SCOPE-NOTICE)). +The editor publishes both scopes at once, so this split is a CLI concern only. + ### Severity resolution Per rule, per checked file — one walk, first decision wins: From 634ed2a665b62f103931ac2e9cfbb5a4198ab0e9 Mon Sep 17 00:00:00 2001 From: abdushakoor12 Date: Tue, 21 Jul 2026 21:27:55 +0500 Subject: [PATCH 3/9] Default `fix` to the configured include roots, not `.` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `basilisk fix` declared `PATHS` with `#[arg(default_value = ".")]`, so `paths` was never empty and `collect_and_fix` skipped `effective_check_paths` — the [CHKARCH-CONFIG-INCLUDE] fallback that `check`/`analyze` run. A no-args `fix` therefore walked the entire working directory instead of `[tool.basilisk] include`. `fix` mutates files, so that default is materially riskier than the same default on a read-only command: in a project with a checked-out virtualenv it rewrote third-party sources under `venv/site-packages` (and walked ~10k files, appearing to hang). Drop the clap default and route the paths through `effective_check_paths`, so explicit CLI paths still win and a bare `basilisk fix` touches exactly what `basilisk check` reads. Refs #333 --- crates/basilisk-cli/src/fix.rs | 5 ++ crates/basilisk-cli/src/main.rs | 5 +- .../basilisk-cli/tests/e2e_include_config.rs | 48 +++++++++++++++++++ 3 files changed, 56 insertions(+), 2 deletions(-) diff --git a/crates/basilisk-cli/src/fix.rs b/crates/basilisk-cli/src/fix.rs index 75ce8d83..ac610f8a 100644 --- a/crates/basilisk-cli/src/fix.rs +++ b/crates/basilisk-cli/src/fix.rs @@ -76,6 +76,11 @@ fn collect_and_fix(paths: &[String], allowed_rules: &[&str]) -> Result, /// Include unsafe (heuristic) fixes alongside safe fixes. #[arg(long)] diff --git a/crates/basilisk-cli/tests/e2e_include_config.rs b/crates/basilisk-cli/tests/e2e_include_config.rs index de4ee367..9d327f06 100644 --- a/crates/basilisk-cli/tests/e2e_include_config.rs +++ b/crates/basilisk-cli/tests/e2e_include_config.rs @@ -104,6 +104,54 @@ fn no_args_checks_include_roots_only() { ); } +/// 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() { From 71d307b2e2ae2592882c10b39e02c05c0d6d5366 Mon Sep 17 00:00:00 2001 From: abdushakoor12 Date: Wed, 22 Jul 2026 11:44:43 +0500 Subject: [PATCH 4/9] Prune virtualenvs structurally by their PEP 405 `pyvenv.cfg` marker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Virtualenvs were skipped only because `venv`/`.venv`/`env`/`site-packages` are literal entries in `DEFAULT_EXCLUDES`. Per [CHKARCH-CONFIG-EXCLUDE], configuring `exclude` *replaces* that list wholesale, so any project that sets its own patterns silently lost all venv protection — and `fix` mutates files, so it rewrote installed third-party packages. Add `basilisk_config::is_virtualenv_dir`, keyed on PEP 405's `pyvenv.cfg` rather than on directory names, so `env/` and `.direnv/python-3.13/` are recognised alongside `.venv/`. Both walkers call it at the exact point they already skip hidden directories — the CLI walk behind `check`/`fix`/`format`/`adopt`/`stubs`, and the LSP workspace scan — so the editor and CLI still agree file-for-file. The skip prunes traversal *into* a venv and never overrides an explicit request: `basilisk check venv/lib/.../dep` still reports, mirroring the walk's existing depth-0 root exemption. Refs #341 --- crates/basilisk-cli/src/pipeline/mod.rs | 7 ++ .../basilisk-cli/tests/e2e_exclude_config.rs | 107 ++++++++++++++++++ crates/basilisk-config/src/lib.rs | 2 +- crates/basilisk-config/src/paths.rs | 18 +++ crates/basilisk-lsp/src/workspace_scan.rs | 52 +++++++-- docs/specs/CHECKER-ARCHITECTURE-SPEC.md | 16 ++- 6 files changed, 192 insertions(+), 10 deletions(-) diff --git a/crates/basilisk-cli/src/pipeline/mod.rs b/crates/basilisk-cli/src/pipeline/mod.rs index 814944b9..66fb1373 100644 --- a/crates/basilisk-cli/src/pipeline/mod.rs +++ b/crates/basilisk-cli/src/pipeline/mod.rs @@ -555,6 +555,13 @@ pub(crate) fn collect_python_files( 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) diff --git a/crates/basilisk-cli/tests/e2e_exclude_config.rs b/crates/basilisk-cli/tests/e2e_exclude_config.rs index 2cc0c00d..655739a9 100644 --- a/crates/basilisk-cli/tests/e2e_exclude_config.rs +++ b/crates/basilisk-cli/tests/e2e_exclude_config.rs @@ -9,6 +9,8 @@ //! - 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( @@ -238,6 +240,111 @@ fn anchored_glob_excludes_the_whole_subtree() { 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"); diff --git a/crates/basilisk-config/src/lib.rs b/crates/basilisk-config/src/lib.rs index 20e25c0d..50645cee 100644 --- a/crates/basilisk-config/src/lib.rs +++ b/crates/basilisk-config/src/lib.rs @@ -24,7 +24,7 @@ pub use editor::{ TypeshedConfigUpdate, TypeshedConfigValue, }; pub use parse::{is_full_commit_sha, is_valid_typeshed_url_template, BasiliskConfig, RuleTables}; -pub use paths::path_matches_pattern; +pub use paths::{is_virtualenv_dir, path_matches_pattern}; pub use severity::RuleSeverity; use std::path::Path; diff --git a/crates/basilisk-config/src/paths.rs b/crates/basilisk-config/src/paths.rs index 94f85ae2..ededb1c0 100644 --- a/crates/basilisk-config/src/paths.rs +++ b/crates/basilisk-config/src/paths.rs @@ -67,6 +67,24 @@ fn segments_match(pattern: &[&str], path: &[&str]) -> bool { } } +/// Whether `dir` is the root of a Python virtual environment. +/// +/// Implements [CHKARCH-CONFIG-EXCLUDE] (GitHub #341). A venv holds installed +/// third-party packages — never the user's own code to check, and never code a +/// file-mutating `fix` may rewrite. Configuring `exclude` *replaces* +/// [`crate::DEFAULT_EXCLUDES`], so the `venv`/`.venv`/`site-packages` entries +/// that used to prune one vanish the moment a project sets its own list. This +/// check is therefore structural and unconditional, like the hidden-directory +/// skip both walkers already apply. +/// +/// The marker is [PEP 405](https://peps.python.org/pep-0405/#specification)'s +/// `pyvenv.cfg`, which every venv root carries whatever it is named — so `env/` +/// and `.direnv/python-3.13/` are recognised exactly like `.venv/`. +#[must_use] +pub fn is_virtualenv_dir(dir: &std::path::Path) -> bool { + dir.join("pyvenv.cfg").is_file() +} + /// Check whether a file path matches a glob path pattern (gitignore-style). /// /// Implements [CHKARCH-CONFIG-EXCLUDE]. See diff --git a/crates/basilisk-lsp/src/workspace_scan.rs b/crates/basilisk-lsp/src/workspace_scan.rs index 62907e58..ab172597 100644 --- a/crates/basilisk-lsp/src/workspace_scan.rs +++ b/crates/basilisk-lsp/src/workspace_scan.rs @@ -10,15 +10,20 @@ use std::path::{Path, PathBuf}; use tower_lsp::lsp_types::Url; /// Recursively collect all `.py` / `.pyi` files under `dir`, skipping hidden -/// dirs and the caller's exclude patterns. +/// dirs, virtualenvs, and the caller's exclude patterns. /// -/// `exclude` is the whole truth about what gets skipped. It arrives already -/// resolved to the effective list — [`basilisk_config::DEFAULT_EXCLUDES`] when -/// the workspace configures nothing, and the configured patterns *instead of* -/// those defaults once it does ([CHKARCH-CONFIG-EXCLUDE]). Applying a second, -/// hardcoded default set here would make the editor skip trees that no -/// configuration could re-enable, so `basilisk check` would report on files -/// the editor silently ignored. +/// `exclude` is the whole truth about which *configurable* trees are skipped. It +/// arrives already resolved to the effective list — +/// [`basilisk_config::DEFAULT_EXCLUDES`] when the workspace configures nothing, +/// and the configured patterns *instead of* those defaults once it does +/// ([CHKARCH-CONFIG-EXCLUDE]). Applying a second, hardcoded default set here +/// would make the editor skip trees that no configuration could re-enable, so +/// `basilisk check` would report on files the editor silently ignored. +/// +/// Two structural skips sit outside that list and are not configurable in +/// either surface: hidden (`.`-prefixed) directories, and virtualenv roots +/// ([`basilisk_config::is_virtualenv_dir`]). The CLI walk applies exactly the +/// same two, so the surfaces still agree file-for-file. pub fn collect_python_files( dir: &Path, out: &mut Vec, @@ -36,6 +41,13 @@ pub fn collect_python_files( if entry.file_name().to_string_lossy().starts_with('.') { continue; } + // Likewise for virtualenvs, pruned by their `pyvenv.cfg` marker so + // a configured `exclude` — which replaces the defaults that named + // `venv`/`site-packages` — cannot expose installed packages to the + // editor that `basilisk check` never reads (GitHub #341). + if basilisk_config::is_virtualenv_dir(&path) { + continue; + } if is_excluded(&path, exclude, workspace_root) { continue; } @@ -179,6 +191,30 @@ mod tests { ); } + // Issue #341: a virtualenv survives in the scan whenever the workspace + // configures its own `exclude`, because that replaces the `DEFAULT_EXCLUDES` + // entries (`venv`, `.venv`, `site-packages`) that were the only thing + // pruning it. PEP 405's `pyvenv.cfg` marker identifies one structurally, + // whatever the directory is named — and the CLI walk must agree, or the + // editor analyses third-party sources `basilisk check` never reads + // (crates/basilisk-cli/tests/e2e_exclude_config.rs). + #[test] + fn virtualenvs_are_pruned_by_pyvenv_cfg_regardless_of_configured_excludes() { + let root = unique_root("venv"); + write(&root, "env/lib/python3.13/site-packages/dep/mod.py"); + let _ = std::fs::write(root.join("env/pyvenv.cfg"), "home = /usr\n"); + write(&root, "src/app.py"); + + let found = scan(&root, &["vendor"]); + + assert_eq!( + found, + vec!["src/app.py".to_owned()], + "a `pyvenv.cfg`-marked directory must be skipped even though the configured \ + `exclude` replaced the defaults that used to name it; got {found:?}" + ); + } + // [ANALYSIS-INDEX-STRUCT] A basename is not a module identity: packages // commonly contain same-named modules in different directories. #[test] diff --git a/docs/specs/CHECKER-ARCHITECTURE-SPEC.md b/docs/specs/CHECKER-ARCHITECTURE-SPEC.md index 688d9b91..7a7f6a2c 100644 --- a/docs/specs/CHECKER-ARCHITECTURE-SPEC.md +++ b/docs/specs/CHECKER-ARCHITECTURE-SPEC.md @@ -1168,7 +1168,21 @@ excluded (`basilisk_config::DEFAULT_EXCLUDES`: `node_modules`, `site-packages`, `.venv`, `__pycache__`, `build`, `dist`, the extension's `bundled` / `_vendored` trees, and friends). Setting `exclude` **replaces** those defaults entirely — re-add any default entries explicitly if they are still needed. -Hidden directories (`.`-prefixed) are always skipped regardless. The single canonical matcher +Two **structural** skips sit outside `exclude` entirely and no configuration can +switch them off, because both surfaces must agree on them file-for-file: + +- hidden directories (`.`-prefixed); and +- **virtualenv roots**, identified by [PEP 405](https://peps.python.org/pep-0405/#specification)'s + `pyvenv.cfg` marker (`basilisk_config::is_virtualenv_dir`) rather than by + directory name, so `env/` and `.direnv/python-3.13/` are pruned exactly like + `.venv/`. A venv holds installed third-party packages — never the user's code. + Without this, a project that sets any custom `exclude` loses the + `venv`/`site-packages` default entries and `basilisk fix` **rewrites installed + packages** (GitHub #341). The skip prunes *traversal into* a venv; an explicit + CLI path pointing inside one is still checked, matching the walk's depth-0 root + exemption. + +The single canonical matcher `basilisk_config::path_matches_pattern` is shared by every entry point so they exclude identically: From eb8bf9d377f2639625358513575df5def5f5810c Mon Sep 17 00:00:00 2001 From: abdushakoor12 Date: Wed, 22 Jul 2026 12:29:30 +0500 Subject: [PATCH 5/9] Recognise walrus targets as bindings (PEP 572) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `names_undefined` decided a return-name was defined by consulting `FunctionInfo::all_local_assigns`, built by matching statement-shaped binding forms — `Assign`, `AnnAssign`, `For`, `With`, imports, nested `def`/`class`. A PEP 572 assignment expression binds from inside an *expression* (`if item := prices.get(asset):` binds `item` from the `if` test), so no statement match ever saw it and the rule reported valid code as undefined. A new `visitor::walrus` collects `Expr::Named` targets at two reaches. `Any` feeds `all_local_assigns`: the target is bound somewhere in the body. `Definite` feeds `unconditional_assigns`: only what Python must evaluate once control reaches a statement, so branch bodies and short-circuiting operands are pruned while the first operand of `and`/`or`, a ternary's test, and a comprehension's outermost iterable are kept. Both skip nested `def`/`class`/`lambda` scopes and both descend into comprehensions, which PEP 572 exempts from the comprehension's own scope. Without the `Definite` half the fix would merely trade `names_undefined` for a fresh `names_unbound` false positive on `if hit := d.get(k): ...` followed by a top-level `return hit`. Conformance stays 141/141; `scope_tree` picks up walrus locals for rename and scope-aware reference finding for free. Refs #339 --- .../tests/checker/names_undefined_tests.rs | 79 ++++++++++ .../basilisk-resolver/src/visitor/assigns.rs | 35 +++-- crates/basilisk-resolver/src/visitor/mod.rs | 1 + .../basilisk-resolver/src/visitor/walrus.rs | 136 ++++++++++++++++++ 4 files changed, 239 insertions(+), 12 deletions(-) create mode 100644 crates/basilisk-resolver/src/visitor/walrus.rs diff --git a/crates/basilisk-checker/tests/checker/names_undefined_tests.rs b/crates/basilisk-checker/tests/checker/names_undefined_tests.rs index 56e7ffe0..7f99dd0d 100644 --- a/crates/basilisk-checker/tests/checker/names_undefined_tests.rs +++ b/crates/basilisk-checker/tests/checker/names_undefined_tests.rs @@ -369,3 +369,82 @@ fn function_local_aliased_import_attribute_in_return_no_false_positive( ); Ok(()) } + +// --------------------------------------------------------------------------- +// Issue #339 — the walrus operator (`:=`, PEP 572) binds its target +// --------------------------------------------------------------------------- + +#[test] +fn walrus_in_if_test_binds_the_name_for_a_return_inside_the_branch( +) -> Result<(), Box> { + // Issue #339 (headline repro): `if item := mapping.get(key):` binds `item` + // for the branch body, so `return item` references a defined name. PEP 572 + // assignment expressions bind in the *enclosing* scope exactly like `=`. + let source = "\ +def get_price(prices: dict[str, float], asset: str) -> float | None: + if item := prices.get(asset): + return item + return None +"; + let diags = run(source)?; + let fired: Vec<&str> = codes(&diags) + .into_iter() + .filter(|c| *c == "names_undefined" || *c == "names_unbound") + .collect(); + assert!( + fired.is_empty(), + "a walrus target used in a return inside the guarded branch must fire neither \ + E0018 nor E0019, got: {fired:?}" + ); + Ok(()) +} + +#[test] +fn walrus_in_while_test_binds_the_name_for_a_return_inside_the_loop( +) -> Result<(), Box> { + // Issue #339 (loop shape): the other canonical walrus site is a `while` + // test, where the binding is equally visible to the loop body. + let source = "\ +def first_line(lines: list[str]) -> str | None: + while line := lines.pop(): + return line + return None +"; + let diags = run(source)?; + let fired: Vec<&str> = codes(&diags) + .into_iter() + .filter(|c| *c == "names_undefined" || *c == "names_unbound") + .collect(); + assert!( + fired.is_empty(), + "a walrus target bound by a `while` test must fire neither E0018 nor E0019, \ + got: {fired:?}" + ); + Ok(()) +} + +#[test] +fn walrus_in_if_test_binds_the_name_for_a_later_top_level_return( +) -> Result<(), Box> { + // Issue #339 (post-branch shape): an `if` test always evaluates, so a walrus + // inside it binds unconditionally — the name is live after the statement, + // whichever way the branch went. Recognising the binding must therefore not + // merely trade E0018 ("not defined") for E0019 ("may be unbound"). + let source = "\ +def lookup(items: dict[str, int], key: str) -> int | None: + if hit := items.get(key): + print(hit) + return hit +"; + let diags = run(source)?; + let fired: Vec<&str> = codes(&diags) + .into_iter() + .filter(|c| *c == "names_undefined" || *c == "names_unbound") + .collect(); + assert!( + fired.is_empty(), + "an `if`-test walrus binds unconditionally, so a later top-level return of the \ + name must fire neither E0018 nor E0019, got: {fired:?}" + ); + Ok(()) +} diff --git a/crates/basilisk-resolver/src/visitor/assigns.rs b/crates/basilisk-resolver/src/visitor/assigns.rs index c43fa46f..fb2fd15c 100644 --- a/crates/basilisk-resolver/src/visitor/assigns.rs +++ b/crates/basilisk-resolver/src/visitor/assigns.rs @@ -8,6 +8,7 @@ use crate::scope::VariableInfo; use super::class_info_ext::{alias_name, expr_simple_name}; use super::core::{classify_rhs, text_range_to_span}; +use super::walrus::{collect_walrus_targets, Reach}; /// Names a plain `import` statement binds into the enclosing scope. /// `import a.b.c` binds the top-level package `a`; `import a.b as d` binds `d`. @@ -45,6 +46,13 @@ pub(super) fn extract_target_names(expr: &Expr) -> Vec { /// Collect all names assigned anywhere in the function body (not in nested functions). pub(super) fn collect_all_assigns(stmts: &[Stmt]) -> Vec { + let mut out = collect_walrus_targets(stmts, Reach::Any); + out.extend(collect_statement_assigns(stmts)); + out +} + +/// The statement-shaped half of [`collect_all_assigns`]. +fn collect_statement_assigns(stmts: &[Stmt]) -> Vec { let mut out = Vec::new(); for stmt in stmts { match stmt { @@ -58,8 +66,8 @@ pub(super) fn collect_all_assigns(stmts: &[Stmt]) -> Vec { } Stmt::For(node) => { out.extend(extract_target_names(&node.target)); - out.extend(collect_all_assigns(&node.body)); - out.extend(collect_all_assigns(&node.orelse)); + out.extend(collect_statement_assigns(&node.body)); + out.extend(collect_statement_assigns(&node.orelse)); } Stmt::FunctionDef(func) => { // Nested function name is defined in enclosing scope. @@ -80,14 +88,14 @@ pub(super) fn collect_all_assigns(stmts: &[Stmt]) -> Vec { out.extend(from_import_bound_names(node)); } Stmt::If(node) => { - out.extend(collect_all_assigns(&node.body)); + out.extend(collect_statement_assigns(&node.body)); for clause in &node.elif_else_clauses { - out.extend(collect_all_assigns(&clause.body)); + out.extend(collect_statement_assigns(&clause.body)); } } Stmt::While(node) => { - out.extend(collect_all_assigns(&node.body)); - out.extend(collect_all_assigns(&node.orelse)); + out.extend(collect_statement_assigns(&node.body)); + out.extend(collect_statement_assigns(&node.orelse)); } Stmt::With(node) => { for item in &node.items { @@ -95,19 +103,19 @@ pub(super) fn collect_all_assigns(stmts: &[Stmt]) -> Vec { out.extend(extract_target_names(var)); } } - out.extend(collect_all_assigns(&node.body)); + out.extend(collect_statement_assigns(&node.body)); } Stmt::Try(node) => { - out.extend(collect_all_assigns(&node.body)); + out.extend(collect_statement_assigns(&node.body)); for handler in &node.handlers { let ExceptHandler::ExceptHandler(h) = handler; if let Some(exc_name) = &h.name { out.push(exc_name.to_string()); } - out.extend(collect_all_assigns(&h.body)); + out.extend(collect_statement_assigns(&h.body)); } - out.extend(collect_all_assigns(&node.orelse)); - out.extend(collect_all_assigns(&node.finalbody)); + out.extend(collect_statement_assigns(&node.orelse)); + out.extend(collect_statement_assigns(&node.finalbody)); } _ => {} } @@ -117,7 +125,10 @@ pub(super) fn collect_all_assigns(stmts: &[Stmt]) -> Vec { /// Collect names assigned at the top level of a function body (unconditionally). pub(super) fn collect_unconditional_assigns(stmts: &[Stmt]) -> Vec { - let mut assignments = Vec::new(); + // A walrus in a statement's own expression — the `if`/`while` test, a `for` + // iterable, an assigned value — is evaluated whenever control reaches that + // statement, so it binds on every path past it just like a plain `=`. + let mut assignments = collect_walrus_targets(stmts, Reach::Definite); for stmt in stmts { match stmt { diff --git a/crates/basilisk-resolver/src/visitor/mod.rs b/crates/basilisk-resolver/src/visitor/mod.rs index e17b1f2d..718e06eb 100644 --- a/crates/basilisk-resolver/src/visitor/mod.rs +++ b/crates/basilisk-resolver/src/visitor/mod.rs @@ -31,6 +31,7 @@ mod typeddict_schema; mod typevar; mod unhashable; pub(crate) mod walks; +mod walrus; mod yield_exprs; use basilisk_parser::ParsedModule; diff --git a/crates/basilisk-resolver/src/visitor/walrus.rs b/crates/basilisk-resolver/src/visitor/walrus.rs new file mode 100644 index 00000000..99bbcfe1 --- /dev/null +++ b/crates/basilisk-resolver/src/visitor/walrus.rs @@ -0,0 +1,136 @@ +//! Implements [CHKARCH-ARCH-PIPELINE]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-ARCH-PIPELINE +//! Bindings introduced by [PEP 572](https://peps.python.org/pep-0572/) +//! assignment expressions (`name := value`). +//! +//! Every other binding form is a statement, so the collectors in +//! [`super::assigns`] match it syntactically. A walrus hides inside an +//! *expression* — `if item := prices.get(asset):` binds `item` from the `if` +//! test — which made its target invisible to those collectors and so undefined +//! to `names_undefined` (GitHub #339). + +use ruff_python_ast::visitor::{walk_elif_else_clause, walk_expr, walk_stmt, Visitor}; +use ruff_python_ast::{Comprehension, ElifElseClause, Expr, Stmt}; + +use super::class_info_ext::expr_simple_name; + +/// How much of the visited code the caller may treat as evaluated. +#[derive(Clone, Copy, PartialEq, Eq)] +pub(super) enum Reach { + /// Everything, however deeply nested or conditional: the target is bound + /// *somewhere* in the body. + Any, + /// Only what Python must evaluate once control reaches each statement in + /// `stmts`, so the target is bound on every path past it. Branch bodies and + /// short-circuiting operands are pruned — `x` in `if a and (x := f()):` may + /// never be bound. + Definite, +} + +/// Collect the targets of the assignment expressions `stmts` binds into the +/// enclosing scope, at the given [`Reach`]. +/// +/// Nested `def`/`class`/`lambda` scopes are excluded: a walrus there binds in +/// *that* scope. Comprehensions are not — PEP 572 deliberately exempts the +/// walrus from the comprehension's own scope so it binds in the enclosing one. +pub(super) fn collect_walrus_targets(stmts: &[Stmt], reach: Reach) -> Vec { + let mut collector = WalrusTargets { + reach, + out: Vec::new(), + }; + for stmt in stmts { + collector.visit_stmt(stmt); + } + collector.out +} + +struct WalrusTargets { + reach: Reach, + out: Vec, +} + +impl WalrusTargets { + fn descends_into_conditional_code(&self) -> bool { + self.reach == Reach::Any + } + + /// Whether reaching this expression's operands is conditional. + fn is_short_circuiting(expr: &Expr) -> bool { + matches!( + expr, + Expr::BoolOp(_) + | Expr::If(_) + | Expr::ListComp(_) + | Expr::SetComp(_) + | Expr::DictComp(_) + | Expr::Generator(_) + ) + } + + /// Visit only the operands of a short-circuiting form that always run: the + /// first operand of `and`/`or`, the test of a ternary, and a comprehension's + /// outermost iterable (evaluated eagerly, even for a generator expression). + fn visit_definite_operands(&mut self, expr: &Expr) { + match expr { + Expr::BoolOp(op) => self.visit_first(op.values.first()), + Expr::If(ternary) => self.visit_expr(&ternary.test), + Expr::ListComp(comp) => self.visit_outermost_iter(&comp.generators), + Expr::SetComp(comp) => self.visit_outermost_iter(&comp.generators), + Expr::DictComp(comp) => self.visit_outermost_iter(&comp.generators), + Expr::Generator(comp) => self.visit_outermost_iter(&comp.generators), + _ => {} + } + } + + fn visit_outermost_iter(&mut self, generators: &[Comprehension]) { + self.visit_first(generators.first().map(|generator| &generator.iter)); + } + + fn visit_first(&mut self, expr: Option<&Expr>) { + if let Some(expr) = expr { + self.visit_expr(expr); + } + } +} + +impl Visitor<'_> for WalrusTargets { + fn visit_stmt(&mut self, stmt: &Stmt) { + // A nested `def`/`class` is its own scope; only the name it binds + // belongs here, and [`super::assigns`] already records that. + if matches!(stmt, Stmt::FunctionDef(_) | Stmt::ClassDef(_)) { + return; + } + walk_stmt(self, stmt); + } + + fn visit_body(&mut self, body: &[Stmt]) { + // Suppressing nested bodies leaves `walk_stmt` visiting exactly the + // expressions a statement evaluates itself — an `if`/`while` test, a + // `for` iterable, an assigned value — which is what `Definite` means. + if self.descends_into_conditional_code() { + ruff_python_ast::visitor::walk_body(self, body); + } + } + + fn visit_elif_else_clause(&mut self, clause: &ElifElseClause) { + // Only the leading `if` test is guaranteed to be evaluated; an `elif` + // test runs only when every test before it was falsy. + if self.descends_into_conditional_code() { + walk_elif_else_clause(self, clause); + } + } + + fn visit_expr(&mut self, expr: &Expr) { + // A `lambda` body is its own scope, exactly like a nested `def`. + if matches!(expr, Expr::Lambda(_)) { + return; + } + if let Expr::Named(named) = expr { + self.out.extend(expr_simple_name(&named.target)); + } + if !self.descends_into_conditional_code() && Self::is_short_circuiting(expr) { + self.visit_definite_operands(expr); + return; + } + walk_expr(self, expr); + } +} From d5e18c30624bbd96cd2d615317678db8df075809 Mon Sep 17 00:00:00 2001 From: abdushakoor12 Date: Wed, 22 Jul 2026 23:57:33 +0500 Subject: [PATCH 6/9] Stamp the bundled typeshed pin in the one-time config seed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opening an unconfigured workspace seeded only the strict-by-default rule tag, so a freshly-opened repo sat permanently UNPINNED ([STUBRES-TYPESHED-WARN]) until the user separately ran `basilisk typeshed download` — not reproducible out of the box. `write_seed` (`crates/basilisk-lsp/src/config_seed.rs`) now builds a full `ConfigurationUpdate` through the same validated, structure-preserving editor transaction, stamping `typeshed-commit = bundled_commit_sha()` alongside `[tool.basilisk.rule-tags] basilisk = "error"`. The bundled commit is complete inside the binary, so the pin needs no network access and cannot produce a NO SOURCE state; it suppresses UNPINNED without changing which stubs are resolved. The seed only runs when the ancestor walk found no `[tool.basilisk]` table, so no `typeshed-path` can exist for the pin to conflict with. One-time and never-resurrect guarantees are untouched: any existing table still blocks the seed entirely, and a deleted pin is never resurrected. The CLI still never seeds. [LSPARCH-CONFIG-SEEDING] is updated to describe the stamped pin. Refs #343 --- crates/basilisk-lsp/src/config_seed.rs | 64 +++++++++++++++---- .../tests/lsp/ws_test_diagnostic_scope.rs | 16 ++++- docs/specs/LSP-ARCHITECTURE-SPEC.md | 28 +++++--- 3 files changed, 85 insertions(+), 23 deletions(-) diff --git a/crates/basilisk-lsp/src/config_seed.rs b/crates/basilisk-lsp/src/config_seed.rs index cadd566a..93dd7dc5 100644 --- a/crates/basilisk-lsp/src/config_seed.rs +++ b/crates/basilisk-lsp/src/config_seed.rs @@ -4,10 +4,15 @@ //! One-time strict-by-default configuration seeding. //! //! When a workspace root's ancestor walk finds no `[tool.basilisk]` table, -//! the LSP writes the two-line seed into the root's `pyproject.toml` -//! (creating the file when absent) BEFORE first analysis: +//! the LSP writes the strict-by-default seed into the root's +//! `pyproject.toml` (creating the file when absent) BEFORE first analysis — +//! the rule tag plus the binary's bundled typeshed pin, so the workspace is +//! reproducible out of the box ([STUBRES-TYPESHED-WARN]): //! //! ```toml +//! [tool.basilisk] +//! typeshed-commit = "" +//! //! [tool.basilisk.rule-tags] //! "basilisk" = "error" //! ``` @@ -22,8 +27,8 @@ use std::collections::BTreeMap; use std::path::Path; use basilisk_config::{ - apply_config_patch, build_rule_patch, discover_config_document, load_basilisk_config, - RuleConfigUpdate, RuleSeverity, + apply_config_patch, build_configuration_patch, discover_config_document, load_basilisk_config, + ConfigurationUpdate, RuleConfigUpdate, RuleSeverity, TypeshedConfigKey, TypeshedConfigUpdate, }; /// Seed `root`'s `pyproject.toml` when the ancestor walk finds no @@ -45,7 +50,7 @@ pub(crate) fn seed_root_if_unconfigured(root: &Path) -> bool { Ok(()) => { tracing::info!( root = %root.display(), - "seeded strict-by-default configuration ([tool.basilisk.rule-tags] basilisk = error)" + "seeded strict-by-default configuration (rule-tags basilisk = error, typeshed-commit = bundled)" ); true } @@ -62,14 +67,29 @@ pub(crate) fn seed_root_if_unconfigured(root: &Path) -> bool { fn write_seed(root: &Path) -> Result<(), basilisk_config::ConfigDocumentError> { let document = discover_config_document(root)?; - let update = RuleConfigUpdate { - rules: BTreeMap::new(), - rule_tags: BTreeMap::from([( - basilisk_checker::rule_tags::BASILISK.to_owned(), - Some(RuleSeverity::Error), - )]), + let update = ConfigurationUpdate { + rules: RuleConfigUpdate { + rules: BTreeMap::new(), + rule_tags: BTreeMap::from([( + basilisk_checker::rule_tags::BASILISK.to_owned(), + Some(RuleSeverity::Error), + )]), + }, + // Pin the binary's bundled typeshed commit so the workspace is + // reproducible from the moment it is opened — never `UNPINNED` + // ([STUBRES-TYPESHED-WARN]). The bundle is complete inside the + // binary, so the pin needs no network access and cannot produce a + // `NO SOURCE` state. The seed only runs when the ancestor walk found + // no `[tool.basilisk]` table, so no `typeshed-path` can exist for + // the pin to conflict with ([STUBRES-TYPESHED]). + typeshed: TypeshedConfigUpdate { + entries: BTreeMap::from([( + TypeshedConfigKey::TypeshedCommit, + Some(basilisk_stubs::typeshed::bundle::bundled_commit_sha().to_owned()), + )]), + }, }; - let patch = build_rule_patch(&document, &update)?; + let patch = build_configuration_patch(&document, &update)?; apply_config_patch(&patch) } @@ -115,6 +135,26 @@ mod tests { let _ = std::fs::remove_dir_all(root); } + /// [LSPARCH-CONFIG-SEEDING]: the seed stamps the binary's bundled + /// typeshed commit alongside the rule tag, so a freshly-opened workspace + /// is pinned and reproducible — never `UNPINNED` + /// ([STUBRES-TYPESHED-WARN]) — without any network access (GitHub #343). + #[test] + fn seed_stamps_the_bundled_typeshed_commit() { + let root = temp_root("pin"); + assert!(seed_root_if_unconfigured(&root)); + let content = std::fs::read_to_string(root.join("pyproject.toml")).unwrap(); + let expected_pin = format!( + "typeshed-commit = \"{}\"", + basilisk_stubs::typeshed::bundle::bundled_commit_sha() + ); + assert!( + content.contains(&expected_pin), + "the seed must pin the bundled typeshed commit; got:\n{content}" + ); + let _ = std::fs::remove_dir_all(root); + } + /// [LSPARCH-CONFIG-SEEDING]: seeding happens once — a second /// initialization never re-seeds, and deleting the tag entry (leaving the /// empty table) never resurrects it. diff --git a/crates/basilisk-lsp/tests/lsp/ws_test_diagnostic_scope.rs b/crates/basilisk-lsp/tests/lsp/ws_test_diagnostic_scope.rs index 8670a914..a7cf477e 100644 --- a/crates/basilisk-lsp/tests/lsp/ws_test_diagnostic_scope.rs +++ b/crates/basilisk-lsp/tests/lsp/ws_test_diagnostic_scope.rs @@ -137,9 +137,10 @@ async fn analyze_opt_out_filters_published_diagnostics_to_pep_only() -> TestResu } // Implements [LSPARCH-CONFIG-SEEDING]: opening a workspace root whose -// ancestor walk finds no [tool.basilisk] table writes the two-line -// strict-by-default seed into the root's pyproject.toml before first -// analysis — exactly once, never resurrecting a deleted entry. +// ancestor walk finds no [tool.basilisk] table writes the +// strict-by-default seed — the rule tag plus the bundled typeshed pin — +// into the root's pyproject.toml before first analysis — exactly once, +// never resurrecting a deleted entry. #[tokio::test] async fn initialization_seeds_an_unconfigured_root_exactly_once() -> TestResult<()> { let root = unique_temp_dir("bsk_seed_e2e"); @@ -160,6 +161,15 @@ async fn initialization_seeds_an_unconfigured_root_exactly_once() -> TestResult< seeded.contains("basilisk") && seeded.contains("error"), "the seed is the one strict-by-default tag entry: {seeded}" ); + let expected_pin = format!( + "typeshed-commit = \"{}\"", + basilisk_stubs::typeshed::bundle::bundled_commit_sha() + ); + assert!( + seeded.contains(&expected_pin), + "the seed pins the bundled typeshed commit so a fresh workspace \ + is never UNPINNED (GitHub #343): {seeded}" + ); } // The user deletes the entry but the (never-pruned) table remains; a diff --git a/docs/specs/LSP-ARCHITECTURE-SPEC.md b/docs/specs/LSP-ARCHITECTURE-SPEC.md index bf1a9edb..8fc911ec 100644 --- a/docs/specs/LSP-ARCHITECTURE-SPEC.md +++ b/docs/specs/LSP-ARCHITECTURE-SPEC.md @@ -96,22 +96,34 @@ ergonomics: project configuration grades rules and never selects commands. When the LSP opens a workspace root whose ancestor walk finds no `[tool.basilisk]` table ([CHKARCH-CONFIG-DISCOVERY](CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-CONFIG-DISCOVERY)), -it writes exactly one thing into the root's `pyproject.toml` (creating the -file when the project has none) — the two-line strict-by-default seed: +it writes exactly one seed into the root's `pyproject.toml` (creating the +file when the project has none) — the strict-by-default rule tag plus the +binary's bundled typeshed pin: ```toml +[tool.basilisk] +typeshed-commit = "" + [tool.basilisk.rule-tags] "basilisk" = "error" ``` PEP rules need no seeding — `check` always runs them ([CHKARCH-COMMANDS](CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-COMMANDS)) — so this -one tag entry is the entire out-of-the-box configuration: every house rule on, -at `error`, in a file the user owns from that moment. Seeding happens once. -The LSP never re-seeds while any `[tool.basilisk]` table exists on the walk, -never edits the entry afterwards, and never resurrects anything the user -deletes — deleting the tag entry switches the house rules back off. The CLI -never seeds. +is the entire out-of-the-box configuration: every house rule on, at `error`, +and the workspace pinned to the typeshed commit the binary already bundles +(`bundled_commit_sha()`), in a file the user owns from that moment. The pin +makes a freshly-opened workspace reproducible — never `UNPINNED` +([STUBRES-TYPESHED-WARN](CHECKER-STUB-RESOLUTION-SPEC.md#STUBRES-TYPESHED-WARN)) +— without changing which stubs are resolved and without network access: the +bundled commit is complete inside the binary, so the pin cannot produce a +`NO SOURCE` state. The seed only runs when no `[tool.basilisk]` table exists, +so no user-set `typeshed-path` can be present for the pin to conflict with. +Seeding happens once. The LSP never re-seeds while any `[tool.basilisk]` +table exists on the walk, never edits the entries afterwards, and never +resurrects anything the user deletes — deleting the tag entry switches the +house rules back off, and deleting the pin returns the workspace to floating +resolution. The CLI never seeds. ## Resolved environment reporting {#LSPARCH-RESOLVED-ENV} From 33ef14b7b81cec9cd5c8edc7bc2edb7c4531845a Mon Sep 17 00:00:00 2001 From: abdushakoor12 Date: Thu, 23 Jul 2026 00:12:23 +0500 Subject: [PATCH 7/9] Restamp conformance references at python/typing@39164cd MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Regenerated by scripts/gen_conformance_reference.py during the local scorer run: 141/141 (100%), 0 false positives, unchanged — only the graded python/typing commit advanced. --- README-pypi.md | 4 ++-- README.md | 4 ++-- README.zh.md | 4 ++-- docs/readme/README.src.md | 4 ++-- docs/readme/README.zh.src.md | 4 ++-- docs/specs/CHECKER-ARCHITECTURE-SPEC.md | 4 ++-- vscode-extension/README.md | 4 ++-- vscode-extension/README.zh.md | 4 ++-- website/src/_data/conformance_report.json | 8 ++++---- 9 files changed, 20 insertions(+), 20 deletions(-) diff --git a/README-pypi.md b/README-pypi.md index fb8062b6..6675d547 100644 --- a/README-pypi.md +++ b/README-pypi.md @@ -29,8 +29,8 @@

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

diff --git a/README.md b/README.md index 3a1b426b..ae3ce1e9 100644 --- a/README.md +++ b/README.md @@ -29,8 +29,8 @@

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

diff --git a/README.zh.md b/README.zh.md index 62a1c830..7c4fb9bc 100644 --- a/README.zh.md +++ b/README.zh.md @@ -28,8 +28,8 @@

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

diff --git a/docs/readme/README.src.md b/docs/readme/README.src.md index d4ccbc3d..e4b55067 100644 --- a/docs/readme/README.src.md +++ b/docs/readme/README.src.md @@ -40,8 +40,8 @@

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

diff --git a/docs/readme/README.zh.src.md b/docs/readme/README.zh.src.md index 6efd7390..c67692f6 100644 --- a/docs/readme/README.zh.src.md +++ b/docs/readme/README.zh.src.md @@ -35,8 +35,8 @@

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

diff --git a/docs/specs/CHECKER-ARCHITECTURE-SPEC.md b/docs/specs/CHECKER-ARCHITECTURE-SPEC.md index c21de587..f1dd92cd 100644 --- a/docs/specs/CHECKER-ARCHITECTURE-SPEC.md +++ b/docs/specs/CHECKER-ARCHITECTURE-SPEC.md @@ -264,7 +264,7 @@ configuration/editor behavior is specified by ### Python Typing PEP Coverage {#CHKARCH-PEPS} -Basilisk's **target** is 100% conformance with the Python typing specification. We measure against the latest **`python/typing@main`**, recording the exact graded commit by hash in `conformance_report.json` (currently [`6ef9f77`](https://github.com/python/typing/tree/6ef9f7719ecfff09dad8724ef42b621fd994fb5e/conformance)). Today the official scorer, run unmodified in CI on the binary in its default configuration (the PEP conformance set; see [CHKARCH-CONFORMANCE-MODE](#CHKARCH-CONFORMANCE-MODE)), reports **141 of 141 files passing (100.0%)**, with **0 false positives** and **0 missed required errors** (970 caught). We run that suite in CI on every change; the gate ratchets the pass-percentage **up** and the false-positive ceiling **down** — closed only by fixing the checker, never by disabling a rule. +Basilisk's **target** is 100% conformance with the Python typing specification. We measure against the latest **`python/typing@main`**, recording the exact graded commit by hash in `conformance_report.json` (currently [`39164cd`](https://github.com/python/typing/tree/39164cd2c5b4e2608cb8b5d2fc8af9ae6f0c0fe8/conformance)). Today the official scorer, run unmodified in CI on the binary in its default configuration (the PEP conformance set; see [CHKARCH-CONFORMANCE-MODE](#CHKARCH-CONFORMANCE-MODE)), reports **141 of 141 files passing (100.0%)**, with **0 false positives** and **0 missed required errors** (970 caught). We run that suite in CI on every change; the gate ratchets the pass-percentage **up** and the false-positive ceiling **down** — closed only by fixing the checker, never by disabling a rule. #### Foundation PEPs {#CHKARCH-PEPS-FOUNDATION} @@ -1371,7 +1371,7 @@ that official check did not run against a freshly cloned suite is a BUILD FAILUR **down**. Per-file results are written to `conformance/conformance_status.csv`. - **Current score** — measured against `python/typing@main` at the exact graded commit recorded in `conformance_report.json`, currently - [`6ef9f77`](https://github.com/python/typing/tree/6ef9f7719ecfff09dad8724ef42b621fd994fb5e/conformance): + [`39164cd`](https://github.com/python/typing/tree/39164cd2c5b4e2608cb8b5d2fc8af9ae6f0c0fe8/conformance): **141 / 141 = 100.0%**, **0 false positives**, **0 missed required errors**, with **970** required errors caught. The binary runs in its default configuration — the PEP conformance set — over a fresh `python/typing` clone whose tree holds no diff --git a/vscode-extension/README.md b/vscode-extension/README.md index e5a704af..762c30c6 100644 --- a/vscode-extension/README.md +++ b/vscode-extension/README.md @@ -29,8 +29,8 @@

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

diff --git a/vscode-extension/README.zh.md b/vscode-extension/README.zh.md index a1d99784..a43b5915 100644 --- a/vscode-extension/README.zh.md +++ b/vscode-extension/README.zh.md @@ -28,8 +28,8 @@

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

diff --git a/website/src/_data/conformance_report.json b/website/src/_data/conformance_report.json index e6f774aa..9f3a39b9 100644 --- a/website/src/_data/conformance_report.json +++ b/website/src/_data/conformance_report.json @@ -3,13 +3,13 @@ "upstream": { "repo": "python/typing", "ref": "main", - "sha": "6ef9f7719ecfff09dad8724ef42b621fd994fb5e", - "shortSha": "6ef9f77", - "commitDate": "2026-07-11", + "sha": "39164cd2c5b4e2608cb8b5d2fc8af9ae6f0c0fe8", + "shortSha": "39164cd", + "commitDate": "2026-07-22", "stale": false }, "calculator": { - "file": "python/typing@6ef9f77:conformance/src/main.py", + "file": "python/typing@39164cd:conformance/src/main.py", "sha256": "3cb2a27bfc689e89a541528f8bdaa8ed24ae8845ce048eaff69717ef0205b112", "bytes": 10810, "funcs": [ From b01f196086384913486551875cb2503b5a169c3f Mon Sep 17 00:00:00 2001 From: abdushakoor12 Date: Thu, 23 Jul 2026 00:57:52 +0500 Subject: [PATCH 8/9] Refresh mutation baseline date after green local run (144 mutants, 100% kill rate, unchanged) --- mutation_testing/mutants_report.html | 1528 ++++++++++++------------- mutation_testing/mutation_scores.json | 2 +- 2 files changed, 765 insertions(+), 765 deletions(-) diff --git a/mutation_testing/mutants_report.html b/mutation_testing/mutants_report.html index c52d62a0..4c8b031d 100644 --- a/mutation_testing/mutants_report.html +++ b/mutation_testing/mutants_report.html @@ -62,7 +62,7 @@

Basilisk Mutation Report cargo-mutants v27.1.0

-
2026-07-21T01:48:25.113674Z → 2026-07-21T02:35:18.402279Z
+
2026-07-22T19:22:35.318154Z → 2026-07-22T19:57:32.98043Z
@@ -116,7 +116,7 @@

Basilisk Mutation Report cargo-mutants v27.1.0

crates/basilisk-checker/src/context.rs:59:13 CheckContext::from_config_with_source -> Self StructField - 82.8s + 79.8s
▶ show diff
@@ -147,7 +147,7 @@

Basilisk Mutation Report cargo-mutants v27.1.0

crates/basilisk-checker/src/rules/aliases_implicit.rs:61:5 collect_type_alias_names -> Vec FnValue vec![] - 109.5s + 97.9s
▶ show diff
@@ -201,15 +201,46 @@

Basilisk Mutation Report cargo-mutants v27.1.0

CAUGHT - crates/basilisk-checker/src/rules/aliases_implicit.rs:61:5 - collect_type_alias_names -> Vec FnValue - vec![String::new()] - 122.1s + crates/basilisk-checker/src/rules/aliases_implicit.rs:63:24 + collect_type_alias_names -> Vec BinaryOperator + == + 55.7s
▶ show diff
+ + CAUGHT + crates/basilisk-checker/src/rules/aliases_implicit.rs:61:5 + collect_type_alias_names -> Vec FnValue + vec!["xyzzy".into()] + 142.2s + + +
▶ show diff
+