From b687168e057f08d238efe4be205869263ec24d93 Mon Sep 17 00:00:00 2001 From: Brian Wamwea Date: Wed, 5 Aug 2026 15:01:53 +0300 Subject: [PATCH] Resolve relative imports by level (#369); accept complete enum-literal unions per upstream a490662 Relative from-imports (from ..sub import mod, from . import x) were resolved as absolute module names: StmtImportFrom.level was dropped when building ImportInfo, and resolve_relative_import was never called from the pipeline. Carry the level through ImportInfo, dispatch nonzero-level imports to the relative resolver in resolve_module_imports, and exempt relative imports from uv-registry classification and typeshed distribution lookup. Also teach assignment_compatibility the enums-expansion equivalence (a complete union of all literal members is equivalent to the enum type), required by python/typing@a490662's broadened enums_expansion test; the conformance gate holds at 141/141 (100%) with 0 false positives. Mutation baseline ratchets up to 139/146 caught (100% kill rate); conformance references re-stamp to the newly graded upstream commit. Refs #369 --- README-pypi.md | 4 +- README.md | 4 +- README.zh.md | 4 +- crates/basilisk-checker/src/imports/apply.rs | 47 +- .../basilisk-checker/src/imports/resolve.rs | 17 +- .../src/rules/assignment_compatibility/mod.rs | 81 +- .../src/rules/imports_unresolved.rs | 1 + .../src/rules/missing_type_stubs/tests.rs | 6 + .../src/rules/undeclared_dependency_import.rs | 1 + .../assignment_compatibility_2_tests.rs | 135 ++ .../tests/checker_rules_a_tests.rs | 2 + .../tests/import_support/mod.rs | 1 + .../tests/relative_import_resolution_tests.rs | 254 ++ .../src/scope/import_types.rs | 5 + .../src/visitor/class_info_ext.rs | 4 + docs/readme/README.src.md | 4 +- docs/readme/README.zh.src.md | 4 +- docs/specs/CHECKER-ARCHITECTURE-SPEC.md | 4 +- mutation_testing/mutants_report.html | 2116 +++++++++-------- mutation_testing/mutation_scores.json | 6 +- vscode-extension/README.md | 4 +- vscode-extension/README.zh.md | 4 +- website/src/_data/conformance_report.json | 8 +- 23 files changed, 1634 insertions(+), 1082 deletions(-) create mode 100644 crates/basilisk-checker/tests/checker/assignment_compatibility_2_tests.rs create mode 100644 crates/basilisk-checker/tests/relative_import_resolution_tests.rs diff --git a/README-pypi.md b/README-pypi.md index 05fcb3170..ec13e9936 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 0dc9b5d), scored on the wheel-installed CLI in its default config by the real upstream harness. + python/typing + conformance suite (commit a490662), 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 3898a712f..a56bf3890 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 0dc9b5d), scored on the wheel-installed CLI in its default config by the real upstream harness. + python/typing + conformance suite (commit a490662), 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 3828ac0c3..c7e84b522 100644 --- a/README.zh.md +++ b/README.zh.md @@ -28,8 +28,8 @@

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

diff --git a/crates/basilisk-checker/src/imports/apply.rs b/crates/basilisk-checker/src/imports/apply.rs index e70cdfc0c..29608649a 100644 --- a/crates/basilisk-checker/src/imports/apply.rs +++ b/crates/basilisk-checker/src/imports/apply.rs @@ -7,7 +7,9 @@ use basilisk_resolver::scope::{ImportKind, ImportedModuleApi, PackageDepKind}; use super::builtins::populate_builtin_classes; use super::fs_cache::FsCache; -use super::resolve::{classify_unresolved, resolve_module_with_importer_cached}; +use super::resolve::{ + classify_unresolved, resolve_module_with_importer_cached, resolve_relative_import_cached, +}; use super::ImportSearchPaths; /// Resolve every import in a single module against the search paths, in place. @@ -36,21 +38,32 @@ pub fn resolve_module_imports( let mut captured: Vec<(String, ImportedModuleApi)> = Vec::new(); for import in &mut resolved.imports { - let result = match import.kind { - ImportKind::Plain | ImportKind::From | ImportKind::Star => { - resolve_module_with_importer_cached( - &import.module, - search_paths, - Some(&importing_file), - &fs, - ) - } + // A `from`-import with leading dots resolves against the importing + // file's package, never the search paths (GitHub #369). The absolute + // path's importer-directory fallback only ever reached single-dot + // siblings by accident; parent packages need the level walk. + let result = if import.relative_level > 0 { + resolve_relative_import_cached( + &importing_file, + import.relative_level, + &import.module, + &fs, + ) + } else { + resolve_module_with_importer_cached( + &import.module, + search_paths, + Some(&importing_file), + &fs, + ) }; if let Some(r) = result { import.resolution = r.resolution; import.resolved_path = Some(r.path); - } else { + } else if import.relative_level == 0 { // Classify why the import is unresolved for actionable diagnostics. + // Relative imports are exempt: they name a workspace-local package, + // so the registry's "not installed" classification would mislead. import.unresolved_reason = Some(classify_unresolved(&import.module, search_paths)); } @@ -69,11 +82,15 @@ pub fn resolve_module_imports( captured.push((binding, api)); } - import.stub_distribution = - stub_distribution(&import.module, search_paths, Some(importing_file.as_path())); + // A relative import names a workspace-local module; it can never be a + // typeshed distribution or a uv-managed package. + if import.relative_level == 0 { + import.stub_distribution = + stub_distribution(&import.module, search_paths, Some(importing_file.as_path())); - // Annotate with package metadata from the uv registry. - enrich_package_metadata(import, search_paths); + // Annotate with package metadata from the uv registry. + enrich_package_metadata(import, search_paths); + } } for (binding, api) in captured { diff --git a/crates/basilisk-checker/src/imports/resolve.rs b/crates/basilisk-checker/src/imports/resolve.rs index 3c2ee435f..d9da4d30a 100644 --- a/crates/basilisk-checker/src/imports/resolve.rs +++ b/crates/basilisk-checker/src/imports/resolve.rs @@ -372,15 +372,26 @@ pub fn resolve_relative_import( module_name: &str, _search_paths: &ImportSearchPaths, ) -> Option { - let fs = FsCache::new(); + resolve_relative_import_cached(importing_file, level, module_name, &FsCache::new()) +} + +/// [`resolve_relative_import`] with a caller-supplied [`FsCache`]; the +/// per-module import loop in [`super::resolve_module_imports`] uses this so +/// every import of a file shares one set of directory listings. +pub(crate) fn resolve_relative_import_cached( + importing_file: &Path, + level: u32, + module_name: &str, + fs: &FsCache, +) -> Option { let mut base = importing_file.parent()?.to_path_buf(); for _ in 1..level { base = base.parent()?.to_path_buf(); } if module_name.is_empty() { - return try_resolve_init(&base, &fs); + return try_resolve_init(&base, fs); } - try_resolve_in_dir(module_name, &base, &fs) + try_resolve_in_dir(module_name, &base, fs) } /// Try resolving a dotted module name within a single directory. diff --git a/crates/basilisk-checker/src/rules/assignment_compatibility/mod.rs b/crates/basilisk-checker/src/rules/assignment_compatibility/mod.rs index b190cdf26..73fcfc7b6 100644 --- a/crates/basilisk-checker/src/rules/assignment_compatibility/mod.rs +++ b/crates/basilisk-checker/src/rules/assignment_compatibility/mod.rs @@ -32,7 +32,7 @@ use basilisk_resolver::{ResolvedModule, RhsKind, Span, VariableInfo}; use crate::diagnostic::{error_diagnostic_owned, Diagnostic, ErrorCode}; -use super::Rule; +use super::{guards::is_enum_class, Rule}; use dataclass_check::check_dataclass_attr_assignments; use literal_parse::infer_with_literal_value; @@ -66,6 +66,7 @@ impl Rule for AssignmentTypeMismatch { value_aliases: alias_match::collect_value_aliases(module), generic_aliases: alias_match::collect_generic_aliases(module), typeddict_schemas: typeddict_struct::build_typeddict_schemas(module), + enum_members: collect_enum_members(module), }; let call_index = callable_check::build_index(module); check_vars( @@ -152,6 +153,75 @@ fn collect_typeddict_names(module: &ResolvedModule) -> std::collections::HashSet names } +/// Collect each enum class's member names (both lowercased). Members are +/// class-body assignments with a value; `nonmember(...)` attributes and +/// sunder/dunder/private names are not members, and annotation-only +/// declarations (`x: int`) never are. +fn collect_enum_members( + module: &ResolvedModule, +) -> std::collections::HashMap> { + module + .classes + .iter() + .filter(|class| is_enum_class(class)) + .map(|class| { + let members = class + .attributes + .iter() + .filter(|attr| { + attr.has_value && !attr.rhs_is_nonmember_call && !attr.name.starts_with('_') + }) + .map(|attr| attr.name.to_ascii_lowercase()) + .collect(); + (class.name.to_ascii_lowercase(), members) + }) + .collect() +} + +/// The member names a `Literal[...]` annotation spells for `enum_name` +/// (`answer.yes` → `yes`), or `None` when any item is not a dotted member of +/// that enum. +fn literal_union_member_names<'decl>( + declared: &'decl InferredType, + enum_name: &str, +) -> Option> { + let items = match declared { + InferredType::Union(items) => items.as_slice(), + single => std::slice::from_ref(single), + }; + items + .iter() + .map(|item| { + let InferredType::Named(item_name) = item else { + return None; + }; + item_name + .strip_prefix(enum_name) + .and_then(|rest| rest.strip_prefix('.')) + }) + .collect() +} + +/// Implements the enums-expansion equivalence (typing spec, enums chapter): +/// a complete union of all literal members is equivalent to the enum type, so +/// an enum-typed value is assignable to `Literal[E.A, E.B]` when the union +/// names EVERY member of `E`. Incomplete unions still mismatch. +fn enum_complete_union_assignable( + inferred: &InferredType, + declared: &InferredType, + enums: &std::collections::HashMap>, +) -> bool { + let InferredType::Named(enum_name) = inferred else { + return false; + }; + let Some(members) = enums.get(enum_name) else { + return false; + }; + literal_union_member_names(declared, enum_name).is_some_and(|named| { + !members.is_empty() && members.iter().map(String::as_str).eq(named.iter().copied()) + }) +} + /// Collect names of PEP 695 type aliases defined in this module (lowercased). /// /// E0014 cannot evaluate expanded type alias types, so annotations that @@ -186,6 +256,10 @@ struct SkipNames { /// used for PEP 705 structural assignability of `TypedDict`-to-`TypedDict` /// assignments instead of name equality. typeddict_schemas: typeddict_struct::TdSchemas, + /// Enum member names per enum class (both lowercase), for the + /// enums-expansion equivalence: a complete union of all literal members is + /// equivalent to the enum type (typing spec, enums chapter). + enum_members: std::collections::HashMap>, } /// Collection literals are checked in the annotation's expected-type context. @@ -421,6 +495,11 @@ fn check_vars( if inferred_type.is_assignable_to(&declared_type) || literal_collection_assignable(var, &inferred_type, &declared_type, skip) + || enum_complete_union_assignable( + &inferred_type, + &declared_type, + &skip.enum_members, + ) { None } else if callable_rescue(var, source, annotation_text, params, call_index) { diff --git a/crates/basilisk-checker/src/rules/imports_unresolved.rs b/crates/basilisk-checker/src/rules/imports_unresolved.rs index 230fcb988..18d8d007a 100644 --- a/crates/basilisk-checker/src/rules/imports_unresolved.rs +++ b/crates/basilisk-checker/src/rules/imports_unresolved.rs @@ -141,6 +141,7 @@ mod tests { fn make_import(module: &str, reason: Option) -> ImportInfo { ImportInfo { module: module.to_owned(), + relative_level: 0, names: vec![], span: Span::new(0, 15), name_spans: Vec::new(), diff --git a/crates/basilisk-checker/src/rules/missing_type_stubs/tests.rs b/crates/basilisk-checker/src/rules/missing_type_stubs/tests.rs index bdf4307ae..65dea7bb2 100644 --- a/crates/basilisk-checker/src/rules/missing_type_stubs/tests.rs +++ b/crates/basilisk-checker/src/rules/missing_type_stubs/tests.rs @@ -36,6 +36,7 @@ fn make_import( ) -> ImportInfo { ImportInfo { module: module.to_owned(), + relative_level: 0, names: vec![], span: Span::new(0, span_end), name_spans: Vec::new(), @@ -230,6 +231,7 @@ fn skips_site_packages_package_with_py_typed_marker() -> Result<(), Box Result<(), Box ImportInfo { ImportInfo { module: module.to_owned(), + relative_level: 0, names: vec![], span: Span::new(0, span_end), name_spans: Vec::new(), diff --git a/crates/basilisk-checker/tests/checker/assignment_compatibility_2_tests.rs b/crates/basilisk-checker/tests/checker/assignment_compatibility_2_tests.rs new file mode 100644 index 000000000..e3f6ce96c --- /dev/null +++ b/crates/basilisk-checker/tests/checker/assignment_compatibility_2_tests.rs @@ -0,0 +1,135 @@ +//! Tests for [`assignment_compatibility`] from [CHKARCH-DIAG-TYPESAFETY]. See docs/specs/CHECKER-ARCHITECTURE-SPEC.md#CHKARCH-DIAG-TYPESAFETY +// Enum-expansion equivalence (typing spec, enums chapter): "a type checker +// should treat a complete union of all literal members as equivalent to the +// enum type". Added for python/typing@a490662's broadened enums_expansion +// conformance test (test4): an enum-typed value assigned to a Literal union +// naming EVERY member must not fire assignment_compatibility. + +use super::common::*; + +#[test] +fn enum_assigned_to_complete_literal_union_no_diagnostic() -> Result<(), Box> +{ + // Mirrors conformance enums_expansion.py test4: Literal[Answer.Yes, + // Answer.No] covers every member of Answer, so it is equivalent to Answer. + let source = r#" +from enum import Enum +from typing import Literal + +class Answer(Enum): + Yes = 1 + No = 2 + +def test4(a: Answer) -> None: + x: Literal[Answer.Yes, Answer.No] = a +"#; + let diags = run(source)?; + let msgs = messages_for(&diags, "assignment_compatibility"); + assert!( + msgs.is_empty(), + "a complete union of all literal members is equivalent to the enum \ + type (typing spec, enums chapter); should not fire, got: {msgs:?}" + ); + Ok(()) +} + +#[test] +fn enum_assigned_to_incomplete_literal_union_fires() -> Result<(), Box> { + // Maybe is missing from the union, so Answer is NOT assignable to it. + let source = r#" +from enum import Enum +from typing import Literal + +class Answer(Enum): + Yes = 1 + No = 2 + Maybe = 3 + +def test(a: Answer) -> None: + x: Literal[Answer.Yes, Answer.No] = a +"#; + let diags = run(source)?; + let msgs = messages_for(&diags, "assignment_compatibility"); + assert!( + !msgs.is_empty(), + "an INCOMPLETE literal union must still reject the enum type, got no diagnostic" + ); + Ok(()) +} + +#[test] +fn single_member_enum_assigned_to_its_literal_no_diagnostic( +) -> Result<(), Box> { + // A one-member enum's single literal IS the complete union. + let source = r#" +from enum import Enum +from typing import Literal + +class Single(Enum): + ONLY = 1 + +def test(s: Single) -> None: + x: Literal[Single.ONLY] = s +"#; + let diags = run(source)?; + let msgs = messages_for(&diags, "assignment_compatibility"); + assert!( + msgs.is_empty(), + "Literal[Single.ONLY] is the complete union of a one-member enum; \ + should not fire, got: {msgs:?}" + ); + Ok(()) +} + +#[test] +fn nonmember_attribute_does_not_count_toward_completeness() -> Result<(), Box> +{ + // `nonmember(...)` attributes and sunder/dunder names are not enum + // members, so the union of the two real members is still complete. + let source = r#" +from enum import Enum, nonmember +from typing import Literal + +class Answer(Enum): + Yes = 1 + No = 2 + helper = nonmember(3) + +def test(a: Answer) -> None: + x: Literal[Answer.Yes, Answer.No] = a +"#; + let diags = run(source)?; + let msgs = messages_for(&diags, "assignment_compatibility"); + assert!( + msgs.is_empty(), + "nonmember attributes are not members; Literal[Yes, No] is complete, got: {msgs:?}" + ); + Ok(()) +} + +#[test] +fn literal_union_of_wrong_enum_still_fires() -> Result<(), Box> { + // The union names another enum's members; assigning Answer must fire. + let source = r#" +from enum import Enum +from typing import Literal + +class Answer(Enum): + Yes = 1 + No = 2 + +class Other(Enum): + A = 1 + B = 2 + +def test(a: Answer) -> None: + x: Literal[Other.A, Other.B] = a +"#; + let diags = run(source)?; + let msgs = messages_for(&diags, "assignment_compatibility"); + assert!( + !msgs.is_empty(), + "a literal union of a DIFFERENT enum's members must reject the value, got no diagnostic" + ); + Ok(()) +} diff --git a/crates/basilisk-checker/tests/checker_rules_a_tests.rs b/crates/basilisk-checker/tests/checker_rules_a_tests.rs index ad2098ccd..3bafa0815 100644 --- a/crates/basilisk-checker/tests/checker_rules_a_tests.rs +++ b/crates/basilisk-checker/tests/checker_rules_a_tests.rs @@ -18,6 +18,8 @@ mod annotations_typeexpr; #[path = "checker/assignment_compatibility_tests.rs"] mod assignment_compatibility; +#[path = "checker/assignment_compatibility_2_tests.rs"] +mod assignment_compatibility_2; #[path = "checker/callables_annotation_tests.rs"] mod callables_annotation; #[path = "checker/calls_argument_type_tests.rs"] diff --git a/crates/basilisk-checker/tests/import_support/mod.rs b/crates/basilisk-checker/tests/import_support/mod.rs index b9cbe4aff..1cea6e8b5 100644 --- a/crates/basilisk-checker/tests/import_support/mod.rs +++ b/crates/basilisk-checker/tests/import_support/mod.rs @@ -117,6 +117,7 @@ pub fn module_with_plain_imports(modules: &[&str]) -> basilisk_resolver::Resolve .iter() .map(|module| basilisk_resolver::ImportInfo { module: (*module).to_owned(), + relative_level: 0, names: vec![], span: basilisk_resolver::Span::new(0, 0), name_spans: Vec::new(), diff --git a/crates/basilisk-checker/tests/relative_import_resolution_tests.rs b/crates/basilisk-checker/tests/relative_import_resolution_tests.rs new file mode 100644 index 000000000..e06ae765b --- /dev/null +++ b/crates/basilisk-checker/tests/relative_import_resolution_tests.rs @@ -0,0 +1,254 @@ +//! Tests for [ANALYSIS-INCR-IMPORTS]. See docs/specs/LSP-ANALYSIS-MODES-SPEC.md#ANALYSIS-INCR-IMPORTS +#![allow( + clippy::allow_attributes, + clippy::unwrap_used, + clippy::expect_used, + clippy::indexing_slicing, + missing_docs +)] +//! Relative-import resolution through the full `resolve_module_imports` +//! pipeline (GitHub #369): `from ..sub import mod` must resolve by walking up +//! from the importing file's package, not by treating `sub` as an absolute +//! module. The standalone `resolve_relative_import` unit tests in +//! `import_resolution_tests.rs` pass in isolation — these tests pin the wiring +//! from the parsed AST (`StmtImportFrom.level`) through `ImportInfo` to the +//! resolver dispatch in `basilisk_checker::imports::resolve_module_imports`. + +use std::fs; +use std::path::Path; + +use basilisk_resolver::scope::ImportResolution; + +mod import_support; +use import_support::{make_search_paths, unique_tmp}; + +/// The issue #369 package layout: +/// +/// ```text +/// /src/pkg/__init__.py +/// /src/pkg/sub/__init__.py +/// /src/pkg/sub/mod.py +/// /src/pkg/dev/__init__.py +/// /src/pkg/dev/other.py +/// ``` +/// +/// Returns the workspace root; the importing file lives in `src/pkg/dev/`. +fn make_issue_369_layout(prefix: &str) -> std::path::PathBuf { + let root = unique_tmp(prefix); + let dev = root.join("src").join("pkg").join("dev"); + let sub = root.join("src").join("pkg").join("sub"); + fs::create_dir_all(&dev).unwrap(); + fs::create_dir_all(&sub).unwrap(); + fs::write(root.join("src").join("pkg").join("__init__.py"), "").unwrap(); + fs::write(dev.join("__init__.py"), "").unwrap(); + fs::write(dev.join("other.py"), "class Bar: pass\n").unwrap(); + fs::write(sub.join("__init__.py"), "").unwrap(); + fs::write(sub.join("mod.py"), "class Foo: pass\n").unwrap(); + root +} + +/// Parse `source` as a file at `importing_file` and run the real pipeline: +/// visitor capture (`basilisk_resolver::resolve`) followed by +/// `resolve_module_imports` — the exact path the CLI and the salsa +/// `resolved_module` query share. +fn resolve_imports_of( + source: &str, + importing_file: &Path, + root: &Path, +) -> basilisk_resolver::ResolvedModule { + let parsed = basilisk_parser::parse_source( + source.to_owned(), + importing_file.to_string_lossy().into_owned(), + ) + .expect("fixture parses"); + let mut resolved = basilisk_resolver::resolve(&parsed).expect("fixture resolves"); + let paths = make_search_paths(vec![root.to_path_buf()]); + basilisk_checker::imports::resolve_module_imports(&mut resolved, &paths); + resolved +} + +/// Issue #369 case 2: `from ..sub import mod` — double dot, bare module +/// target. Must resolve to the sibling package's `__init__.py`, one level up +/// from the importing file's package. +#[test] +fn double_dot_bare_module_target_resolves_to_parent_sibling_package() { + let root = make_issue_369_layout("bsk_rel369_dotdot"); + let importing = root.join("src").join("pkg").join("dev").join("user.py"); + + let resolved = resolve_imports_of("from ..sub import mod\n", &importing, &root); + + let import = &resolved.imports[0]; + assert_ne!( + import.resolution, + ImportResolution::Unresolved, + "`from ..sub import mod` must resolve `..sub` relative to the \ + importing package (GitHub #369); got unresolved_reason {:?}", + import.unresolved_reason + ); + let path = import.resolved_path.as_ref().unwrap(); + assert!( + path.ends_with(Path::new("src/pkg/sub/__init__.py")), + "`..sub` must resolve to src/pkg/sub/__init__.py, got {path:?}" + ); + + let _ = fs::remove_dir_all(&root); +} + +/// Issue #369 case 3: `from ..sub.mod import Foo` — double dot, dotted +/// attribute target. Must resolve to `src/pkg/sub/mod.py`. +#[test] +fn double_dot_dotted_module_target_resolves_through_parent_package() { + let root = make_issue_369_layout("bsk_rel369_dotted"); + let importing = root.join("src").join("pkg").join("dev").join("user.py"); + + let resolved = resolve_imports_of("from ..sub.mod import Foo\n", &importing, &root); + + let import = &resolved.imports[0]; + assert_ne!( + import.resolution, + ImportResolution::Unresolved, + "`from ..sub.mod import Foo` must resolve relative to the importing \ + package (GitHub #369); got unresolved_reason {:?}", + import.unresolved_reason + ); + let path = import.resolved_path.as_ref().unwrap(); + assert!( + path.ends_with(Path::new("src/pkg/sub/mod.py")), + "`..sub.mod` must resolve to src/pkg/sub/mod.py, got {path:?}" + ); + + let _ = fs::remove_dir_all(&root); +} + +/// `from . import other` — bare single dot with no module path. The import +/// target is the importing file's own package `__init__.py`; the bound name +/// `other` is a sibling submodule. +#[test] +fn bare_dot_import_resolves_to_own_package_init() { + let root = make_issue_369_layout("bsk_rel369_bare"); + let importing = root.join("src").join("pkg").join("dev").join("user.py"); + + let resolved = resolve_imports_of("from . import other\n", &importing, &root); + + let import = &resolved.imports[0]; + assert_ne!( + import.resolution, + ImportResolution::Unresolved, + "`from . import other` must resolve to the importing file's package \ + __init__.py (GitHub #369); got unresolved_reason {:?}", + import.unresolved_reason + ); + let path = import.resolved_path.as_ref().unwrap(); + assert!( + path.ends_with(Path::new("src/pkg/dev/__init__.py")), + "`from . import other` must resolve to src/pkg/dev/__init__.py, got {path:?}" + ); + + let _ = fs::remove_dir_all(&root); +} + +/// `from .. import sub` — bare double dot binding a subpackage of the parent. +#[test] +fn bare_double_dot_import_resolves_to_parent_package_init() { + let root = make_issue_369_layout("bsk_rel369_bare2"); + let importing = root.join("src").join("pkg").join("dev").join("user.py"); + + let resolved = resolve_imports_of("from .. import sub\n", &importing, &root); + + let import = &resolved.imports[0]; + assert_ne!( + import.resolution, + ImportResolution::Unresolved, + "`from .. import sub` must resolve to the parent package __init__.py \ + (GitHub #369); got unresolved_reason {:?}", + import.unresolved_reason + ); + let path = import.resolved_path.as_ref().unwrap(); + assert!( + path.ends_with(Path::new("src/pkg/__init__.py")), + "`from .. import sub` must resolve to src/pkg/__init__.py, got {path:?}" + ); + + let _ = fs::remove_dir_all(&root); +} + +/// `from ..sub import *` — the star-import form must dispatch through the same +/// relative resolution as the named form. +#[test] +fn double_dot_star_import_resolves_to_parent_sibling_package() { + let root = make_issue_369_layout("bsk_rel369_star"); + let importing = root.join("src").join("pkg").join("dev").join("user.py"); + + let resolved = resolve_imports_of("from ..sub import *\n", &importing, &root); + + let import = &resolved.imports[0]; + assert_ne!( + import.resolution, + ImportResolution::Unresolved, + "`from ..sub import *` must resolve `..sub` relative to the importing \ + package (GitHub #369); got unresolved_reason {:?}", + import.unresolved_reason + ); + let path = import.resolved_path.as_ref().unwrap(); + assert!( + path.ends_with(Path::new("src/pkg/sub/__init__.py")), + "`..sub` must resolve to src/pkg/sub/__init__.py, got {path:?}" + ); + + let _ = fs::remove_dir_all(&root); +} + +/// Issue #369 case 1: `from .other import Bar` — single dot, sibling module. +/// Passes today only by accident (the absolute resolver's importer-directory +/// fallback); pinned here so the relative dispatch keeps it working on purpose. +#[test] +fn single_dot_sibling_module_resolves() { + let root = make_issue_369_layout("bsk_rel369_dot"); + let importing = root.join("src").join("pkg").join("dev").join("user.py"); + + let resolved = resolve_imports_of("from .other import Bar\n", &importing, &root); + + let import = &resolved.imports[0]; + assert_ne!(import.resolution, ImportResolution::Unresolved); + let path = import.resolved_path.as_ref().unwrap(); + assert!( + path.ends_with(Path::new("src/pkg/dev/other.py")), + "`.other` must resolve to src/pkg/dev/other.py, got {path:?}" + ); + + let _ = fs::remove_dir_all(&root); +} + +/// A relative import whose target genuinely does not exist stays unresolved — +/// the fix must add resolution, not blanket-accept every nonzero level. +#[test] +fn missing_relative_target_stays_unresolved() { + let root = make_issue_369_layout("bsk_rel369_missing"); + let importing = root.join("src").join("pkg").join("dev").join("user.py"); + + let resolved = resolve_imports_of("from ..nonexistent import x\n", &importing, &root); + + let import = &resolved.imports[0]; + assert_eq!( + import.resolution, + ImportResolution::Unresolved, + "a relative import of a module that does not exist must stay unresolved" + ); + + let _ = fs::remove_dir_all(&root); +} + +/// An absolute import is untouched by the relative dispatch: level 0 keeps the +/// full search-path walk (here: workspace-root resolution of `src`-less +/// absolute names still fails, exactly as before). +#[test] +fn absolute_import_still_walks_search_paths() { + let root = make_issue_369_layout("bsk_rel369_abs"); + let importing = root.join("src").join("pkg").join("dev").join("user.py"); + // `src` is not on the search path as a package root, so the absolute name + // `pkg.sub` must NOT resolve — only the relative form reaches it. + let resolved = resolve_imports_of("from pkg.sub import mod\n", &importing, &root); + assert_eq!(resolved.imports[0].resolution, ImportResolution::Unresolved); + + let _ = fs::remove_dir_all(&root); +} diff --git a/crates/basilisk-resolver/src/scope/import_types.rs b/crates/basilisk-resolver/src/scope/import_types.rs index 6d6adb4c5..3c86abbcf 100644 --- a/crates/basilisk-resolver/src/scope/import_types.rs +++ b/crates/basilisk-resolver/src/scope/import_types.rs @@ -71,7 +71,12 @@ pub enum UnresolvedReason { #[derive(Debug, Clone, PartialEq)] pub struct ImportInfo { /// The dotted module name being imported (e.g. `"os.path"`, `"requests"`). + /// Empty for a bare relative import (`from . import x`). pub module: String, + /// Number of leading dots on a `from`-import (`from ..sub import x` → 2); + /// `0` for absolute imports. A nonzero level resolves `module` relative to + /// the importing file's package instead of the search paths (GitHub #369). + pub relative_level: u32, /// Locally-bound names introduced by the import. /// `from X import A, B` → `["A", "B"]` (alias-aware: `import C as D` → `["D"]`). /// Plain `import X` / `import X.Y` is empty — the bound name is the top-level diff --git a/crates/basilisk-resolver/src/visitor/class_info_ext.rs b/crates/basilisk-resolver/src/visitor/class_info_ext.rs index c8e7d0dd7..f46b145d2 100644 --- a/crates/basilisk-resolver/src/visitor/class_info_ext.rs +++ b/crates/basilisk-resolver/src/visitor/class_info_ext.rs @@ -359,6 +359,8 @@ pub(super) fn import_infos_from(node: &StmtImport) -> Vec { .iter() .map(|alias| ImportInfo { module: alias.name.to_string(), + // Plain `import X` is always absolute; Python has no relative form. + relative_level: 0, // `import X as Y` binds `Y`, not the module name — capture the alias so // scope-resolution rules (e.g. names_undefined) see the real binding. Plain // `import X` / `import X.Y` keeps `names` empty; its bound name is the @@ -410,6 +412,7 @@ pub(super) fn import_from_infos_from(node: &StmtImportFrom) -> Vec { if is_star { return vec![ImportInfo { module, + relative_level: node.level, names: Vec::new(), span: text_range_to_span(node.range), name_spans: module_span.into_iter().collect(), @@ -431,6 +434,7 @@ pub(super) fn import_from_infos_from(node: &StmtImportFrom) -> Vec { .collect(); vec![ImportInfo { module, + relative_level: node.level, names, span: text_range_to_span(node.range), name_spans, diff --git a/docs/readme/README.src.md b/docs/readme/README.src.md index d9e008122..2ed4c7fb4 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 0dc9b5d), scored on the wheel-installed CLI in its default config by the real upstream harness. + python/typing + conformance suite (commit a490662), 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 e4c387387..fc2b5a7ad 100644 --- a/docs/readme/README.zh.src.md +++ b/docs/readme/README.zh.src.md @@ -35,8 +35,8 @@

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

diff --git a/docs/specs/CHECKER-ARCHITECTURE-SPEC.md b/docs/specs/CHECKER-ARCHITECTURE-SPEC.md index f5f36594d..f5246ff21 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 [`0dc9b5d`](https://github.com/python/typing/tree/0dc9b5d23b368713af33ac25338eeb08b80f6360/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 [`a490662`](https://github.com/python/typing/tree/a4906624f170c169cf667f962080c56d5a5ba6ff/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} @@ -1464,7 +1464,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 - [`0dc9b5d`](https://github.com/python/typing/tree/0dc9b5d23b368713af33ac25338eeb08b80f6360/conformance): + [`a490662`](https://github.com/python/typing/tree/a4906624f170c169cf667f962080c56d5a5ba6ff/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/mutation_testing/mutants_report.html b/mutation_testing/mutants_report.html index ee709c0a1..9f6a39b70 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-08-02T08:34:40.287272Z → 2026-08-02T09:26:46.50831Z
+
2026-08-05T10:46:22.208794Z → 2026-08-05T11:57:58.608647Z
@@ -71,7 +71,7 @@

Basilisk Mutation Report cargo-mutants v27.1.0

Mutation Score
-
145
+
146
Total Mutants
@@ -79,7 +79,7 @@

Basilisk Mutation Report cargo-mutants v27.1.0

Missed
-
138
+
139
Caught
@@ -94,7 +94,7 @@

Basilisk Mutation Report cargo-mutants v27.1.0

Missed (0)
-
Caught (138)
+
Caught (139)
Other (7)
@@ -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 - 63.1s + 143.4s
▶ 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![] - 70.6s + 207.8s
▶ show diff
@@ -203,13 +203,13 @@

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!["xyzzy".into()] - 60.8s + vec![String::new()] + 217.7s
▶ show diff
- - CAUGHT - crates/basilisk-checker/src/rules/aliases_implicit.rs:63:24 - collect_type_alias_names -> Vec BinaryOperator - == - 57.6s - - -
▶ show diff
- CAUGHT crates/basilisk-checker/src/rules/aliases_implicit.rs:61:5 collect_type_alias_names -> Vec FnValue - vec![String::new()] - 145.8s + vec!["xyzzy".into()] + 80.8s -
▶ show diff
-
@@ -5135,7 +5171,7 @@

Basilisk Mutation Report cargo-mutants v27.1.0

crates/basilisk-checker/src/rules/match_exhaustiveness.rs:48:5 make_diagnostic -> Diagnostic FnValue Default::default() - 5.9s + 6.0s
▶ show diff
@@ -5170,11 +5206,11 @@

Basilisk Mutation Report cargo-mutants v27.1.0

crates/basilisk-checker/src/rules/missing_parameter_annotation.rs:103:5 make_diagnostic -> Diagnostic FnValue Default::default() - 5.2s + 12.8s -
▶ show diff
-