Talks

Automated Type Contracts Generation for Ruby

RubyKaigi2017
http://rubykaigi.org/2017/presentations/rubymine.html

Beauty and power of Ruby and Rails pays us back when it comes to finding bugs in large codebases. Static analysis is hindered by magic DSLs and patches. We may annotate the code with YARD which also enables improved tooling such as code completion. Sadly, the benefits of this process rarely compensate for the effort.

In this session we’ll see a new approach to type annotations generation. We'll learn how to obtain this data from runtime, to cope with DSLs and monkey patching, propose some tooling beyond YARD and create contracts like (String, T) - T

YARV hacking and minimized DFAs included.

RubyKaigi 2017

00:00:06 Hi, my name is Valentin, and I work for JetBrains, a company that produces tools for developers. The most famous of our tools is IntelliJ IDEA, and the most important one today is RubyMine, which is Ruby's IDE.
00:00:13 Today, I'll tell you about a side project we have been working on in our team for some time.
00:00:25 The question is: why do we need types in our programs? Why do we discuss whether we should add type annotations to the Ruby language syntax or not? First, I will take a moment to explain briefly what we are doing in the RubyMine team and why typing is important from our IDE's perspective.
00:00:39 The first and most fundamental use case is symbol resolution. Let's take a look at this short snippet where we try to resolve the `to_s` method. There are many implementations in a large project, perhaps thousands. Without knowing the type of a variable or object, we don't know where to go or how it is implemented, which is why types are fundamentally important.
00:01:02 The second use case, which is interconnected with symbol resolution, is code verification and bug prediction. For instance, in this snippet, we try to resolve the `project.property` in the `commit` variable. If we don’t understand what type the `commit` variable holds, we can’t tell if it leads to a bug when calling the `project` method, or if our IDE can identify it correctly.
00:01:29 Annotating properties with YARD annotations can help alleviate this issue. However, if there are numerous false positives due to unknown types, static analysis becomes ineffective.
00:01:53 A third valuable use of types in Ruby is code assistance, like code completion that suggests the right values at the right time. This speeds up development, increases productivity, and may decrease the chances of introducing new bugs into your code.
00:02:27 Lastly, more complex features like refactorings greatly benefit from type information. For example, consider a method named `execute`. If we want to rename it to `dispatch`, it seems simple enough. However, what if there are multiple classes with a method named `execute`? How do we decide which methods to rename without confusion? Such decisions are complicated when method names are generic.
00:03:04 Let’s consider the broader perspective and focus on understanding why others feel types are crucial, particularly in terms of bug prediction.
00:03:25 From a non-ID perspective, does a good Ruby tool exist that helps predict bugs? One well-known tool is RuboCop, the most recognized linter for Ruby. It has many built-in inspections called 'cops' that check your code, and you can add custom cops based on your team’s preferences.
00:03:58 While RuboCop is great, it’s not a full-fledged code analysis tool. For example, it may catch code smells and suggest formatting changes, but it struggles with critical issues like sending a method to a hash that doesn’t support it. This limitation means RuboCop may miss actual errors.
00:04:26 This is where RubyMine shines. It understands that if a variable is of type Hash and we attempt to call a method that doesn’t exist on that type, it suggests that this is likely an error. However, due to Ruby's dynamic nature, such warnings may not be entirely reliable.
00:05:06 For instance, there may be cases where a method is dynamically created; the type of the return value is difficult to analyze. If the method returns an object that’s created through a factory method, understanding the expected return type can be problematic if the input is not known upfront.
00:05:43 In finding solutions to these issues, we must consider how we prove a program is correct before running it. Donald Knuth famously said, one cannot be sure a program works correctly until it is executed. This truth serves as a reminder that testing is an essential part of software development; however, testing can only prove the presence of bugs, not their absence.
00:06:28 There are two main issues here: we lack ideal static analysis tools, and running tests only examines a limited number of scenarios within a multitude of possible cases. One quote by Edsger Dijkstra states, 'Program testing can be used to show the presence of bugs but never to show their absence.' This reinforces the idea that testing alone is insufficient.
00:07:14 In Ruby, we often rely on Test-Driven Development (TDD), yet we do not have a robust static analysis tool. Even when achieving 100% test coverage, there is no guarantee that all potential issues in dependencies are covered. Integrating tests with third-party libraries is particularly challenging.
00:07:51 So, what can we do? We can continue running tests, identifying regressions in production, and analyzing code after each commit to understand changes. We can seek ways to combine the broad coverage of tests with static analysis capabilities.
00:08:34 Let’s consider an example where RSpec shows how types can vary based on input. Given a method that has varying outputs based on its input types, we can create rules depicting input-output relationships.
00:09:17 By executing a series of test cases and analyzing the types produced by the execution, we can compile these observations to help enhance our understanding of how our code operates under different conditions.
00:10:01 In essence, this process requires us to gather raw data through execution. We could employ TracePoint to capture method calls, variable states, and context, allowing us to create a comprehensive overview of the data relevant to our analysis.
00:10:36 Each time a method is called, we can gather contextual data. Through this, we track how parameter values change, creating a robust dataset that can aid in type contract generation.
00:11:10 As we gather more data, we can look to transform this raw information into type contracts—essentially refining it into a human-readable format that can be incorporated back into our codebase.
00:12:00 In doing this, we can address edge cases where the method’s behavior is unclear at runtime, particularly those scenarios where method parameters rely on default or multivariate states. This requires deeper investigation into the bytecode, which can reveal insights about how methods are constructed.
00:12:42 When we disassemble the bytecode, it can be intuitive to understand the flow of functions, helping identify points at which variables receive default values. This context is invaluable in understanding how data flows through our code.
00:13:20 Next, we consider an illustrative example demonstrating how to collect data from dynamically created methods. The goal is to generate rules that help pinpoint the types of input that lead to specific outputs.
00:14:05 By examining various inputs and their corresponding outputs, we simplify our dataset. We can maintain a form of minimalism in our findings, ensuring we only track the relevant connections.
00:14:42 In these instances, merging equivalent transitional states assists from an analytical perspective, streamlining the variables we need to track. The significant outcome is to produce streamlined automata that represent the many paths our inputs can take.
00:15:20 Through the process of automaton minimization, we can eliminate redundancy in the data, ultimately enabling us to represent our data models efficiently and succinctly.
00:16:05 A logical question arises when concerning methods that have undergone slight changes over time. In this case, we need to track these changes carefully to ensure consistent contracts across varying versions of the same method.
00:16:48 Next is the challenge of merging multiple automata into one coherent structure that accounts for all accepted inputs while considering the numerous variations each version may introduce. The interest lies in remaining efficient, avoiding excessive complexity in the structure.
00:17:35 Despite these complexities, we can identify key points within the runtime pointing back to prior states within our analyses—facilitating a smoother flow toward a reliable contract.
00:18:15 Fortunately, there are numerous resources available, including reading the Ruby C extension guides, to enhance our understanding of implementing tracepoints correctly.
00:18:59 Our next objective involves transforming the rich dataset of types we collect into more simplified contracts. The goal is to allow for easier readability and usability within our codebases.
00:19:35 Utilizing Ruby's capabilities, we can understand how various methods like String’s `split` function are used, and how these usages relate to the types we expect and their outputs.
00:20:15 By converting our analysis into tuples that denote input-output relationships, we begin to create a sophisticated framework for understanding and implementing types within Ruby, paving the way for effective type contracts.
00:20:57 Further, we compress our automata to eliminate excess nodes through systematic merging of similar behaviors. This allows us to recognize the recurring functionality of certain methods without unnecessarily complicating our contracts.
00:21:42 Finally, we strive to normalize any components in our contracts that may comprise duplicated or outdated elements, thereby refining our output.
00:22:20 With this approach, we can expose potential inconsistencies without losing the connection to the original method functionality, thereby securing our ongoing development efforts.
00:23:00 The highlighting benefit is the potential for alterations in existing code, while remaining confident that the type contracts intelligently evolve alongside method usage.
00:23:45 Should we modify methods, we expect that the generated contracts will gracefully accommodate these changes, presenting an updated view of expected types that account for previous behaviors.
00:24:30 Although challenges remain—particularly in harmonizing transitions between varying types—we believe our data collection efforts will lead to insights that promote the emergence of more consistent type contracts.
00:25:09 The quest fundamentally revolves around creating a system capable of generating reliable type annotations automatically, relying on the shared knowledge gleaned from community efforts across multiple applications.
00:25:51 This solution provides ample opportunities for our Ruby projects as others contribute their unique use cases back into a communal resource, enhancing the dataset available for analysis.
00:26:38 Ultimately, as we see more developers utilizing combined efforts, we anticipate achieving a more accurate representation of type contracts that foster greater inclusivity.
00:27:23 The data-driven results will also yield more comprehensive insights that may guide optimization efforts for Ruby's virtual machine in the future.
00:28:05 To conclude, I believe a successful implementation will not only enhance user experiences across Ruby projects but also uphold the joy of working with dynamic languages in general.
00:29:31 So, your idea is about collecting type data from Ruby applications and merging it into a broader pool of knowledge. You seem to assume that there will be many distinct type signatures across various projects?