From d73e9b4f2768334ba2332fb4347435bfd4ae5ec0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20No=C3=ABl?= Date: Sat, 14 Mar 2026 23:34:47 -0700 Subject: [PATCH 1/2] ci: add typo, packaging, and API coverage guardrails --- .codespellrc | 3 + .github/workflows/check-typo.yml | 15 ++-- .github/workflows/ci.yml | 6 ++ .pre-commit-config.yaml | 6 ++ CONTRIBUTING.md | 9 +++ scripts/check_api_coverage.rb | 127 +++++++++++++++++++++++++++++++ scripts/smoke_gem_install.sh | 44 +++++++++++ 7 files changed, 204 insertions(+), 6 deletions(-) create mode 100644 .codespellrc create mode 100755 scripts/check_api_coverage.rb create mode 100755 scripts/smoke_gem_install.sh diff --git a/.codespellrc b/.codespellrc new file mode 100644 index 0000000..4a00d7d --- /dev/null +++ b/.codespellrc @@ -0,0 +1,3 @@ +[codespell] +ignore-words = .codespellignore +ignore-multiline-regex = \{\s*/\*\s*\*\s*codespell:ignore-begin\s*\*\s*\*/\s*\}\s*.*?\s*\{\s*/\*\s*\*\s*codespell:ignore-end\s*\*\s*\*/\s*\} diff --git a/.github/workflows/check-typo.yml b/.github/workflows/check-typo.yml index ed93dc4..660511a 100644 --- a/.github/workflows/check-typo.yml +++ b/.github/workflows/check-typo.yml @@ -23,10 +23,13 @@ jobs: - name: Checkout Git Repository uses: actions/checkout@v6 - # The `skip` parameter is a total mess, read up before adding. - # https://github.com/codespell-project/codespell/issues/1915 - - name: Check Typo using codespell - uses: codespell-project/actions-codespell@v2 + - name: Set up Python + uses: actions/setup-python@v6 with: - check_filenames: true - ignore_words_file: .codespellignore + python-version: "3.13" + + - name: Install codespell + run: python -m pip install --upgrade codespell + + - name: Check Typo using codespell + run: codespell --check-filenames --config .codespellrc diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7984347..fc9c5fb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,6 +47,12 @@ jobs: rails-paradedb.gemspec \ Rakefile + - name: Check API coverage + run: ruby scripts/check_api_coverage.rb + + - name: Gem install smoke test + run: bash scripts/smoke_gem_install.sh + matrix-tests: name: Ruby ${{ matrix.ruby-version }} / Rails ${{ matrix.rails-version }} runs-on: ubuntu-latest diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f24e91e..65021b8 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -29,6 +29,12 @@ repos: hooks: - id: markdownlint + - repo: https://github.com/codespell-project/codespell + rev: v2.4.1 + hooks: + - id: codespell + args: ["--config", ".codespellrc", "--check-filenames"] + - repo: local hooks: - id: ruby-syntax diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 60c40eb..ed4e517 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -70,6 +70,15 @@ prek install -f If you change Ruby code, keep style consistent with existing files and tests. +### API and Packaging Consistency Checks + +Run these checks before opening a PR when you change API wrappers or packaging: + +```bash +ruby scripts/check_api_coverage.rb +bash scripts/smoke_gem_install.sh +``` + ### Pull Request Workflow 1. Ensure your change has an issue. diff --git a/scripts/check_api_coverage.rb b/scripts/check_api_coverage.rb new file mode 100755 index 0000000..7e7897d --- /dev/null +++ b/scripts/check_api_coverage.rb @@ -0,0 +1,127 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require "json" +require "pathname" +require "set" + +ROOT = Pathname(__dir__).join("..").expand_path +API_JSON = ROOT.join("api.json") +APIIGNORE_JSON = ROOT.join("apiignore.json") +TOKENIZER_TYPE_KEY_PREFIX = "PDB_TYPE_TOKENIZER_" + +def load_json(path) + JSON.parse(path.read) +rescue Errno::ENOENT + raise "#{path} not found" +rescue JSON::ParserError => e + raise "invalid JSON in #{path}: #{e.message}" +end + +def flatten_ignore(section, kind:) + case section + when nil + Set.new + when Array + Set.new(section.map(&:to_s)) + when Hash + Set.new(section.values.flatten.map(&:to_s)) + else + raise "apiignore #{kind} section must be an Array or object of Arrays" + end +end + +def source_paths + files = Dir.glob(ROOT.join("lib/**/*.rb").to_s).sort + rakefile = ROOT.join("Rakefile") + files << rakefile.to_s if rakefile.file? + files +end + +def read_sources + source_paths.to_h { |path| [path, File.read(path)] } +end + +def missing_quoted_tokens(expected_tokens, source_text) + expected_tokens.select do |token| + !source_text.match?(/["']#{Regexp.escape(token)}["']/) + end.sort +end + +def missing_symbols(expected_symbols, source_text) + expected_symbols.select { |symbol| !source_text.include?(symbol) }.sort +end + +def main + api = load_json(API_JSON) + apiignore = APIIGNORE_JSON.file? ? load_json(APIIGNORE_JSON) : {} + + operators = Set.new(api.fetch("operators").values.map(&:to_s)) + functions = Set.new(api.fetch("functions").values.map(&:to_s)) + all_types_by_key = api.fetch("types").transform_keys(&:to_s).transform_values(&:to_s) + + tokenizer_types = Set.new( + all_types_by_key + .select { |name, _| name.start_with?(TOKENIZER_TYPE_KEY_PREFIX) } + .values + ) + static_types = Set.new( + all_types_by_key + .reject { |name, _| name.start_with?(TOKENIZER_TYPE_KEY_PREFIX) } + .values + ) + + ignored_operators = flatten_ignore(apiignore["operators"], kind: "operators") + ignored_functions = flatten_ignore(apiignore["functions"], kind: "functions") + ignored_types = flatten_ignore(apiignore["types"], kind: "types") + + sources = read_sources + source_text = sources.values.join("\n") + + missing_ops = missing_quoted_tokens(operators, source_text) + missing_functions = missing_symbols(functions, source_text) + missing_static_types = missing_symbols(static_types, source_text) + + tokenizer_sql = sources.find { |path, _| path.end_with?("lib/parade_db/tokenizer_sql.rb") }&.last.to_s + dynamic_tokenizer_supported = tokenizer_sql.include?('"pdb.#{function_name}"') + missing_tokenizer_types = dynamic_tokenizer_supported ? [] : tokenizer_types.to_a.sort + + referenced_symbols = Set.new(source_text.scan(/\bpdb\.[a-zA-Z_][a-zA-Z0-9_]*\b/)) + allowed_symbols = functions | static_types | tokenizer_types | ignored_functions | ignored_types + untracked_symbols = (referenced_symbols - allowed_symbols).to_a.sort + + issues = [] + + unless missing_ops.empty? + issues << "operators declared in api.json but not found in Ruby wrappers: #{missing_ops.join(', ')}" + end + + unless missing_functions.empty? + issues << "functions declared in api.json but not found in Ruby wrappers: #{missing_functions.join(', ')}" + end + + unless missing_static_types.empty? + issues << "types declared in api.json but not found in Ruby wrappers: #{missing_static_types.join(', ')}" + end + + unless missing_tokenizer_types.empty? + issues << "tokenizer types require dynamic tokenizer qualification, but support was not detected: #{missing_tokenizer_types.join(', ')}" + end + + unless untracked_symbols.empty? + issues << "pdb.* symbols used in code but missing from api.json/apiignore.json: #{untracked_symbols.join(', ')}" + end + + if issues.empty? + puts "✅ API coverage check passed." + puts " operators: #{operators.size}, functions: #{functions.size}, types: #{all_types_by_key.size}, referenced symbols: #{referenced_symbols.size}" + return 0 + end + + warn "❌ API coverage check failed:" + issues.each { |issue| warn " - #{issue}" } + warn "\nUpdate api.json, apiignore.json, or wrapper usage so they stay in sync." + 1 +end + +exit(main) diff --git a/scripts/smoke_gem_install.sh b/scripts/smoke_gem_install.sh new file mode 100755 index 0000000..3fe9dff --- /dev/null +++ b/scripts/smoke_gem_install.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "${ROOT_DIR}" + +VERSION="$(ruby -e 'require File.expand_path("lib/parade_db/version", Dir.pwd); print ParadeDB::VERSION')" +GEM_FILE="rails-paradedb-${VERSION}.gem" + +rm -f "${GEM_FILE}" +gem build rails-paradedb.gemspec >/dev/null + +if [[ ! -f "${GEM_FILE}" ]]; then + echo "❌ Expected gem file not found after build: ${GEM_FILE}" >&2 + exit 1 +fi + +SMOKE_DIR="$(mktemp -d "${TMPDIR:-/tmp}/rails-paradedb-smoke.XXXXXX")" +GEM_HOME_DIR="${SMOKE_DIR}/gem-home" +HOME_DIR="${SMOKE_DIR}/home" + +cleanup() { + rm -rf "${SMOKE_DIR}" "${GEM_FILE}" +} +trap cleanup EXIT + +mkdir -p "${GEM_HOME_DIR}" "${HOME_DIR}" +export HOME="${HOME_DIR}" +export GEM_SPEC_CACHE="${SMOKE_DIR}/spec-cache" + +gem install --no-document --install-dir "${GEM_HOME_DIR}" "${GEM_FILE}" >/dev/null + +EXPECTED_VERSION="${VERSION}" GEM_HOME="${GEM_HOME_DIR}" GEM_PATH="${GEM_HOME_DIR}" ruby <<'RUBY' +require "parade_db" + +expected = ENV.fetch("EXPECTED_VERSION") +abort("Version mismatch: expected #{expected}, got #{ParadeDB::VERSION}") unless ParadeDB::VERSION == expected + +builder = ParadeDB::Arel::Builder.new("mock_items") +sql = ParadeDB::Arel.to_sql(builder.match(:description, "running shoes")) +abort("Generated SQL is missing ParadeDB match operator: #{sql}") unless sql.include?("&&&") + +puts "✅ Gem smoke install passed for rails-paradedb #{ParadeDB::VERSION}" +RUBY From 2932d1ec92b09bd91c6481b784ffd50a8f0c8d31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20No=C3=ABl?= Date: Sat, 14 Mar 2026 23:45:08 -0700 Subject: [PATCH 2/2] ci: align typo workflow with django and fix smoke script --- .github/workflows/check-typo.yml | 15 ++++++--------- scripts/smoke_gem_install.sh | 7 +++++-- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/.github/workflows/check-typo.yml b/.github/workflows/check-typo.yml index 660511a..ed93dc4 100644 --- a/.github/workflows/check-typo.yml +++ b/.github/workflows/check-typo.yml @@ -23,13 +23,10 @@ jobs: - name: Checkout Git Repository uses: actions/checkout@v6 - - name: Set up Python - uses: actions/setup-python@v6 - with: - python-version: "3.13" - - - name: Install codespell - run: python -m pip install --upgrade codespell - + # The `skip` parameter is a total mess, read up before adding. + # https://github.com/codespell-project/codespell/issues/1915 - name: Check Typo using codespell - run: codespell --check-filenames --config .codespellrc + uses: codespell-project/actions-codespell@v2 + with: + check_filenames: true + ignore_words_file: .codespellignore diff --git a/scripts/smoke_gem_install.sh b/scripts/smoke_gem_install.sh index 3fe9dff..bb64261 100755 --- a/scripts/smoke_gem_install.sh +++ b/scripts/smoke_gem_install.sh @@ -32,13 +32,16 @@ gem install --no-document --install-dir "${GEM_HOME_DIR}" "${GEM_FILE}" >/dev/nu EXPECTED_VERSION="${VERSION}" GEM_HOME="${GEM_HOME_DIR}" GEM_PATH="${GEM_HOME_DIR}" ruby <<'RUBY' require "parade_db" +require "arel" expected = ENV.fetch("EXPECTED_VERSION") abort("Version mismatch: expected #{expected}, got #{ParadeDB::VERSION}") unless ParadeDB::VERSION == expected builder = ParadeDB::Arel::Builder.new("mock_items") -sql = ParadeDB::Arel.to_sql(builder.match(:description, "running shoes")) -abort("Generated SQL is missing ParadeDB match operator: #{sql}") unless sql.include?("&&&") +node = builder.match(:description, "running shoes") +unless node.is_a?(Arel::Nodes::InfixOperation) && node.operator.to_s == "&&&" + abort("Generated node is not a ParadeDB match infix operation") +end puts "✅ Gem smoke install passed for rails-paradedb #{ParadeDB::VERSION}" RUBY