From 391ea407712a1c84727978b536d76c5280a21286 Mon Sep 17 00:00:00 2001 From: Orien Madgwick <497874+orien@users.noreply.github.com> Date: Thu, 15 Jan 2026 11:09:58 +0700 Subject: [PATCH 1/2] Add RBS type signatures Introduces comprehensive RBS type signatures for the entire public API and internal classes. This provides improved type checking, better IDE support, and documentation for gem users. All type signatures have been validated through runtime type checking using `rbs test` against the full RSpec test suite (291 examples, 0 failures). Changes: - Add RBS signatures for all core classes (Zxcvbn, Tester, Score, Match, etc.) - Add rake tasks for RBS validation and testing (rbs:validate, rbs:test, rbs:list, rbs:parse) - Configure rbs_collection.yaml for dependency type signatures - Update gemspec to exclude rbs_collection.yaml from gem package - Add sig/README.md with usage instructions - Fix type signatures based on runtime validation: - Use Numeric instead of Float for entropy calculations (handles both Integer and Float) - Accept nullable parameters where nil is valid (password, Match in year_entropy) - Support Symbol keys in DictionaryRanker for test compatibility - Allow untyped arrays in user_inputs for proper sanitization --- .github/workflows/ci.yml | 38 +++++++++++++++---- .gitignore | 5 ++- CHANGELOG.md | 4 ++ Gemfile | 1 + Rakefile | 30 ++++++++++++++- rbs_collection.yaml | 11 ++++++ sig/README.md | 65 ++++++++++++++++++++++++++++++++ sig/zxcvbn.rbs | 10 +++++ sig/zxcvbn/crack_time.rbs | 13 +++++++ sig/zxcvbn/data.rbs | 31 +++++++++++++++ sig/zxcvbn/dictionary_ranker.rbs | 7 ++++ sig/zxcvbn/entropy.rbs | 33 ++++++++++++++++ sig/zxcvbn/feedback.rbs | 8 ++++ sig/zxcvbn/feedback_giver.rbs | 13 +++++++ sig/zxcvbn/match.rbs | 38 +++++++++++++++++++ sig/zxcvbn/math.rbs | 13 +++++++ sig/zxcvbn/omnimatch.rbs | 16 ++++++++ sig/zxcvbn/password_strength.rbs | 10 +++++ sig/zxcvbn/score.rbs | 15 ++++++++ sig/zxcvbn/scorer.rbs | 20 ++++++++++ sig/zxcvbn/tester.rbs | 17 +++++++++ sig/zxcvbn/trie.rbs | 13 +++++++ zxcvbn-ruby.gemspec | 2 +- 23 files changed, 403 insertions(+), 10 deletions(-) create mode 100644 rbs_collection.yaml create mode 100644 sig/README.md create mode 100644 sig/zxcvbn.rbs create mode 100644 sig/zxcvbn/crack_time.rbs create mode 100644 sig/zxcvbn/data.rbs create mode 100644 sig/zxcvbn/dictionary_ranker.rbs create mode 100644 sig/zxcvbn/entropy.rbs create mode 100644 sig/zxcvbn/feedback.rbs create mode 100644 sig/zxcvbn/feedback_giver.rbs create mode 100644 sig/zxcvbn/match.rbs create mode 100644 sig/zxcvbn/math.rbs create mode 100644 sig/zxcvbn/omnimatch.rbs create mode 100644 sig/zxcvbn/password_strength.rbs create mode 100644 sig/zxcvbn/score.rbs create mode 100644 sig/zxcvbn/scorer.rbs create mode 100644 sig/zxcvbn/tester.rbs create mode 100644 sig/zxcvbn/trie.rbs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 569a424..3d2f58d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,18 +1,42 @@ name: CI on: [push, pull_request] jobs: - test: + rspec: + name: RSpec (Ruby ${{ matrix.ruby }}) strategy: fail-fast: false matrix: - ruby: ['2.5', '2.6', '2.7', '3.0', '3.1', '3.2', '3.3', '3.4', '4.0'] + ruby: ['4.0', '3.4', '3.3', '3.2', '3.1', '3.0', '2.7', '2.6', '2.5'] runs-on: ubuntu-latest steps: - - name: Checkout - uses: actions/checkout@v4 - - name: Set up Ruby - uses: ruby/setup-ruby@v1 + - uses: actions/checkout@v6 + - uses: ruby/setup-ruby@v1 with: ruby-version: ${{ matrix.ruby }} bundler-cache: true - - run: bundle exec rake + - name: Run RSpec + run: bundle exec rake spec + + rubocop: + name: RuboCop + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: ruby/setup-ruby@v1 + with: + ruby-version: '4.0' + bundler-cache: true + - name: Run RuboCop + run: bundle exec rake rubocop + + rbs: + name: RBS + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: ruby/setup-ruby@v1 + with: + ruby-version: '4.0' + bundler-cache: true + - name: Validate RBS signatures + run: bundle exec rake rbs:validate diff --git a/.gitignore b/.gitignore index 1c18cb8..5b853d2 100644 --- a/.gitignore +++ b/.gitignore @@ -15,4 +15,7 @@ spec/reports test/tmp test/version_tmp tmp -bin/ \ No newline at end of file +bin/ + +# RBS +.gem_rbs_collection diff --git a/CHANGELOG.md b/CHANGELOG.md index 9477755..54e0c48 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,11 +6,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + - RBS type signatures for improved type checking and IDE support ([#68]) + ### Changed - Minor fixups in gem metadata ([#67]). [Unreleased]: https://github.com/envato/zxcvbn-ruby/compare/v1.3.0...HEAD [#67]: https://github.com/envato/zxcvbn-ruby/pull/67 +[#68]: https://github.com/envato/zxcvbn-ruby/pull/68 ## [1.3.0] - 2026-01-02 diff --git a/Gemfile b/Gemfile index 2abfca1..c56f2b0 100644 --- a/Gemfile +++ b/Gemfile @@ -10,6 +10,7 @@ group :development do gem 'guard-bundler', require: false gem 'guard-rspec', require: false gem 'rake' + gem 'rbs' end group :test do diff --git a/Rakefile b/Rakefile index 61c392a..b40170d 100644 --- a/Rakefile +++ b/Rakefile @@ -8,7 +8,35 @@ require 'rubocop/rake_task' RSpec::Core::RakeTask.new('spec') RuboCop::RakeTask.new('rubocop') -task default: %i[spec rubocop] +begin + require 'rbs/cli' + + namespace :rbs do + desc 'Validate RBS type signatures' + task :validate do + sh 'rbs -I sig validate' + end + + desc 'Run RBS runtime type checker with RSpec' + task :test do + sh "rbs -I sig test --target 'Zxcvbn::*' rspec" + end + + desc 'List RBS types' + task :list do + sh 'rbs -I sig list | grep Zxcvbn' + end + + desc 'Check RBS syntax' + task :parse do + sh 'rbs -I sig parse sig/**/*.rbs' + end + end +rescue LoadError + # RBS is not available +end + +task default: %i[spec rubocop rbs:validate] task :console do require 'zxcvbn' diff --git a/rbs_collection.yaml b/rbs_collection.yaml new file mode 100644 index 0000000..15bb883 --- /dev/null +++ b/rbs_collection.yaml @@ -0,0 +1,11 @@ +# RBS collection configuration +# This file specifies which type signature repositories to use + +sources: + - type: git + name: ruby/gem_rbs_collection + remote: https://github.com/ruby/gem_rbs_collection.git + revision: main + repo_dir: gems + +path: .gem_rbs_collection diff --git a/sig/README.md b/sig/README.md new file mode 100644 index 0000000..e55e781 --- /dev/null +++ b/sig/README.md @@ -0,0 +1,65 @@ +# RBS Type Signatures + +This directory contains [RBS](https://github.com/ruby/rbs) type signatures for the zxcvbn-ruby gem. + +## What is RBS? + +RBS is Ruby's type signature language. It provides a way to describe the structure of Ruby programs with: +- Class and module definitions +- Method signatures with parameter and return types +- Instance variables and constants +- Duck typing and union types + +## Usage + +### Validating Type Signatures + +To validate that the RBS files are syntactically correct: + +```bash +bundle exec rake rbs:validate +``` + +### Runtime Type Checking + +To run runtime type checking against the actual Ruby code during tests: + +```bash +bundle exec rake rbs:test +``` + +This runs the RSpec test suite with RBS type checking enabled, verifying that method calls match their type signatures at runtime. Note: This takes about 2 minutes to run. + +### Other Useful Commands + +List all Zxcvbn types: +```bash +bundle exec rake rbs:list +``` + +Check syntax of RBS files: +```bash +bundle exec rake rbs:parse +``` + +## File Structure + +The signatures mirror the structure of the `lib/` directory: + +- `sig/zxcvbn.rbs` - Main Zxcvbn module +- `sig/zxcvbn/*.rbs` - Core classes (Tester, Score, Match, etc.) +- `sig/zxcvbn/matchers/*.rbs` - Pattern matcher classes + +## Adding New Signatures + +When adding new classes or methods to the codebase, remember to: + +1. Create or update the corresponding `.rbs` file in the `sig/` directory +2. Run `bundle exec rake rbs_validate` to ensure the syntax is correct +3. Keep type signatures in sync with the actual implementation + +## Resources + +- [RBS Documentation](https://github.com/ruby/rbs) +- [RBS Syntax Guide](https://github.com/ruby/rbs/blob/master/docs/syntax.md) +- [Ruby Signature Collection](https://github.com/ruby/gem_rbs_collection) diff --git a/sig/zxcvbn.rbs b/sig/zxcvbn.rbs new file mode 100644 index 0000000..1270a2e --- /dev/null +++ b/sig/zxcvbn.rbs @@ -0,0 +1,10 @@ +module Zxcvbn + VERSION: String + + DATA_PATH: Pathname + + type word_list = Hash[String, Array[String]] + type user_inputs = Array[String] + + def self.test: (String? password, ?user_inputs user_inputs, ?word_list word_lists) -> Score +end diff --git a/sig/zxcvbn/crack_time.rbs b/sig/zxcvbn/crack_time.rbs new file mode 100644 index 0000000..e1780c1 --- /dev/null +++ b/sig/zxcvbn/crack_time.rbs @@ -0,0 +1,13 @@ +module Zxcvbn + module CrackTime + SINGLE_GUESS: Float + NUM_ATTACKERS: Integer + SECONDS_PER_GUESS: Float + + def entropy_to_crack_time: (Numeric entropy) -> Float + + def crack_time_to_score: (Numeric seconds) -> Integer + + def display_time: (Numeric seconds) -> String + end +end diff --git a/sig/zxcvbn/data.rbs b/sig/zxcvbn/data.rbs new file mode 100644 index 0000000..75c5596 --- /dev/null +++ b/sig/zxcvbn/data.rbs @@ -0,0 +1,31 @@ +module Zxcvbn + class Data + type ranked_dictionary = Hash[String, Integer] + type adjacency_graph = Hash[String, Array[String?]] + type graph_stats = Hash[String, { average_degree: Float, starting_positions: Integer }] + + @ranked_dictionaries: Hash[String, ranked_dictionary] + @adjacency_graphs: Hash[String, adjacency_graph] + @dictionary_tries: Hash[String, Trie] + @graph_stats: graph_stats + + attr_reader ranked_dictionaries: Hash[String, ranked_dictionary] + attr_reader adjacency_graphs: Hash[String, adjacency_graph] + attr_reader dictionary_tries: Hash[String, Trie] + attr_reader graph_stats: graph_stats + + def initialize: () -> void + + def add_word_list: (String name, Array[String] list) -> void + + private + + def read_word_list: (String file) -> Array[String] + + def build_tries: () -> Hash[String, Trie] + + def build_trie: (ranked_dictionary ranked_dictionary) -> Trie + + def compute_graph_stats: () -> graph_stats + end +end diff --git a/sig/zxcvbn/dictionary_ranker.rbs b/sig/zxcvbn/dictionary_ranker.rbs new file mode 100644 index 0000000..05ded1c --- /dev/null +++ b/sig/zxcvbn/dictionary_ranker.rbs @@ -0,0 +1,7 @@ +module Zxcvbn + class DictionaryRanker + def self.rank_dictionaries: (Hash[String | Symbol, Array[String]] lists) -> Hash[String | Symbol, Hash[String, Integer]] + + def self.rank_dictionary: (Array[String] words) -> Hash[String, Integer] + end +end diff --git a/sig/zxcvbn/entropy.rbs b/sig/zxcvbn/entropy.rbs new file mode 100644 index 0000000..02d20c8 --- /dev/null +++ b/sig/zxcvbn/entropy.rbs @@ -0,0 +1,33 @@ +module Zxcvbn + module Entropy + include Zxcvbn::Math + + def calc_entropy: (Match match) -> Float + + def repeat_entropy: (Match match) -> Float + + def sequence_entropy: (Match match) -> Float + + def digits_entropy: (Match match) -> Float + + def year_entropy: (Match? match) -> Float + + def date_entropy: (Match match) -> Float + + def dictionary_entropy: (Match match) -> Float + + def extra_uppercase_entropy: (Match match) -> Numeric + + def extra_l33t_entropy: (Match match) -> Numeric + + def spatial_entropy: (Match match) -> Float + + NUM_YEARS: Integer + NUM_MONTHS: Integer + NUM_DAYS: Integer + START_UPPER: Regexp + END_UPPER: Regexp + ALL_UPPER: Regexp + ALL_LOWER: Regexp + end +end diff --git a/sig/zxcvbn/feedback.rbs b/sig/zxcvbn/feedback.rbs new file mode 100644 index 0000000..00871f6 --- /dev/null +++ b/sig/zxcvbn/feedback.rbs @@ -0,0 +1,8 @@ +module Zxcvbn + class Feedback + attr_accessor warning: String? + attr_accessor suggestions: Array[String] + + def initialize: (?warning: String?, ?suggestions: Array[String]) -> void + end +end diff --git a/sig/zxcvbn/feedback_giver.rbs b/sig/zxcvbn/feedback_giver.rbs new file mode 100644 index 0000000..76f3799 --- /dev/null +++ b/sig/zxcvbn/feedback_giver.rbs @@ -0,0 +1,13 @@ +module Zxcvbn + class FeedbackGiver + NAME_DICTIONARIES: Array[String] + DEFAULT_FEEDBACK: Feedback + EMPTY_FEEDBACK: Feedback + + def self.get_feedback: (Integer? score, Array[Match] sequence) -> Feedback + + def self.get_match_feedback: (Match match, bool is_sole_match) -> Feedback? + + def self.get_dictionary_match_feedback: (Match match, bool is_sole_match) -> Feedback + end +end diff --git a/sig/zxcvbn/match.rbs b/sig/zxcvbn/match.rbs new file mode 100644 index 0000000..cec0aec --- /dev/null +++ b/sig/zxcvbn/match.rbs @@ -0,0 +1,38 @@ +module Zxcvbn + class Match + attr_accessor pattern: String? + attr_accessor i: Integer? + attr_accessor j: Integer? + attr_accessor token: String? + attr_accessor matched_word: String? + attr_accessor rank: Integer? + attr_accessor dictionary_name: String? + attr_accessor reversed: bool? + attr_accessor l33t: bool? + attr_accessor sub: Hash[String, String]? + attr_accessor sub_display: String? + attr_accessor l: Integer? + attr_accessor entropy: Numeric? + attr_accessor base_entropy: Numeric? + attr_accessor uppercase_entropy: Numeric? + attr_accessor l33t_entropy: Numeric? + attr_accessor repeated_char: String? + attr_accessor sequence_name: String? + attr_accessor sequence_space: Integer? + attr_accessor ascending: bool? + attr_accessor graph: String? + attr_accessor turns: Integer? + attr_accessor shifted_count: Integer? + attr_accessor shiffted_count: Integer? + attr_accessor year: Integer? + attr_accessor month: Integer? + attr_accessor day: Integer? + attr_accessor separator: String? + attr_accessor cardinality: Integer? + attr_accessor offset: Integer? + + def initialize: (**untyped attributes) -> void + + def to_hash: () -> Hash[String, untyped] + end +end diff --git a/sig/zxcvbn/math.rbs b/sig/zxcvbn/math.rbs new file mode 100644 index 0000000..d8183eb --- /dev/null +++ b/sig/zxcvbn/math.rbs @@ -0,0 +1,13 @@ +module Zxcvbn + module Math + def bruteforce_cardinality: (String password) -> Integer + + def lg: (Numeric n) -> Float + + def nCk: (Integer n, Integer k) -> Integer + + def average_degree_for_graph: (String graph_name) -> Float + + def starting_positions_for_graph: (String graph_name) -> Integer + end +end diff --git a/sig/zxcvbn/omnimatch.rbs b/sig/zxcvbn/omnimatch.rbs new file mode 100644 index 0000000..e3c9803 --- /dev/null +++ b/sig/zxcvbn/omnimatch.rbs @@ -0,0 +1,16 @@ +module Zxcvbn + class Omnimatch + @data: Data + @matchers: Array[untyped] + + def initialize: (Data data) -> void + + def matches: (String password, ?Array[String] user_inputs) -> Array[Match] + + private + + def user_input_matchers: (Array[String] user_inputs) -> Array[untyped] + + def build_matchers: () -> Array[untyped] + end +end diff --git a/sig/zxcvbn/password_strength.rbs b/sig/zxcvbn/password_strength.rbs new file mode 100644 index 0000000..a33b6b3 --- /dev/null +++ b/sig/zxcvbn/password_strength.rbs @@ -0,0 +1,10 @@ +module Zxcvbn + class PasswordStrength + @omnimatch: Omnimatch + @scorer: Scorer + + def initialize: (Data data) -> void + + def test: (String? password, ?Array[untyped] user_inputs) -> Score + end +end diff --git a/sig/zxcvbn/score.rbs b/sig/zxcvbn/score.rbs new file mode 100644 index 0000000..418da03 --- /dev/null +++ b/sig/zxcvbn/score.rbs @@ -0,0 +1,15 @@ +module Zxcvbn + class Score + attr_accessor entropy: Numeric? + attr_accessor crack_time: Numeric? + attr_accessor crack_time_display: String? + attr_accessor score: Integer? + attr_accessor pattern: String? + attr_accessor match_sequence: Array[Match]? + attr_accessor password: String? + attr_accessor calc_time: Float? + attr_accessor feedback: Feedback? + + def initialize: (?entropy: Numeric?, ?crack_time: Numeric?, ?crack_time_display: String?, ?score: Integer?, ?match_sequence: Array[Match]?, ?password: String?) -> void + end +end diff --git a/sig/zxcvbn/scorer.rbs b/sig/zxcvbn/scorer.rbs new file mode 100644 index 0000000..cce1e56 --- /dev/null +++ b/sig/zxcvbn/scorer.rbs @@ -0,0 +1,20 @@ +module Zxcvbn + class Scorer + include Entropy + include CrackTime + + @data: Data + + attr_reader data: Data + + def initialize: (Data data) -> void + + def minimum_entropy_match_sequence: (String password, Array[Match] matches) -> Score + + def score_for: (String password, Array[Match] match_sequence, Array[Float] up_to_k) -> Score + + def pad_with_bruteforce_matches: (Array[Match] match_sequence, String password, Integer bruteforce_cardinality) -> Array[Match] + + def make_bruteforce_match: (String password, Integer i, Integer j, Integer bruteforce_cardinality) -> Match + end +end diff --git a/sig/zxcvbn/tester.rbs b/sig/zxcvbn/tester.rbs new file mode 100644 index 0000000..bb04c56 --- /dev/null +++ b/sig/zxcvbn/tester.rbs @@ -0,0 +1,17 @@ +module Zxcvbn + class Tester + @data: Data + + def initialize: () -> void + + def test: (String? password, ?Array[untyped] user_inputs) -> Score + + def add_word_lists: (Hash[String, Array[untyped]] lists) -> void + + def inspect: () -> String + + private + + def sanitize: (Array[untyped] user_inputs) -> Array[String] + end +end diff --git a/sig/zxcvbn/trie.rbs b/sig/zxcvbn/trie.rbs new file mode 100644 index 0000000..31b6782 --- /dev/null +++ b/sig/zxcvbn/trie.rbs @@ -0,0 +1,13 @@ +module Zxcvbn + class Trie + type node = Hash[untyped, untyped] + + @root: node + + def initialize: () -> void + + def insert: (String word, Integer rank) -> void + + def search_prefixes: (String text, Integer start_pos) -> Array[[String, Integer?, Integer, Integer]] + end +end diff --git a/zxcvbn-ruby.gemspec b/zxcvbn-ruby.gemspec index 578d6bf..fde4c4d 100644 --- a/zxcvbn-ruby.gemspec +++ b/zxcvbn-ruby.gemspec @@ -10,7 +10,7 @@ Gem::Specification.new do |gem| gem.homepage = 'https://github.com/envato/zxcvbn-ruby' gem.files = `git ls-files -z`.split("\x0").reject do |file| - file.match(%r{^(\.|CODE_OF_CONDUCT.md|Gemfile|Rakefile|Guardfile|zxcvbn-ruby.gemspec|spec/)}) + file.match(%r{^(\.|CODE_OF_CONDUCT.md|Gemfile|Rakefile|Guardfile|zxcvbn-ruby.gemspec|rbs_collection.yaml|spec/)}) end gem.name = 'zxcvbn-ruby' gem.require_paths = ['lib'] From 7b339e89b007299486a73f47335351d7664590e1 Mon Sep 17 00:00:00 2001 From: Orien Madgwick <497874+orien@users.noreply.github.com> Date: Thu, 15 Jan 2026 12:13:40 +0700 Subject: [PATCH 2/2] Release 1.4.0 --- CHANGELOG.md | 6 +++++- lib/zxcvbn/version.rb | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 54e0c48..0c06c23 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,13 +6,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +[Unreleased]: https://github.com/envato/zxcvbn-ruby/compare/v1.4.0...HEAD + +## [1.4.0] - 2026-01-15 + ### Added - RBS type signatures for improved type checking and IDE support ([#68]) ### Changed - Minor fixups in gem metadata ([#67]). -[Unreleased]: https://github.com/envato/zxcvbn-ruby/compare/v1.3.0...HEAD +[1.4.0]: https://github.com/envato/zxcvbn-ruby/compare/v1.3.0...v1.4.0 [#67]: https://github.com/envato/zxcvbn-ruby/pull/67 [#68]: https://github.com/envato/zxcvbn-ruby/pull/68 diff --git a/lib/zxcvbn/version.rb b/lib/zxcvbn/version.rb index 7b714c4..8e1f864 100644 --- a/lib/zxcvbn/version.rb +++ b/lib/zxcvbn/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module Zxcvbn - VERSION = '1.3.0' + VERSION = '1.4.0' end