From dde59bc80c21e6ab4703b0f95fda4816fc175c55 Mon Sep 17 00:00:00 2001 From: JohnnyT Date: Sun, 31 Aug 2025 05:12:54 -0600 Subject: [PATCH 1/4] Adds git commit message info to CLAUDE --- CLAUDE.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 17eb82e..f45202e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -416,3 +416,5 @@ test/predicator/ - Elixir ~> 1.11 required - All dependencies in development/test only - No runtime dependencies for core functionality + +- When creating git commit messages, be concise but informative, and highlight the functional changes. No need to mention code quality improvements as they are expected (unless the functional change is about code quality improvements) \ No newline at end of file From a37f83db6102f52b5ff99dead0f73e3e8680ca1e Mon Sep 17 00:00:00 2001 From: JohnnyT Date: Sun, 31 Aug 2025 05:31:56 -0600 Subject: [PATCH 2/4] Adds strict equality operators (=== and !==) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements strict equality and strict inequality operators with proper tokenization, parsing, evaluation, and decompilation support. Operators work across all data types including undefined values and preserve round-trip accuracy. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- CLAUDE.md | 2 +- lib/predicator/evaluator.ex | 16 +- lib/predicator/lexer.ex | 10 + lib/predicator/parser.ex | 30 ++- .../visitors/instructions_visitor.ex | 3 + lib/predicator/visitors/string_visitor.ex | 3 + test/predicator/parser_test.exs | 2 +- test/predicator/strict_equality_test.exs | 246 ++++++++++++++++++ 8 files changed, 302 insertions(+), 10 deletions(-) create mode 100644 test/predicator/strict_equality_test.exs diff --git a/CLAUDE.md b/CLAUDE.md index f45202e..5fa6a44 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -417,4 +417,4 @@ test/predicator/ - All dependencies in development/test only - No runtime dependencies for core functionality -- When creating git commit messages, be concise but informative, and highlight the functional changes. No need to mention code quality improvements as they are expected (unless the functional change is about code quality improvements) \ No newline at end of file +- When creating git commit messages, be concise but informative, and highlight the functional changes. No need to mention code quality improvements as they are expected (unless the functional change is about code quality improvements) diff --git a/lib/predicator/evaluator.ex b/lib/predicator/evaluator.ex index aa897cc..d704f29 100644 --- a/lib/predicator/evaluator.ex +++ b/lib/predicator/evaluator.ex @@ -226,7 +226,7 @@ defmodule Predicator.Evaluator do # Comparison instruction defp execute_instruction(%__MODULE__{} = evaluator, ["compare", operator]) - when operator in ["GT", "LT", "EQ", "GTE", "LTE", "NE"] do + when operator in ["GT", "LT", "EQ", "GTE", "LTE", "NE", "STRICT_EQ", "STRICT_NE"] do execute_compare(evaluator, operator) end @@ -362,8 +362,18 @@ defmodule Predicator.Evaluator do (is_map(a) and is_map(b) and not is_struct(a) and not is_struct(b)) @spec compare_values(Types.value(), Types.value(), binary()) :: Types.value() - defp compare_values(:undefined, _right, _operator), do: :undefined - defp compare_values(_left, :undefined, _operator), do: :undefined + # Handle undefined values for non-strict operators + defp compare_values(:undefined, _right, operator) + when operator not in ["STRICT_EQ", "STRICT_NE"], + do: :undefined + + defp compare_values(_left, :undefined, operator) + when operator not in ["STRICT_EQ", "STRICT_NE"], + do: :undefined + + # Strict equality works on all types, including :undefined + defp compare_values(left, right, "STRICT_EQ"), do: left === right + defp compare_values(left, right, "STRICT_NE"), do: left !== right defp compare_values(left, right, operator) when types_match(left, right) do case operator do diff --git a/lib/predicator/lexer.ex b/lib/predicator/lexer.ex index d5878af..0a136f6 100644 --- a/lib/predicator/lexer.ex +++ b/lib/predicator/lexer.ex @@ -52,6 +52,8 @@ defmodule Predicator.Lexer do | {:eq, pos_integer(), pos_integer(), pos_integer(), binary()} | {:ne, pos_integer(), pos_integer(), pos_integer(), binary()} | {:equal_equal, pos_integer(), pos_integer(), pos_integer(), binary()} + | {:strict_equal, pos_integer(), pos_integer(), pos_integer(), binary()} + | {:strict_ne, pos_integer(), pos_integer(), pos_integer(), binary()} | {:plus, pos_integer(), pos_integer(), pos_integer(), binary()} | {:minus, pos_integer(), pos_integer(), pos_integer(), binary()} | {:multiply, pos_integer(), pos_integer(), pos_integer(), binary()} @@ -251,6 +253,10 @@ defmodule Predicator.Lexer do ?! -> case rest do + [?=, ?= | rest3] -> + token = {:strict_ne, line, col, 3, "!=="} + tokenize_chars(rest3, line, col + 3, [token | tokens]) + [?= | rest2] -> token = {:ne, line, col, 2, "!="} tokenize_chars(rest2, line, col + 2, [token | tokens]) @@ -262,6 +268,10 @@ defmodule Predicator.Lexer do ?= -> case rest do + [?=, ?= | rest3] -> + token = {:strict_equal, line, col, 3, "==="} + tokenize_chars(rest3, line, col + 3, [token | tokens]) + [?= | rest2] -> token = {:equal_equal, line, col, 2, "=="} tokenize_chars(rest2, line, col + 2, [token | tokens]) diff --git a/lib/predicator/parser.ex b/lib/predicator/parser.ex index 6224571..be73859 100644 --- a/lib/predicator/parser.ex +++ b/lib/predicator/parser.ex @@ -100,7 +100,8 @@ defmodule Predicator.Parser do @typedoc """ Comparison operators in the AST. """ - @type comparison_op :: :gt | :lt | :gte | :lte | :eq | :ne + @type comparison_op :: + :gt | :lt | :gte | :lte | :eq | :equal_equal | :ne | :strict_eq | :strict_ne @typedoc """ Arithmetic operators in the AST. @@ -330,13 +331,30 @@ defmodule Predicator.Parser do case peek_token(new_state) do # Comparison operators (including equality) {op_type, _line, _col, _len, _value} - when op_type in [:gt, :lt, :gte, :lte, :eq, :equal_equal, :ne] -> + when op_type in [ + :gt, + :lt, + :gte, + :lte, + :eq, + :equal_equal, + :ne, + :strict_equal, + :strict_ne + ] -> op_state = advance(new_state) case parse_addition(op_state) do {:ok, right, final_state} -> - # Map == to :eq for consistency, != stays as :ne - normalized_op = if op_type == :equal_equal, do: :eq, else: op_type + # Map tokens to AST operators + normalized_op = + case op_type do + :equal_equal -> :equal_equal + :strict_equal -> :strict_eq + :strict_ne -> :strict_ne + _other_op_type -> op_type + end + ast = {:comparison, normalized_op, left, right} {:ok, ast, final_state} @@ -724,6 +742,9 @@ defmodule Predicator.Parser do defp format_token(:lte, _value), do: "'<='" defp format_token(:eq, _value), do: "'='" defp format_token(:ne, _value), do: "'!='" + defp format_token(:equal_equal, _value), do: "'=='" + defp format_token(:strict_equal, _value), do: "'==='" + defp format_token(:strict_ne, _value), do: "'!=='" defp format_token(:and_op, _value), do: "'AND'" defp format_token(:or_op, _value), do: "'OR'" defp format_token(:not_op, _value), do: "'NOT'" @@ -742,7 +763,6 @@ defmodule Predicator.Parser do defp format_token(:multiply, _value), do: "'*'" defp format_token(:divide, _value), do: "'/'" defp format_token(:modulo, _value), do: "'%'" - defp format_token(:equal_equal, _value), do: "'=='" defp format_token(:and_and, _value), do: "'&&'" defp format_token(:or_or, _value), do: "'||'" defp format_token(:bang, _value), do: "'!'" diff --git a/lib/predicator/visitors/instructions_visitor.ex b/lib/predicator/visitors/instructions_visitor.ex index 8522b39..c0819f5 100644 --- a/lib/predicator/visitors/instructions_visitor.ex +++ b/lib/predicator/visitors/instructions_visitor.ex @@ -193,7 +193,10 @@ defmodule Predicator.Visitors.InstructionsVisitor do defp map_comparison_op(:gte), do: "GTE" defp map_comparison_op(:lte), do: "LTE" defp map_comparison_op(:eq), do: "EQ" + defp map_comparison_op(:equal_equal), do: "EQ" defp map_comparison_op(:ne), do: "NE" + defp map_comparison_op(:strict_eq), do: "STRICT_EQ" + defp map_comparison_op(:strict_ne), do: "STRICT_NE" # Helper function to map AST arithmetic operators to instruction format @spec map_arithmetic_op(Parser.arithmetic_op()) :: binary() diff --git a/lib/predicator/visitors/string_visitor.ex b/lib/predicator/visitors/string_visitor.ex index 2e17238..3b4eecb 100644 --- a/lib/predicator/visitors/string_visitor.ex +++ b/lib/predicator/visitors/string_visitor.ex @@ -241,7 +241,10 @@ defmodule Predicator.Visitors.StringVisitor do defp format_operator(:gte), do: ">=" defp format_operator(:lte), do: "<=" defp format_operator(:eq), do: "=" + defp format_operator(:equal_equal), do: "==" defp format_operator(:ne), do: "!=" + defp format_operator(:strict_eq), do: "===" + defp format_operator(:strict_ne), do: "!==" defp format_operator(:add), do: "+" defp format_operator(:subtract), do: "-" defp format_operator(:multiply), do: "*" diff --git a/test/predicator/parser_test.exs b/test/predicator/parser_test.exs index 9acf99c..4e43ae8 100644 --- a/test/predicator/parser_test.exs +++ b/test/predicator/parser_test.exs @@ -1065,7 +1065,7 @@ defmodule Predicator.ParserTest do result = Parser.parse(tokens) expected_ast = - {:comparison, :eq, {:arithmetic, :add, {:identifier, "a"}, {:identifier, "b"}}, + {:comparison, :equal_equal, {:arithmetic, :add, {:identifier, "a"}, {:identifier, "b"}}, {:arithmetic, :multiply, {:identifier, "c"}, {:identifier, "d"}}} assert {:ok, ^expected_ast} = result diff --git a/test/predicator/strict_equality_test.exs b/test/predicator/strict_equality_test.exs new file mode 100644 index 0000000..01b8903 --- /dev/null +++ b/test/predicator/strict_equality_test.exs @@ -0,0 +1,246 @@ +defmodule Predicator.StrictEqualityTest do + @moduledoc """ + Comprehensive tests for strict equality operators (=== and !==). + """ + + use ExUnit.Case + + describe "lexer tokenization" do + test "tokenizes === as strict_equal" do + {:ok, tokens} = Predicator.Lexer.tokenize("a === b") + + assert tokens == [ + {:identifier, 1, 1, 1, "a"}, + {:strict_equal, 1, 3, 3, "==="}, + {:identifier, 1, 7, 1, "b"}, + {:eof, 1, 8, 0, nil} + ] + end + + test "tokenizes !== as strict_ne" do + {:ok, tokens} = Predicator.Lexer.tokenize("x !== y") + + assert tokens == [ + {:identifier, 1, 1, 1, "x"}, + {:strict_ne, 1, 3, 3, "!=="}, + {:identifier, 1, 7, 1, "y"}, + {:eof, 1, 8, 0, nil} + ] + end + + test "distinguishes === from == and =" do + {:ok, tokens} = Predicator.Lexer.tokenize("a = b == c === d") + + assert tokens == [ + {:identifier, 1, 1, 1, "a"}, + {:eq, 1, 3, 1, "="}, + {:identifier, 1, 5, 1, "b"}, + {:equal_equal, 1, 7, 2, "=="}, + {:identifier, 1, 10, 1, "c"}, + {:strict_equal, 1, 12, 3, "==="}, + {:identifier, 1, 16, 1, "d"}, + {:eof, 1, 17, 0, nil} + ] + end + + test "distinguishes !== from !=" do + {:ok, tokens} = Predicator.Lexer.tokenize("a != b !== c") + + assert tokens == [ + {:identifier, 1, 1, 1, "a"}, + {:ne, 1, 3, 2, "!="}, + {:identifier, 1, 6, 1, "b"}, + {:strict_ne, 1, 8, 3, "!=="}, + {:identifier, 1, 12, 1, "c"}, + {:eof, 1, 13, 0, nil} + ] + end + + test "handles strict operators in complex expressions" do + {:ok, tokens} = Predicator.Lexer.tokenize("(x === 1) AND (y !== 'text')") + + assert Enum.any?(tokens, fn token -> match?({:strict_equal, _, _, _, _}, token) end) + assert Enum.any?(tokens, fn token -> match?({:strict_ne, _, _, _, _}, token) end) + end + end + + describe "parser AST generation" do + test "parses === as strict_eq comparison" do + {:ok, ast} = Predicator.parse("value === 42") + + assert ast == {:comparison, :strict_eq, {:identifier, "value"}, {:literal, 42}} + end + + test "parses !== as strict_ne comparison" do + {:ok, ast} = Predicator.parse("name !== 'John'") + + assert ast == + {:comparison, :strict_ne, {:identifier, "name"}, + {:string_literal, "John", :single}} + end + + test "handles mixed equality operators with correct precedence" do + # Test that different operators can be used in separate comparisons with logical operators + {:ok, ast} = Predicator.parse("(a === b) AND (c != d)") + + assert match?( + {:logical_and, {:comparison, :strict_eq, _, _}, {:comparison, :ne, _, _}}, + ast + ) + end + + test "parses complex expressions with strict operators" do + {:ok, ast} = Predicator.parse("(x === 1) AND (y !== 'test')") + + assert match?( + {:logical_and, {:comparison, :strict_eq, _, _}, {:comparison, :strict_ne, _, _}}, + ast + ) + end + end + + describe "instruction compilation" do + test "compiles === to STRICT_EQ instruction" do + {:ok, instructions} = Predicator.compile("value === 42") + + assert instructions == [ + ["load", "value"], + ["lit", 42], + ["compare", "STRICT_EQ"] + ] + end + + test "compiles !== to STRICT_NE instruction" do + {:ok, instructions} = Predicator.compile("name !== 'test'") + + assert instructions == [ + ["load", "name"], + ["lit", "test"], + ["compare", "STRICT_NE"] + ] + end + end + + describe "evaluation behavior" do + test "=== performs strict equality (same type and value)" do + # Same type and value - true + assert {:ok, true} = Predicator.evaluate("5 === 5", %{}) + assert {:ok, true} = Predicator.evaluate("'hello' === 'hello'", %{}) + assert {:ok, true} = Predicator.evaluate("true === true", %{}) + + # Different types - false + assert {:ok, false} = Predicator.evaluate("5 === '5'", %{}) + assert {:ok, false} = Predicator.evaluate("1 === true", %{}) + assert {:ok, false} = Predicator.evaluate("0 === false", %{}) + end + + test "!== performs strict inequality" do + # Different types - true + assert {:ok, true} = Predicator.evaluate("5 !== '5'", %{}) + assert {:ok, true} = Predicator.evaluate("1 !== true", %{}) + assert {:ok, true} = Predicator.evaluate("0 !== false", %{}) + + # Same type and value - false + assert {:ok, false} = Predicator.evaluate("5 !== 5", %{}) + assert {:ok, false} = Predicator.evaluate("'hello' !== 'hello'", %{}) + assert {:ok, false} = Predicator.evaluate("true !== true", %{}) + end + + test "=== vs == behavior differences" do + # Test with different value types + # Both should be true for same values + # Same value, same type + assert {:ok, true} = Predicator.evaluate("1 == 1", %{}) + # Same value, same type + assert {:ok, true} = Predicator.evaluate("1 === 1", %{}) + + # Same type comparisons + assert {:ok, true} = Predicator.evaluate("1 == 1", %{}) + assert {:ok, true} = Predicator.evaluate("1 === 1", %{}) + end + + test "!== vs != behavior differences" do + # Different types should behave differently + # Same value, same type + assert {:ok, false} = Predicator.evaluate("1 != 1", %{}) + # Same value, same type + assert {:ok, false} = Predicator.evaluate("1 !== 1", %{}) + + # Different values + assert {:ok, true} = Predicator.evaluate("1 != 2", %{}) + # Different values + assert {:ok, true} = Predicator.evaluate("1 !== 2", %{}) + end + + test "evaluates with context variables" do + context = %{"num" => 42, "str" => "42", "flag" => true} + + assert {:ok, true} = Predicator.evaluate("num === 42", context) + assert {:ok, false} = Predicator.evaluate("num === str", context) + assert {:ok, false} = Predicator.evaluate("flag === 1", context) + assert {:ok, true} = Predicator.evaluate("num !== str", context) + end + + test "handles complex expressions with mixed operators" do + context = %{"a" => 1, "b" => 1, "c" => true} + + # Mixed strict and loose equality with same values + assert {:ok, true} = Predicator.evaluate("a == b AND a === b", context) + assert {:ok, false} = Predicator.evaluate("a !== b OR c === 1", context) + end + end + + describe "string visitor decompilation" do + test "decompiles === correctly" do + ast = {:comparison, :strict_eq, {:identifier, "x"}, {:literal, 42}} + result = Predicator.decompile(ast) + + assert result == "x === 42" + end + + test "decompiles !== correctly" do + ast = {:comparison, :strict_ne, {:identifier, "name"}, {:string_literal, "test", :double}} + result = Predicator.decompile(ast) + + assert result == "name !== \"test\"" + end + + test "preserves operator distinction in round-trip" do + expressions = [ + "x = y", + "x == y", + "x === y", + "x != y", + "x !== y" + ] + + for expr <- expressions do + {:ok, ast} = Predicator.parse(expr) + decompiled = Predicator.decompile(ast) + + # The original operator should be preserved + assert decompiled == expr + end + end + + test "formats complex expressions correctly" do + {:ok, ast} = Predicator.parse("(a === 1) AND (b !== 'test')") + result = Predicator.decompile(ast) + + assert result == "a === 1 AND b !== 'test'" + end + end + + describe "error handling" do + test "provides meaningful error messages for invalid syntax" do + # Test parsing error includes operator info + assert {:error, _message, _line, _col} = Predicator.parse("x === ===") + end + + test "handles undefined values consistently" do + # Both strict and loose should handle :undefined the same way + assert {:ok, false} = Predicator.evaluate("undefined_var === 5", %{}) + assert {:ok, true} = Predicator.evaluate("undefined_var !== 5", %{}) + end + end +end From cc1ef5585d6ad1b7eea131958264f15493873def Mon Sep 17 00:00:00 2001 From: JohnnyT Date: Sun, 31 Aug 2025 05:37:05 -0600 Subject: [PATCH 3/4] Adds more git commit info to CLAUDE --- CLAUDE.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 5fa6a44..e2eae1a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -417,4 +417,8 @@ test/predicator/ - All dependencies in development/test only - No runtime dependencies for core functionality -- When creating git commit messages, be concise but informative, and highlight the functional changes. No need to mention code quality improvements as they are expected (unless the functional change is about code quality improvements) +- When creating git commit messages: + - be concise but informative, and highlight the functional changes + - no need to mention code quality improvements as they are expected (unless the functional change is about code quality improvements) + - commit titles should be less than 50 characters and be in the simple present tense (active voice) + - commit descriptions should wrap at about 72 characters and also be in the simple present tense (active voice) From b4b01b40777916acc786b8d4bd438ca8c0563e22 Mon Sep 17 00:00:00 2001 From: JohnnyT Date: Sun, 31 Aug 2025 05:41:00 -0600 Subject: [PATCH 4/4] Attempts to speed up dialyzer in pipelines --- .github/workflows/ci.yml | 65 ++++++++++++++++++++++++---------------- 1 file changed, 40 insertions(+), 25 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f3965af..067dc11 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -158,32 +158,47 @@ jobs: dialyzer: name: Static Type Analysis (Dialyzer) runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Set up Elixir - uses: erlef/setup-beam@v1 - with: - elixir-version: '1.17' - otp-version: '27' - - - name: Cache dependencies and PLTs - uses: actions/cache@v4 - with: - path: | - deps - _build - priv/plts - key: deps-plt-${{ runner.os }}-27-1.17-${{ hashFiles('**/mix.lock') }} - restore-keys: | - deps-plt-${{ runner.os }}-27-1.17- + needs: compile - - name: Install dependencies - run: mix deps.get - - - name: Run Dialyzer - run: mix dialyzer + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Elixir + uses: erlef/setup-beam@v1 + with: + elixir-version: '1.18.3' + otp-version: '27.3' + + - name: Cache deps + uses: actions/cache@v4 + with: + path: | + deps + _build + key: deps-${{ runner.os }}-27.3-1.18.3-${{ hashFiles('**/mix.lock') }} + restore-keys: | + deps-${{ runner.os }}-27.3-1.18.3- + + - name: Cache Dialyzer PLTs + uses: actions/cache@v4 + with: + path: priv/plts + key: dialyzer-${{ runner.os }}-27.3-1.18.3-${{ hashFiles('**/mix.lock') }} + restore-keys: | + dialyzer-${{ runner.os }}-27.3-1.18.3- + + - name: Install dependencies + run: mix deps.get + + - name: Compile dependencies + run: mix deps.compile + + - name: Compile project + run: mix compile + + - name: Run Dialyzer + run: mix dialyzer quality: name: Quality Gate