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/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..bb64261 --- /dev/null +++ b/scripts/smoke_gem_install.sh @@ -0,0 +1,47 @@ +#!/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" +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") +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