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..0c06c23 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,11 +6,19 @@ 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 ## [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/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 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']