From ffa94cb6f6bba43a99e81d20efd0970e7fa694ea Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 6 Jun 2026 04:37:08 +0000 Subject: [PATCH] Skip re-checking files unaffected by a type change Steep re-type-checks every source file and re-validates every signature file on each change. This adds incremental re-checking: a file is re-processed only when the change could actually affect its diagnostics, and otherwise its cached result is reused. The decision combines two sets: * The referencing side ("Refs"). While type-checking a source file we collect the RBS type names it touches (Steep::TypeNameReferences); while validating a signature file we collect the names the validator touches (Signature::Validator#referenced_type_names). The set is a sound over-approximation of the file's real dependencies. * The changed side. The signature service computes the closure of type names whose definition changed in an update (last_changed_type_names), expanding over descendants (inheritance/mixin), nested declarations, and type alias dependencies. A file is re-checked only when its referenced set intersects the changed set. The need-to-recheck is recorded on the cached file (an `outdated` flag) rather than per job, so it survives even when a file's re-check is deferred across cycles. SourceFile gains referenced_type_names/outdated, and a new SignatureFile holds the per-file validation diagnostics, refs and outdated flag on the signature side. Soundness for newly introduced types: a file with an unresolved reference (e.g. a constant whose declaration does not exist yet) has an incomplete reference set, so it is always re-checked until the reference resolves. This keeps the rbs-inline workflow correct, where a `.rb` file is checked before its generated `.rbs` exists, leaving the type it defines unresolved until the signature appears. https://claude.ai/code/session_017Dsb3STHRZuLYYvgyXrBRG --- lib/steep.rb | 1 + lib/steep/services/signature_service.rb | 49 +++++ lib/steep/services/type_check_service.rb | 172 +++++++++++++++- lib/steep/signature/validator.rb | 10 + lib/steep/type_name_references.rb | 83 ++++++++ sig/steep/services/signature_service.rbs | 8 + sig/steep/services/type_check_service.rbs | 45 ++++- sig/steep/signature/validator.rbs | 2 + sig/steep/type_name_references.rbs | 26 +++ sig/test/type_check_service_test.rbs | 28 +++ sig/test/type_check_worker_test.rbs | 4 + sig/test/validation_test.rbs | 11 + test/signature_service_test.rb | 148 ++++++++++++++ test/type_check_service_test.rb | 232 ++++++++++++++++++++++ test/type_check_worker_test.rb | 144 ++++++++++++++ test/type_name_references_test.rb | 151 ++++++++++++++ test/validation_test.rb | 80 ++++++++ 17 files changed, 1179 insertions(+), 15 deletions(-) create mode 100644 lib/steep/type_name_references.rb create mode 100644 sig/steep/type_name_references.rbs create mode 100644 test/signature_service_test.rb create mode 100644 test/type_name_references_test.rb diff --git a/lib/steep.rb b/lib/steep.rb index 119c3e72c..3681abde1 100644 --- a/lib/steep.rb +++ b/lib/steep.rb @@ -85,6 +85,7 @@ require "steep/annotation_parser" require "steep/typing" require "steep/type_construction" +require "steep/type_name_references" require "steep/type_inference/context" require "steep/type_inference/send_args" require "steep/type_inference/block_params" diff --git a/lib/steep/services/signature_service.rb b/lib/steep/services/signature_service.rb index 90bcb6b36..d29acfd2d 100644 --- a/lib/steep/services/signature_service.rb +++ b/lib/steep/services/signature_service.rb @@ -80,9 +80,19 @@ def constant_resolver attr_reader :implicitly_returns_nil + # Type names whose definitions changed in the most recent `#update`, + # already including the closure over descendants and nested declarations + # so consumers need not expand it themselves. + attr_reader :last_changed_type_names + def initialize(status:, implicitly_returns_nil:) @status = status @implicitly_returns_nil = implicitly_returns_nil + @last_changed_type_names = Set[] + end + + def reset_last_changed_type_names + @last_changed_type_names = Set[] end def self.load_from(loader, implicitly_returns_nil:) @@ -393,6 +403,11 @@ def update_builder(ancestor_builder:, paths:) add_descendants(graph: new_graph, names: new_names, set: changed_names) add_nested_decls(env: new_env, names: new_names, set: changed_names) + add_type_alias_dependencies(env: old_env, set: changed_names) + add_type_alias_dependencies(env: new_env, set: changed_names) + + @last_changed_type_names = changed_names + old_definition_builder.update( env: new_env, ancestor_builder: new_ancestor_builder, @@ -498,6 +513,40 @@ def add_nested_decls(env:, names:, set:) end end end + + # Adds aliases that (transitively) reference any name already in `set`: + # changing a referenced type changes the alias's meaning. Doing this here, + # on the changed side, lets a referencing file record only the alias name. + def add_type_alias_dependencies(env:, set:) + referenced_by = {} #: Hash[RBS::TypeName, Set[RBS::TypeName]] + + env.type_alias_decls.each do |alias_name, entry| + each_type_name_in(entry.decl.type) do |referenced| + (referenced_by[referenced] ||= Set[]) << alias_name + end + end + + queue = set.to_a + until queue.empty? + name = queue.shift or break + (referenced_by[name] || Set[]).each do |alias_name| + if set.add?(alias_name) + queue << alias_name + end + end + end + end + + def each_type_name_in(type, &block) + case type + when RBS::Types::ClassInstance, RBS::Types::ClassSingleton, RBS::Types::Interface, RBS::Types::Alias + yield type.name + end + + type.each_type do |child| + each_type_name_in(child, &block) + end + end end end end diff --git a/lib/steep/services/type_check_service.rb b/lib/steep/services/type_check_service.rb index 28f3f4b3f..26974442f 100644 --- a/lib/steep/services/type_check_service.rb +++ b/lib/steep/services/type_check_service.rb @@ -2,7 +2,7 @@ module Steep module Services class TypeCheckService attr_reader :project - attr_reader :signature_validation_diagnostics + attr_reader :signature_files attr_reader :source_files attr_reader :signature_services @@ -15,21 +15,38 @@ class SourceFile attr_reader :errors attr_reader :ignores - def initialize(path:, node:, content:, typing:, ignores:, errors:) + # RBS type names referenced while type-checking this file (see TypeNameReferences). + attr_reader :referenced_type_names + + # True when the cached `typing`/`referenced_type_names` predate the + # current `content` and must be recomputed before being reused. + attr_reader :outdated + + # True when the last check left a reference unresolved (e.g. an unknown + # constant). Such a reference is absent from `referenced_type_names`, so a + # type added later would not intersect it; while set, the file is + # re-checked on every type change until the reference resolves. This keeps + # rbs-inline sound, where a `.rb` is checked before its generated `.rbs`. + attr_reader :has_unresolved_references + + def initialize(path:, node:, content:, typing:, ignores:, errors:, referenced_type_names: Set[], outdated: false, has_unresolved_references: false) @path = path @node = node @content = content @typing = typing @ignores = ignores @errors = errors + @referenced_type_names = referenced_type_names + @outdated = outdated + @has_unresolved_references = has_unresolved_references end def self.with_syntax_error(path:, content:, error:) new(path: path, node: false, content: content, errors: [error], typing: nil, ignores: nil) end - def self.with_typing(path:, content:, typing:, node:, ignores:) - new(path: path, node: node, content: content, errors: nil, typing: typing, ignores: ignores) + def self.with_typing(path:, content:, typing:, node:, ignores:, referenced_type_names: Set[], has_unresolved_references: false) + new(path: path, node: node, content: content, errors: nil, typing: typing, ignores: ignores, referenced_type_names: referenced_type_names, has_unresolved_references: has_unresolved_references) end def self.no_data(path:, content:) @@ -43,10 +60,20 @@ def update_content(content) node: node, errors: errors, typing: typing, - ignores: ignores + ignores: ignores, + referenced_type_names: referenced_type_names, + has_unresolved_references: has_unresolved_references, + outdated: true ) end + # Flags the cached result as stale (a referenced type changed). Sticky + # until the file is re-checked, so a deferred re-check is never lost. + def mark_outdated! + @outdated = true + self + end + def diagnostics case when errors @@ -99,6 +126,32 @@ def diagnostics end end + # Per-file validation state for a signature file (`.rbs`, or `.rb` with + # inline RBS) in one target: the signature-side analogue of `SourceFile` + # for incremental skipping. Stores diagnostics directly, since validation + # has no `Typing`-like result to derive them from on demand. + class SignatureFile + attr_reader :diagnostics + + # Type names touched while validating this file (cf. SourceFile#referenced_type_names). + attr_reader :referenced_type_names + + # True when a referenced type changed after validation; sticky until the + # file is re-validated, so a deferred re-validation is never lost. + attr_reader :outdated + + def initialize(diagnostics:, referenced_type_names:, outdated: false) + @diagnostics = diagnostics + @referenced_type_names = referenced_type_names + @outdated = outdated + end + + def mark_outdated! + @outdated = true + self + end + end + def initialize(project:) @project = project @@ -107,7 +160,8 @@ def initialize(project:) loader = Project::Target.construct_env_loader(options: target.options, project: project) hash[target.name] = SignatureService.load_from(loader, implicitly_returns_nil: target.implicitly_returns_nil) end - @signature_validation_diagnostics = project.targets.each.with_object({}) do |target, hash| #$ Hash[Symbol, Hash[Pathname, Array[Diagnostic::Signature::Base]]] + # Cached SignatureFile per target then path (a signature is validated per target). + @signature_files = project.targets.each.with_object({}) do |target, hash| #$ Hash[Symbol, Hash[Pathname, SignatureFile]] hash[target.name] = {} end end @@ -132,9 +186,9 @@ def signature_diagnostics end end when SignatureService::LoadedStatus - validation_diagnostics = signature_validation_diagnostics[target.name] || {} - validation_diagnostics.each do |path, diagnostics| - signature_diagnostics.fetch(path).push(*diagnostics) + files = signature_files[target.name] || {} + files.each do |path, file| + signature_diagnostics.fetch(path).push(*file.diagnostics) end end end @@ -161,6 +215,9 @@ def each_diagnostics(&block) end def update(changes:) + # Each target's changed-name set describes only this update; clear stale ones. + signature_services.each_value(&:reset_last_changed_type_names) + Steep.measure "#update_signature" do update_signature(changes: changes) end @@ -168,9 +225,66 @@ def update(changes:) Steep.measure "#update_sources" do update_sources(changes: changes) end + + invalidate_outdated_source_files() + invalidate_outdated_signature_files() + end + + # Marks each cached source file whose referenced types intersect this + # update's changed names as outdated. Recorded on the file (not per job) so + # a re-check deferred to a later cycle is not lost when changed_names moves on. + def invalidate_outdated_source_files + source_files.each_value do |file| + next if file.outdated + next unless file.typing + + target = project.target_for_source_path(file.path) || project.target_for_inline_source_path(file.path) + next unless target + + # target is a project target, so the service always exists. + signature_service = signature_services.fetch(target.name) + + changed_names = signature_service.last_changed_type_names + next if changed_names.empty? + + if file.referenced_type_names.intersect?(changed_names) + file.mark_outdated! + end + end + end + + # Signature-side counterpart of #invalidate_outdated_source_files. A file's + # own edit is covered too: its defined names are part of both sets. + def invalidate_outdated_signature_files + signature_files.each do |target_name, files_by_path| + # signature_files and signature_services share their target-name keys. + signature_service = signature_services.fetch(target_name) + changed_names = signature_service.last_changed_type_names + next if changed_names.empty? + + files_by_path.each_value do |file| + next if file.outdated + file.mark_outdated! if file.referenced_type_names.intersect?(changed_names) + end + end end + def signature_validation_needed?(path:, target:) + service = signature_services.fetch(target.name) + return true unless service.status.is_a?(SignatureService::LoadedStatus) + file = signature_files.fetch(target.name)[path] + return true unless file + file.outdated + end + + # Validates the signature file, reusing the cached diagnostics when nothing + # it references changed (see #signature_validation_needed?). def validate_signature(path:, target:) + unless signature_validation_needed?(path: path, target: target) + Steep.logger.debug { "Skipping signature validation for #{path} (no referenced type changed)" } + return signature_files.fetch(target.name).fetch(path).diagnostics + end + Steep.logger.tagged "#validate_signature(path=#{path})" do Steep.measure "validation" do service = signature_services.fetch(target.name) @@ -179,6 +293,9 @@ def validate_signature(path:, target:) raise "#{path} is not library nor signature of #{target.name}" end + # Refs only matter for a healthy env; an error status always re-validates. + referenced_type_names = Set[] #: Set[RBS::TypeName] + case service.status when SignatureService::SyntaxErrorStatus diagnostics = service.status.diagnostics.select do |diag| @@ -240,6 +357,11 @@ def validate_signature(path:, target:) error.location or raise Pathname(error.location.buffer.name) == path end + + # Own defined types plus what the validator referenced; defined names + # let ancestor changes reach us via the descendant closure. + referenced_type_names = Set.new(type_names) + referenced_type_names.merge(validator.referenced_type_names) end source = service.status.files[path] @@ -250,14 +372,38 @@ def validate_signature(path:, target:) end end - signature_validation_diagnostics.fetch(target.name)[path] = diagnostics + signature_files.fetch(target.name)[path] = + SignatureFile.new(diagnostics: diagnostics, referenced_type_names: referenced_type_names) + diagnostics end end end + def type_check_needed?(path:, target:) + file = source_files[path] + return true unless file&.typing + return true if file.outdated + # Incomplete refs (an unresolved reference): can't trust the intersection. + return true if file.has_unresolved_references + + # target is a project target, so the service always exists. + signature_service = signature_services.fetch(target.name) + return true unless signature_service.current_subtyping + + false + end + + # Type checks the source file and returns its diagnostics (nil if it can't + # run), reusing the cached result when nothing it references changed (see + # #type_check_needed?). def typecheck_source(path:, target:) return unless target + unless type_check_needed?(path: path, target: target) + Steep.logger.debug { "Skipping type check for #{path} (no referenced type changed)" } + return source_files.fetch(path).diagnostics + end + Steep.logger.tagged "#typecheck_source(path=#{path})" do Steep.measure "typecheck" do signature_service = signature_services.fetch(target.name) @@ -337,7 +483,11 @@ def type_check_file(target:, subtyping:, path:, text:) source = Source.parse(text, path: path, factory: subtyping.factory) typing = TypeCheckService.type_check(source: source, subtyping: subtyping, constant_resolver: yield, cursor: nil) ignores = Source::IgnoreRanges.new(ignores: source.ignores) - SourceFile.with_typing(path: path, content: text, node: source.node, typing: typing, ignores: ignores) + referenced_type_names = TypeNameReferences.from_source_file(typing: typing, source: source) + # An unknown constant leaves no entry in referenced_type_names; flag the + # set as incomplete (see SourceFile#has_unresolved_references). + has_unresolved_references = typing.errors.any? { |error| error.is_a?(Diagnostic::Ruby::UnknownConstant) } + SourceFile.with_typing(path: path, content: text, node: source.node, typing: typing, ignores: ignores, referenced_type_names: referenced_type_names, has_unresolved_references: has_unresolved_references) end rescue AnnotationParser::SyntaxError => exn error = Diagnostic::Ruby::AnnotationSyntaxError.new(message: exn.message, location: exn.location) diff --git a/lib/steep/signature/validator.rb b/lib/steep/signature/validator.rb index 6f1a69323..6ee0afb3f 100644 --- a/lib/steep/signature/validator.rb +++ b/lib/steep/signature/validator.rb @@ -7,10 +7,16 @@ class Validator attr_reader :checker attr_reader :context + # Type names referenced while validating (member types, ancestors and + # self-type constraints), used to decide whether a signature file's + # validation could be affected by a type change. + attr_reader :referenced_type_names + def initialize(checker:) @checker = checker @errors = [] @context = [] + @referenced_type_names = Set[] end def push_context(self_type: latest_context[0], class_type: latest_context[1], instance_type: latest_context[2]) @@ -159,6 +165,7 @@ def validate_type_0(type) case type when RBS::Types::ClassInstance, RBS::Types::Interface, RBS::Types::ClassSingleton, RBS::Types::Alias type_name = type.name + referenced_type_names << type_name if type.location location = type.location[:name] end @@ -182,6 +189,9 @@ def validate_type_name_deprecation(type_name, location) def ancestor_to_type(ancestor) case ancestor when RBS::Definition::Ancestor::Instance + # Mixins and self-type constraints reach here, not through #validate_type. + referenced_type_names << ancestor.name + args = ancestor.args.map {|type| checker.factory.type(type) } case diff --git a/lib/steep/type_name_references.rb b/lib/steep/type_name_references.rb new file mode 100644 index 000000000..9e5985226 --- /dev/null +++ b/lib/steep/type_name_references.rb @@ -0,0 +1,83 @@ +module Steep + # Collects the RBS type names referenced while type-checking a source file: a + # sound over-approximation of the file's type dependencies, used to decide + # whether it needs re-checking when a type changes. + # + # Names come from the type-resolution result (node types and resolved method + # calls) and from annotations (`@type`, `@implements`), which influence + # inference without appearing as a node's inferred type. + class TypeNameReferences + attr_reader :type_names + + def initialize + @type_names = Set[] + end + + def self.from_source_file(typing:, source:) + collector = new() + collector.collect_from_typing(typing) + collector.collect_from_annotations(source) + collector.type_names + end + + def collect_from_typing(typing) + typing.each_typing do |_node, type| + add_type(type) + end + + typing.method_calls.each_value do |call| + add_type(call.receiver_type) + add_type(call.return_type) + + if call.is_a?(TypeInference::MethodCall::Typed) + call.actual_method_type.each_type do |type| + add_type(type) + end + + call.method_decls.each do |decl| + # The type defining the called method: a change to it may change the signature. + add_type_name(decl.method_name.type_name) + end + end + end + end + + def collect_from_annotations(source) + source.each_annotation do |_node, annotations| + annotations.each do |annotation| + case annotation + when AST::Annotation::Implements + add_type_name(annotation.name.name) + when AST::Annotation::Named, AST::Annotation::Typed + type = annotation.type + case type + when Interface::MethodType + type.each_type do |t| + add_type(t) + end + else + add_type(type) + end + else + # `@dynamic` and other annotations carry no type to collect. + end + end + end + end + + def add_type(type) + case type + when AST::Types::Name::Base + add_type_name(type.name) + end + + type.each_child do |child| + add_type(child) + end + end + + def add_type_name(type_name) + type_names << type_name.absolute! + end + end +end diff --git a/sig/steep/services/signature_service.rbs b/sig/steep/services/signature_service.rbs index 9b1394df3..df3f7ea11 100644 --- a/sig/steep/services/signature_service.rbs +++ b/sig/steep/services/signature_service.rbs @@ -98,6 +98,10 @@ module Steep attr_reader implicitly_returns_nil: bool + attr_reader last_changed_type_names: Set[RBS::TypeName] + + def reset_last_changed_type_names: () -> void + def initialize: (status: status, implicitly_returns_nil: bool) -> void def self.load_from: (RBS::EnvironmentLoader loader, implicitly_returns_nil: bool) -> SignatureService @@ -163,6 +167,10 @@ module Steep def add_descendants: (graph: RBS::AncestorGraph, names: Set[RBS::TypeName], set: Set[RBS::TypeName]) -> void def add_nested_decls: (env: RBS::Environment, names: Set[RBS::TypeName], set: Set[RBS::TypeName]) -> void + + def add_type_alias_dependencies: (env: RBS::Environment, set: Set[RBS::TypeName]) -> void + + def each_type_name_in: (RBS::Types::t `type`) { (RBS::TypeName) -> void } -> void end end end diff --git a/sig/steep/services/type_check_service.rbs b/sig/steep/services/type_check_service.rbs index 62e699790..03820cebf 100644 --- a/sig/steep/services/type_check_service.rbs +++ b/sig/steep/services/type_check_service.rbs @@ -3,7 +3,7 @@ module Steep class TypeCheckService attr_reader project: Project - attr_reader signature_validation_diagnostics: Hash[Symbol, Hash[Pathname, Array[Diagnostic::Signature::Base]]] + attr_reader signature_files: Hash[Symbol, Hash[Pathname, SignatureFile]] attr_reader source_files: Hash[Pathname, SourceFile] @@ -24,21 +24,32 @@ module Steep attr_reader errors: Array[Diagnostic::Ruby::Base]? + attr_reader referenced_type_names: Set[RBS::TypeName] + + attr_reader outdated: bool + + attr_reader has_unresolved_references: bool + def initialize: ( path: Pathname, node: Parser::AST::Node | nil | false, content: String, typing: Typing?, ignores: Source::IgnoreRanges?, - errors: Array[Diagnostic::Ruby::Base]? + errors: Array[Diagnostic::Ruby::Base]?, + ?referenced_type_names: Set[RBS::TypeName], + ?outdated: bool, + ?has_unresolved_references: bool ) -> void def self.with_syntax_error: (path: Pathname, content: String, error: Diagnostic::Ruby::SyntaxError | Diagnostic::Ruby::AnnotationSyntaxError) -> SourceFile - def self.with_typing: (path: Pathname, content: String, typing: Typing, ignores: Source::IgnoreRanges, node: Parser::AST::Node?) -> SourceFile + def self.with_typing: (path: Pathname, content: String, typing: Typing, ignores: Source::IgnoreRanges, node: Parser::AST::Node?, ?referenced_type_names: Set[RBS::TypeName], ?has_unresolved_references: bool) -> SourceFile def self.no_data: (path: Pathname, content: String) -> SourceFile + def mark_outdated!: () -> self + def update_content: (String content) -> SourceFile # Diagnostics filtered by `ignores` @@ -46,6 +57,24 @@ module Steep def diagnostics: () -> Array[Diagnostic::Ruby::Base] end + # The validation result of a single signature file within one target + # + class SignatureFile + attr_reader diagnostics: Array[Diagnostic::Signature::Base] + + attr_reader referenced_type_names: Set[RBS::TypeName] + + attr_reader outdated: bool + + def initialize: ( + diagnostics: Array[Diagnostic::Signature::Base], + referenced_type_names: Set[RBS::TypeName], + ?outdated: bool + ) -> void + + def mark_outdated!: () -> self + end + def initialize: (project: Project) -> void # Returns the diagnostics of signature files -- RBS files and inline RBS declarations @@ -69,9 +98,17 @@ module Steep # def update: (changes: Server::ChangeBuffer::changes) -> void + def invalidate_outdated_source_files: () -> void + + def invalidate_outdated_signature_files: () -> void + + def signature_validation_needed?: (path: Pathname, target: Project::Target) -> bool + + def type_check_needed?: (path: Pathname, target: Project::Target) -> bool + # Validates RBS signature included in the path # - # Updates `#signature_validation_diagnostics` of the file and returns the diagnostics of the target-path pair. + # Updates the `SignatureFile` cached in `#signature_files` and returns the diagnostics of the target-path pair. # def validate_signature: (path: Pathname, target: Project::Target) -> Array[Diagnostic::Signature::Base] diff --git a/sig/steep/signature/validator.rbs b/sig/steep/signature/validator.rbs index 4f967b30d..137e91396 100644 --- a/sig/steep/signature/validator.rbs +++ b/sig/steep/signature/validator.rbs @@ -17,6 +17,8 @@ module Steep # attr_reader context: Array[[AST::Types::t?, AST::Types::t?, AST::Types::t?]] + attr_reader referenced_type_names: Set[RBS::TypeName] + def latest_context: -> [AST::Types::t?, AST::Types::t?, AST::Types::t?] def push_context: [T] (?self_type: AST::Types::t?, ?class_type: AST::Types::t?, ?instance_type: AST::Types::t?) { () -> T } -> T diff --git a/sig/steep/type_name_references.rbs b/sig/steep/type_name_references.rbs new file mode 100644 index 000000000..65c38db5d --- /dev/null +++ b/sig/steep/type_name_references.rbs @@ -0,0 +1,26 @@ +module Steep + # Collects the RBS type names referenced while type-checking a source file: a + # sound over-approximation of the file's type dependencies, used to decide + # whether it needs re-checking when a type changes. + # + # Names come from the type-resolution result (node types and resolved method + # calls) and from annotations (`@type`, `@implements`), which influence + # inference without appearing as a node's inferred type. + class TypeNameReferences + @type_names: Set[RBS::TypeName] + + attr_reader type_names: Set[RBS::TypeName] + + def initialize: () -> void + + def self.from_source_file: (typing: Typing, source: Source) -> Set[RBS::TypeName] + + def collect_from_typing: (Typing typing) -> void + + def collect_from_annotations: (Source source) -> void + + def add_type: (AST::Types::t type) -> void + + def add_type_name: (RBS::TypeName type_name) -> void + end +end diff --git a/sig/test/type_check_service_test.rbs b/sig/test/type_check_service_test.rbs index 48aa1bc8b..2b3c37d6f 100644 --- a/sig/test/type_check_service_test.rbs +++ b/sig/test/type_check_service_test.rbs @@ -66,4 +66,32 @@ class TypeCheckServiceTest < Minitest::Test def test_typecheck_source__ancestor_error_with_library_location: () -> untyped def test_typecheck_source__ancestor_error_with_user_file_location: () -> untyped + + def test_type_check_needed__predicates: () -> untyped + + def test_signature_validation_needed__predicates: () -> untyped + + # Only the source files referencing a changed type are re-type-checked, and a + # pending re-check survives an unrelated intervening update (the need is + # recorded on the file, not recomputed per cycle). The change is to a + # signature, not the `.rb` content, so a source file's cached object survives + # unless it is actually re-type-checked -- object identity observes which files + # were re-checked. + def test_typecheck_source__rechecks_only_affected_files: () -> untyped + + # Only the signature files whose referenced types changed are re-validated, and + # a pending re-validation survives an unrelated intervening update. An + # unaffected file's cached SignatureFile object survives. + def test_validate_signature__revalidates_only_affected_files: () -> untyped + + def test_inline_change_marks_dependent_source_outdated: () -> untyped + + def source_recheck_project: () -> untyped + + def normalize_recheck_diagnostics: (untyped diagnostics) -> untyped + + # Regression (rbs-inline workflow): the `.rb` is created before its generated + # `.rbs`, so the referenced type is unresolved and absent from the cached + # refs. The file must still be re-checked once the signature appears. + def test_incremental_recheck__file_rechecked_after_referenced_type_is_added_later: () -> untyped end diff --git a/sig/test/type_check_worker_test.rbs b/sig/test/type_check_worker_test.rbs index 1ba6e1f54..40b410acb 100644 --- a/sig/test/type_check_worker_test.rbs +++ b/sig/test/type_check_worker_test.rbs @@ -40,6 +40,8 @@ class TypeCheckWorkerTest < Minitest::Test def test_handle_job_validate_app_signature: () -> untyped + def test_handle_job_validate_app_signature_reuses_unaffected: () -> untyped + def test_handle_job_validate_app_signature_skip: () -> untyped def test_handle_job_validate_lib_signature: () -> untyped @@ -50,6 +52,8 @@ class TypeCheckWorkerTest < Minitest::Test def test_handle_job_typecheck_code_diagnostics: () -> untyped + def test_handle_job_typecheck_code_reuses_unaffected_file: () -> untyped + def test_handle_job_typecheck_skip: () -> untyped def test_handle_job_typecheck_inline__no_error: () -> untyped diff --git a/sig/test/validation_test.rbs b/sig/test/validation_test.rbs index e233c57ed..52b357931 100644 --- a/sig/test/validation_test.rbs +++ b/sig/test/validation_test.rbs @@ -88,4 +88,15 @@ class ValidationTest < Minitest::Test def test_validate__deprecated__generic: () -> untyped def test_validate__deprecated__namespace: () -> untyped + + # `#referenced_type_names` collects every type name the validator touches. + # Both paths are exercised: member types (#validate_type) and ancestors / + # self-type constraints (#ancestor_to_type). + def test_referenced_type_names: () -> untyped + + def test_referenced_type_names__generic_bound: () -> untyped + + def test_referenced_type_names__type_alias: () -> untyped + + def test_referenced_type_names__constant: () -> untyped end diff --git a/test/signature_service_test.rb b/test/signature_service_test.rb new file mode 100644 index 000000000..1f1e50fe6 --- /dev/null +++ b/test/signature_service_test.rb @@ -0,0 +1,148 @@ +require_relative "test_helper" + +# Tests for SignatureService#last_changed_type_names: the type names whose +# definitions changed in the most recent #update, expanded over descendants, +# nested declarations and type aliases. Driven through TypeCheckService. +class SignatureServiceTest < Minitest::Test + include Steep + include TestHelper + + ContentChange = Services::ContentChange + TypeCheckService = Services::TypeCheckService + + def build_service(&block) + project = Project.new(steepfile_path: Pathname.pwd + "Steepfile") + Project::DSL.eval(project, &block) + TypeCheckService.new(project: project) + end + + # An alias that (transitively) references a changed type must enter the set, + # even when declared in an unchanged file -- so a referencing file need only + # record the alias name. + def test_changed_names_includes_aliases_referencing_changed_type + service = build_service do + target :main do + check "lib/a.rb" + signature "sig/types.rbs" + signature "sig/aliases.rbs" + end + end + + service.update( + changes: { + Pathname("sig/types.rbs") => [ContentChange.string(<<~RBS)], + class Widget + def size: () -> Integer + end + RBS + # The alias lives in a separate file; transitively `gadget -> widget`. + Pathname("sig/aliases.rbs") => [ContentChange.string(<<~RBS)], + type widget = Widget + type gadget = widget + RBS + Pathname("lib/a.rb") => [ContentChange.string("1\n")] + } + ) + + # Change only the type in sig/types.rbs; sig/aliases.rbs is untouched. + service.update( + changes: { + Pathname("sig/types.rbs") => [ContentChange.string(<<~RBS)] + class Widget + def size: () -> String + end + RBS + } + ) + + changed_names = service.signature_services[:main].last_changed_type_names + + assert_includes changed_names, RBS::TypeName.parse("::Widget") + # The alias that directly references Widget... + assert_includes changed_names, RBS::TypeName.parse("::widget") + # ...and the alias that transitively references it through another alias. + assert_includes changed_names, RBS::TypeName.parse("::gadget") + end + + # A change to a parent type must pull its descendants into the set, so a file + # that mentions only a child is still re-checked. + def test_changed_names_includes_descendants_of_changed_type + service = build_service do + target :main do + check "lib/a.rb" + signature "sig/types.rbs" + end + end + + service.update( + changes: { + Pathname("sig/types.rbs") => [ContentChange.string(<<~RBS)], + class Animal + def name: () -> Integer + end + + class Dog < Animal + end + RBS + Pathname("lib/a.rb") => [ContentChange.string("1\n")] + } + ) + + # Change only the parent Animal; Dog's own declaration is untouched. + service.update( + changes: { + Pathname("sig/types.rbs") => [ContentChange.string(<<~RBS)] + class Animal + def name: () -> String + end + + class Dog < Animal + end + RBS + } + ) + + changed_names = service.signature_services[:main].last_changed_type_names + + assert_includes changed_names, RBS::TypeName.parse("::Animal") + # The descendant enters the set even though its own declaration is unchanged. + assert_includes changed_names, RBS::TypeName.parse("::Dog") + end + + # A change to an inline (`inline: true`) Ruby file's declared types must enter + # the set so dependents are re-checked. + def test_changed_names_includes_inline_declared_types + service = build_service do + target :lib do + check "lib", inline: true + signature "sig" + end + end + + hello_v1 = <<~RUBY + class Hello + # @rbs () -> Integer + def world + 1 + end + end + RUBY + hello_v2 = <<~RUBY + class Hello + # @rbs () -> String + def world + "x" + end + end + RUBY + + service.update(changes: { Pathname("lib/hello.rb") => [ContentChange.string(hello_v1)] }) + + # Change the inline-declared return type. + service.update(changes: { Pathname("lib/hello.rb") => [ContentChange.string(hello_v2)] }) + + changed_names = service.signature_services[:lib].last_changed_type_names + assert_includes changed_names, RBS::TypeName.parse("::Hello"), + "an inline-declared type change must enter changed_names" + end +end diff --git a/test/type_check_service_test.rb b/test/type_check_service_test.rb index 151dce5ac..2e791d03b 100644 --- a/test/type_check_service_test.rb +++ b/test/type_check_service_test.rb @@ -881,4 +881,236 @@ module Hello end end + # --- Incremental re-check: skip/reuse of unaffected files ----------------- + # Cached results are reused when a type change cannot affect the file + # (#type_check_needed? / #signature_validation_needed?). + + def test_type_check_needed__predicates + service = Services::TypeCheckService.new(project: project) + service.update(changes: reset_changes) + core_target = project.targets.find { _1.name == :core } + + service.update( + changes: { + Pathname("sig/core.rbs") => [ContentChange.string("class Foo\nend\n")], + Pathname("lib/core.rb") => [ContentChange.string("Foo.new\n")] + } + ) + + # No source file recorded for this path yet -> needed. + assert service.type_check_needed?(path: Pathname("lib/unknown.rb"), target: core_target) + + # Recorded but never type-checked (no typing) -> needed. + assert service.type_check_needed?(path: Pathname("lib/core.rb"), target: core_target) + + service.typecheck_source(path: Pathname("lib/core.rb"), target: core_target) + + # Freshly checked, environment healthy -> not needed. + refute service.type_check_needed?(path: Pathname("lib/core.rb"), target: core_target) + + # A content change marks the cached result outdated -> needed. + service.update(changes: { Pathname("lib/core.rb") => [ContentChange.string("Foo.new\nFoo.new\n")] }) + assert service.type_check_needed?(path: Pathname("lib/core.rb"), target: core_target) + end + + def test_signature_validation_needed__predicates + service = Services::TypeCheckService.new(project: project) + service.update(changes: reset_changes) + core_target = project.targets.find { _1.name == :core } + + service.update(changes: { Pathname("sig/core.rbs") => [ContentChange.string("class Foo\nend\n")] }) + + # Not validated yet -> needed. + assert service.signature_validation_needed?(path: Pathname("sig/core.rbs"), target: core_target) + + service.validate_signature(path: Pathname("sig/core.rbs"), target: core_target) + + # Validated and nothing changed -> not needed. + refute service.signature_validation_needed?(path: Pathname("sig/core.rbs"), target: core_target) + + # Changing the file's own type invalidates it -> needed. + service.update(changes: { Pathname("sig/core.rbs") => [ContentChange.string("class Foo\n def f: () -> Integer\nend\n")] }) + assert service.signature_validation_needed?(path: Pathname("sig/core.rbs"), target: core_target) + end + + # Only the source files referencing a changed type are re-type-checked, and a + # pending re-check survives an unrelated intervening update (the need is + # recorded on the file, not recomputed per cycle). The change is to a + # signature, not the `.rb` content, so a source file's cached object survives + # unless it is actually re-type-checked -- object identity observes which files + # were re-checked. + def test_typecheck_source__rechecks_only_affected_files + service = Services::TypeCheckService.new(project: project) + service.update(changes: reset_changes) + core_target = project.targets.find { _1.name == :core } + main_target = project.targets.find { _1.name == :main } + + # Foo and Bar live in separate signature files; core.rb uses Foo, main.rb Bar. + service.update( + changes: { + Pathname("sig/core.rbs") => [ContentChange.string("class Foo\n def foo: () -> void\nend\n")], + Pathname("sig/main.rbs") => [ContentChange.string("class Bar\n def bar: () -> void\nend\n")], + Pathname("lib/core.rb") => [ContentChange.string("Foo.new.foo\n")], + Pathname("lib/main.rb") => [ContentChange.string("Bar.new.bar\n")] + } + ) + + service.typecheck_source(path: Pathname("lib/core.rb"), target: core_target) + service.typecheck_source(path: Pathname("lib/main.rb"), target: main_target) + core_before = service.source_files[Pathname("lib/core.rb")] + main_before = service.source_files[Pathname("lib/main.rb")] + + # Change only Foo, deferring core.rb's re-check... + service.update( + changes: { + Pathname("sig/core.rbs") => [ContentChange.string("class Foo\n def foo: () -> Integer\nend\n")] + } + ) + # ...then run an unrelated intervening cycle (the unreferenced test target) + # whose changed-type set mentions neither Foo nor Bar. + service.update(changes: { Pathname("sig/core_test.rbs") => [ContentChange.string("class CoreTestHelper\nend\n")] }) + + service.typecheck_source(path: Pathname("lib/core.rb"), target: core_target) + service.typecheck_source(path: Pathname("lib/main.rb"), target: main_target) + + refute_same core_before, service.source_files[Pathname("lib/core.rb")], + "core.rb references the changed Foo and must be re-type-checked, even across the intervening cycle" + assert_same main_before, service.source_files[Pathname("lib/main.rb")], + "main.rb references only the unchanged Bar and must not be re-type-checked" + end + + # Only the signature files whose referenced types changed are re-validated, and + # a pending re-validation survives an unrelated intervening update. An + # unaffected file's cached SignatureFile object survives. + def test_validate_signature__revalidates_only_affected_files + service = Services::TypeCheckService.new(project: project) + service.update(changes: reset_changes) + core_target = project.targets.find { _1.name == :core } + main_target = project.targets.find { _1.name == :main } + + service.update( + changes: { + Pathname("sig/core.rbs") => [ContentChange.string("class Foo\n def f: () -> Integer\nend\n")], + Pathname("sig/main.rbs") => [ContentChange.string("class Bar\n def b: () -> Integer\nend\n")] + } + ) + + service.validate_signature(path: Pathname("sig/core.rbs"), target: core_target) + service.validate_signature(path: Pathname("sig/main.rbs"), target: main_target) + core_before = service.signature_files[:core][Pathname("sig/core.rbs")] + main_before = service.signature_files[:main][Pathname("sig/main.rbs")] + + # Change only sig/core.rbs, then run an unrelated intervening cycle (the + # unreferenced test target) mentioning neither Foo nor Bar. + service.update(changes: { Pathname("sig/core.rbs") => [ContentChange.string("class Foo\n def f: () -> String\nend\n")] }) + service.update(changes: { Pathname("sig/core_test.rbs") => [ContentChange.string("class CoreTestHelper\nend\n")] }) + + service.validate_signature(path: Pathname("sig/core.rbs"), target: core_target) + service.validate_signature(path: Pathname("sig/main.rbs"), target: main_target) + + refute_same core_before, service.signature_files[:core][Pathname("sig/core.rbs")], + "sig/core.rbs changed and must be re-validated, even across the intervening cycle" + assert_same main_before, service.signature_files[:main][Pathname("sig/main.rbs")], + "sig/main.rbs is unaffected and must not be re-validated" + end + + def test_inline_change_marks_dependent_source_outdated + # A change to an inline (`inline: true`) Ruby file's declared types must + # mark the source files that reference those types as outdated. + project = Project.new(steepfile_path: Pathname.pwd + "Steepfile") + Project::DSL.eval(project) do + target :lib do + check "lib", inline: true + signature "sig" + end + end + service = Services::TypeCheckService.new(project: project) + target = project.targets[0] + + hello_v1 = <<~RUBY + class Hello + # @rbs () -> Integer + def world + 1 + end + end + RUBY + hello_v2 = <<~RUBY + class Hello + # @rbs () -> String + def world + "x" + end + end + RUBY + + service.update( + changes: { + Pathname("lib/hello.rb") => [ContentChange.string(hello_v1)], + Pathname("lib/user.rb") => [ContentChange.string("Hello.new.world\n")] + } + ) + + # Check user.rb so it has a cached result and recorded references. + service.typecheck_source(path: Pathname("lib/user.rb"), target: target) + user = service.source_files[Pathname("lib/user.rb")] + assert_includes user.referenced_type_names, RBS::TypeName.parse("::Hello") + refute user.outdated + + # Changing the inline file must invalidate the dependent file. + service.update(changes: { Pathname("lib/hello.rb") => [ContentChange.string(hello_v2)] }) + + assert service.source_files[Pathname("lib/user.rb")].outdated, + "a file referencing an inline-defined type must be marked outdated when that type changes" + end + + def source_recheck_project + Project.new(steepfile_path: Pathname.pwd + "Steepfile").tap do |project| + Project::DSL.eval(project) do + target :main do + check "lib/a.rb" + signature "sig/foo.rbs" + end + end + end + end + + def normalize_recheck_diagnostics(diagnostics) + diagnostics.map { |d| [d.class.name, d.location&.source].join(":") }.sort + end + + # Regression (rbs-inline workflow): the `.rb` is created before its generated + # `.rbs`, so the referenced type is unresolved and absent from the cached + # refs. The file must still be re-checked once the signature appears. + def test_incremental_recheck__file_rechecked_after_referenced_type_is_added_later + project = source_recheck_project + service = Services::TypeCheckService.new(project: project) + target = project.target_for_source_path(Pathname("lib/a.rb")) or raise + + # Cycle 1: only the source file exists; sig/foo.rbs has not been generated. + service.update(changes: { Pathname("lib/a.rb") => [ContentChange.string("Foo.new\n")] }) + diag_before = normalize_recheck_diagnostics(service.typecheck_source(path: Pathname("lib/a.rb"), target: target) || []) + + # Foo is undefined, so the reference does not resolve and is not recorded. + assert diag_before.any? { |d| d.start_with?("Steep::Diagnostic::Ruby::UnknownConstant:") }, + "cycle 1 should report Foo as an unknown constant, got: #{diag_before}" + refute_includes service.source_files[Pathname("lib/a.rb")].referenced_type_names, RBS::TypeName.parse("::Foo") + assert service.source_files[Pathname("lib/a.rb")].has_unresolved_references, + "a file with an unresolved reference must record that its refs are incomplete" + + # Cycle 2: the hook generates sig/foo.rbs, defining Foo. + service.update(changes: { Pathname("sig/foo.rbs") => [ContentChange.string("class Foo\nend\n")] }) + + assert service.type_check_needed?(path: Pathname("lib/a.rb"), target: target), + "a file with unresolved references must be re-checked when type information changes" + + diag_after = normalize_recheck_diagnostics(service.typecheck_source(path: Pathname("lib/a.rb"), target: target) || []) + assert_empty diag_after, "the UnknownConstant error must disappear once Foo is defined" + + # Refs are now complete and the unresolved flag has cleared, so ordinary + # skipping resumes. + refute service.source_files[Pathname("lib/a.rb")].has_unresolved_references + assert_includes service.source_files[Pathname("lib/a.rb")].referenced_type_names, RBS::TypeName.parse("::Foo") + end + end diff --git a/test/type_check_worker_test.rb b/test/type_check_worker_test.rb index 5b50d54fa..3789c4ac5 100644 --- a/test/type_check_worker_test.rb +++ b/test/type_check_worker_test.rb @@ -329,6 +329,67 @@ def world: () -> void end end + def test_handle_job_validate_app_signature_reuses_unaffected + in_tmpdir do + with_master_read_queue do |master_read_queue| + project = Project.new(steepfile_path: current_dir + "Steepfile") + Project::DSL.parse(project, <<~RUBY) + target :lib do + check "lib" + signature "sig" + end + RUBY + + worker = Server::TypeCheckWorker.new( + project: project, + assignment: assignment, + commandline_args: [], + reader: worker_reader, + writer: worker_writer + ) + + foo = current_dir + "sig/foo.rbs" + bar = current_dir + "sig/bar.rbs" + + # Cycle 1: validate both signature files. + worker.instance_variable_set(:@current_type_check_guid, "guid1") + {}.tap do |changes| + changes[Pathname("sig/foo.rbs")] = [Services::ContentChange.string("class Foo\n def f: () -> Integer\nend\n")] + changes[Pathname("sig/bar.rbs")] = [Services::ContentChange.string("class Bar\n def b: () -> Integer\nend\n")] + worker.handle_job(TypeCheckWorker::StartTypeCheckJob.new(guid: "guid1", changes: changes)) + end + worker.handle_job(TypeCheckWorker::ValidateAppSignatureJob.new(guid: "guid1", path: foo, target: project.targets[0])) + worker.handle_job(TypeCheckWorker::ValidateAppSignatureJob.new(guid: "guid1", path: bar, target: project.targets[0])) + 2.times { master_read_queue.pop } + + # Cycle 2: change only Foo. + worker.instance_variable_set(:@current_type_check_guid, "guid2") + worker.handle_job( + TypeCheckWorker::StartTypeCheckJob.new( + guid: "guid2", + changes: { Pathname("sig/foo.rbs") => [Services::ContentChange.string("class Foo\n def f: () -> String\nend\n")] } + ) + ) + + # Foo's definition changed -> must be re-validated; Bar is unaffected. + assert worker.service.signature_validation_needed?(path: Pathname("sig/foo.rbs"), target: project.targets[0]) + refute worker.service.signature_validation_needed?(path: Pathname("sig/bar.rbs"), target: project.targets[0]) + + # Re-validating Bar reuses its cached result (same SignatureFile kept); + # Foo is recomputed (a fresh SignatureFile replaces the cached one). + bar_before = worker.service.signature_files[:lib][Pathname("sig/bar.rbs")] + foo_before = worker.service.signature_files[:lib][Pathname("sig/foo.rbs")] + + worker.handle_job(TypeCheckWorker::ValidateAppSignatureJob.new(guid: "guid2", path: foo, target: project.targets[0])) + worker.handle_job(TypeCheckWorker::ValidateAppSignatureJob.new(guid: "guid2", path: bar, target: project.targets[0])) + 2.times { master_read_queue.pop } + + assert_same bar_before, worker.service.signature_files[:lib][Pathname("sig/bar.rbs")] + refute_same foo_before, worker.service.signature_files[:lib][Pathname("sig/foo.rbs")] + end + end + end + def test_handle_job_validate_app_signature_skip in_tmpdir do with_master_read_queue do |master_read_queue| @@ -583,6 +644,89 @@ def world: () -> void end end + def test_handle_job_typecheck_code_reuses_unaffected_file + in_tmpdir do + with_master_read_queue do |master_read_queue| + project = Project.new(steepfile_path: current_dir + "Steepfile") + Project::DSL.parse(project, <<~RUBY) + target :lib do + check "lib" + signature "sig" + end + RUBY + + worker = Server::TypeCheckWorker.new( + project: project, + assignment: assignment, + commandline_args: [], + reader: worker_reader, + writer: worker_writer + ) + + worker.instance_variable_set(:@current_type_check_guid, "guid1") + + # Cycle 1: load everything and check both files. Foo and Bar live in + # separate signature files so that a change to one does not mark the + # other as changed. + {}.tap do |changes| + changes[Pathname("lib/a.rb")] = [Services::ContentChange.string("Foo.new.foo\n")] + changes[Pathname("lib/b.rb")] = [Services::ContentChange.string("Bar.new.bar\n")] + changes[Pathname("sig/foo.rbs")] = [Services::ContentChange.string(<<~RBS)] + class Foo + def foo: () -> void + end + RBS + changes[Pathname("sig/bar.rbs")] = [Services::ContentChange.string(<<~RBS)] + class Bar + def bar: () -> void + end + RBS + worker.handle_job(TypeCheckWorker::StartTypeCheckJob.new(guid: "guid1", changes: changes)) + end + + worker.handle_job(TypeCheckWorker::TypeCheckCodeJob.new(guid: "guid1", path: current_dir + "lib/a.rb", target: project.targets[0])) + worker.handle_job(TypeCheckWorker::TypeCheckCodeJob.new(guid: "guid1", path: current_dir + "lib/b.rb", target: project.targets[0])) + 2.times { master_read_queue.pop } + + # Cycle 2: change only Foo; Bar's file is untouched. + worker.instance_variable_set(:@current_type_check_guid, "guid2") + {}.tap do |changes| + changes[Pathname("sig/foo.rbs")] = [Services::ContentChange.string(<<~RBS)] + class Foo + def foo: () -> Integer + end + RBS + worker.handle_job(TypeCheckWorker::StartTypeCheckJob.new(guid: "guid2", changes: changes)) + end + + changed_names = worker.service.signature_services[:lib].last_changed_type_names + assert_includes changed_names, RBS::TypeName.parse("::Foo") + refute_includes changed_names, RBS::TypeName.parse("::Bar") + + a_before = worker.service.source_files[Pathname("lib/a.rb")] + b_before = worker.service.source_files[Pathname("lib/b.rb")] + + worker.handle_job(TypeCheckWorker::TypeCheckCodeJob.new(guid: "guid2", path: current_dir + "lib/a.rb", target: project.targets[0])) + worker.handle_job(TypeCheckWorker::TypeCheckCodeJob.new(guid: "guid2", path: current_dir + "lib/b.rb", target: project.targets[0])) + + # Both files still report progress (the master's completion accounting is + # unchanged): one notification per file. + a_message = master_read_queue.pop + b_message = master_read_queue.pop + assert_equal TypeCheck__Progress::METHOD, a_message[:method] + assert_equal TypeCheck__Progress::METHOD, b_message[:method] + + # a.rb references Foo (which changed) and was re-checked: a fresh + # SourceFile object replaced the cached one. + refute_same a_before, worker.service.source_files[Pathname("lib/a.rb")] + + # b.rb references only Bar (unchanged) and its own content is unchanged, + # so its cached result was reused without re-running the type check. + assert_same b_before, worker.service.source_files[Pathname("lib/b.rb")] + end + end + end + def test_handle_job_typecheck_skip in_tmpdir do with_master_read_queue do |master_read_queue| diff --git a/test/type_name_references_test.rb b/test/type_name_references_test.rb new file mode 100644 index 000000000..4aad85542 --- /dev/null +++ b/test/type_name_references_test.rb @@ -0,0 +1,151 @@ +require_relative "test_helper" + +class TypeNameReferencesTest < Minitest::Test + include TestHelper + include FactoryHelper + include SubtypingHelper + include TypeConstructionHelper + + TypeNameReferences = Steep::TypeNameReferences + + def collect(checker, source) + with_standard_construction(checker, source) do |construction, typing| + construction.synthesize(source.node) + TypeNameReferences.from_source_file(typing: typing, source: source) + end + end + + def test_collects_inferred_types + with_checker do |checker| + source = parse_ruby(<<-RUBY) +x = 1 +y = x + 2 + RUBY + + names = collect(checker, source) + + assert_includes names, RBS::TypeName.parse("::Integer") + end + end + + def test_collects_annotation_only_types + # `::String` appears only in the annotation; `s` is never used so it never + # shows up as an inferred node type. It must still be collected. + with_checker do |checker| + source = parse_ruby(<<-RUBY) +# @type var s: ::String +x = 1 + RUBY + + names = collect(checker, source) + + assert_includes names, RBS::TypeName.parse("::String") + end + end + + def test_collects_method_call_argument_types + # `::Integer` is the *parameter* type of the called method and never appears + # as an inferred node type; it is reachable only through the method type. + with_checker(<<-RBS) do |checker| +class WithParam + def take: (::Symbol) -> void +end + RBS + source = parse_ruby(<<-RUBY) +x = WithParam.new +x.take(:foo) + RUBY + + names = collect(checker, source) + + assert_includes names, RBS::TypeName.parse("::WithParam") + assert_includes names, RBS::TypeName.parse("::Symbol") + end + end + + def test_collects_type_assertion_types + # The asserted type of a `#:` assertion is recorded in the typing. `::Symbol` + # appears nowhere else, so it can only be collected via the assertion. + with_checker do |checker| + source = parse_ruby(<<-RUBY) +xs = [] #: Array[::Symbol] + RUBY + + names = collect(checker, source) + + assert_includes names, RBS::TypeName.parse("::Array") + assert_includes names, RBS::TypeName.parse("::Symbol") + end + end + + def test_collects_type_application_types + # The type arguments of a `#$` application are recorded in the typing. + # `::Numeric` appears only in the application. + with_checker(<<-RBS) do |checker| +class Array[unchecked out Elem] + def union: [T] (*Array[T]) -> Array[Elem | T] +end + RBS + source = parse_ruby(<<-RUBY) +xs = [1].union([1.2]) #$ Numeric + RUBY + + names = collect(checker, source) + + assert_includes names, RBS::TypeName.parse("::Numeric") + end + end + + def test_collects_implements_annotation + # `::Marker` appears only in `@implements`, never as a constant or call in + # the source, so collecting it can only be the annotation handling. (The + # `< Marker` ancestor lives in RBS, which is not a source of references.) + with_checker(<<-RBS) do |checker| +class Marker +end + +class Host < Marker +end + RBS + source = parse_ruby(<<-RUBY) +class Host + # @implements Marker + def foo + end +end + RUBY + + names = collect(checker, source) + + assert_includes names, RBS::TypeName.parse("::Marker") + end + end + + def test_collects_nested_class_types_as_absolute + # A type named relatively inside a nested namespace must be collected as its + # absolute name. Asserting the specific absolute names are present (rather + # than only "all names are absolute", which an empty set would satisfy) + # checks both that extraction works and that names are absolutized. + with_checker(<<-RBS) do |checker| +module Outer + class Inner + def value: () -> Outer::Item + end + + class Item + end +end + RBS + source = parse_ruby(<<-RUBY) +Outer::Inner.new.value + RUBY + + names = collect(checker, source) + + refute_empty names + assert_includes names, RBS::TypeName.parse("::Outer::Inner") + assert_includes names, RBS::TypeName.parse("::Outer::Item") + assert names.all?(&:absolute?), "expected all collected names to be absolute: #{names.map(&:to_s)}" + end + end +end diff --git a/test/validation_test.rb b/test/validation_test.rb index bcfe10dd6..daf65781f 100644 --- a/test/validation_test.rb +++ b/test/validation_test.rb @@ -1463,4 +1463,84 @@ class Foo end end end + + # `#referenced_type_names` collects every type name the validator touches. + # Both paths are exercised: member types (#validate_type) and ancestors / + # self-type constraints (#ancestor_to_type). + def test_referenced_type_names + with_checker <<-EOF do |checker| +interface _Printable + def print: () -> void +end + +module Mix : _Printable +end + +class Widget +end + +class Foo + include Mix + + def m: (Widget) -> void +end + EOF + + validator = Validator.new(checker: checker) + validator.validate_one_class(RBS::TypeName.parse("::Foo")) + + # Member type appearing only as a method parameter (validate_type path). + assert_includes validator.referenced_type_names, RBS::TypeName.parse("::Widget") + # Self-type constraint of the included module, reached through the ancestor + # chain (ancestor_to_type path) and not present at Foo's own surface. + assert_includes validator.referenced_type_names, RBS::TypeName.parse("::_Printable") + end + end + + def test_referenced_type_names__generic_bound + with_checker <<-EOF do |checker| +class Target +end + +class Foo[T < Target] +end + EOF + validator = Validator.new(checker: checker) + validator.validate_one_class(RBS::TypeName.parse("::Foo")) + + # `Target` appears only as a type parameter's upper bound. + assert_includes validator.referenced_type_names, RBS::TypeName.parse("::Target") + end + end + + def test_referenced_type_names__type_alias + with_checker <<-EOF do |checker| +class Target +end + +type foo_alias = Target + EOF + validator = Validator.new(checker: checker) + validator.validate_alias + + # `Target` appears only on the alias's right-hand side. + assert_includes validator.referenced_type_names, RBS::TypeName.parse("::Target") + end + end + + def test_referenced_type_names__constant + with_checker <<-EOF do |checker| +class Target +end + +FOO: Target + EOF + validator = Validator.new(checker: checker) + validator.validate_const + + # `Target` appears only as the constant's type. + assert_includes validator.referenced_type_names, RBS::TypeName.parse("::Target") + end + end + end