Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .codespellrc
Original file line number Diff line number Diff line change
@@ -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*\}
6 changes: 6 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
127 changes: 127 additions & 0 deletions scripts/check_api_coverage.rb
Original file line number Diff line number Diff line change
@@ -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)
47 changes: 47 additions & 0 deletions scripts/smoke_gem_install.sh
Original file line number Diff line number Diff line change
@@ -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
Loading