From bd843d2219f366d1e96505bcee568dcd30953011 Mon Sep 17 00:00:00 2001 From: Brandon Lucas Date: Wed, 18 Feb 2026 20:09:46 -0500 Subject: [PATCH] fix: record type aliases now work for unification and field access MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expand type aliases via unify_with_env() everywhere in the type checker, not just in a few places. This fixes named record types like `type Vec2 = { x: Float, y: Float }` — they now properly unify with anonymous records and support field access (v.x, v.y). Also adds scripts/validate.sh for automated full-suite regression testing (Rust tests + all 5 package test suites + type checking). Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 31 +++++++++++++++---- scripts/release.sh | 4 +++ scripts/validate.sh | 73 +++++++++++++++++++++++++++++++++++++++++++++ src/typechecker.rs | 49 +++++++++++++++--------------- 4 files changed, 127 insertions(+), 30 deletions(-) create mode 100755 scripts/validate.sh diff --git a/CLAUDE.md b/CLAUDE.md index 2ee2c35..0196f0a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -42,15 +42,34 @@ When making changes: 7. **Fix language limitations**: If you encounter parser/type system limitations, fix them (without regressions on guarantees or speed) 8. **Git commits**: Always use `--no-gpg-sign` flag -### Post-work checklist (run after each major piece of work) +### Post-work checklist (run after each committable change) + +**MANDATORY: Run the full validation script after every committable change:** +```bash +./scripts/validate.sh +``` + +This script runs ALL of the following checks and will fail if any regress: +1. `cargo check` — no Rust compilation errors +2. `cargo test` — all Rust tests pass (currently 387) +3. `cargo build --release` — release binary builds +4. `lux test` on every package (path, frontmatter, xml, rss, markdown) — all 286 package tests pass +5. `lux check` on every package — type checking + lint passes + +If `validate.sh` is not available or you need to run manually: ```bash nix develop --command cargo check # No Rust errors -nix develop --command cargo test # All tests pass (currently 381) -./target/release/lux check # Type check + lint all .lux files -./target/release/lux fmt # Format all .lux files -./target/release/lux lint # Standalone lint pass +nix develop --command cargo test # All Rust tests pass +nix develop --command cargo build --release # Build release binary +cd ../packages/path && ../../lang/target/release/lux test # Package tests +cd ../packages/frontmatter && ../../lang/target/release/lux test +cd ../packages/xml && ../../lang/target/release/lux test +cd ../packages/rss && ../../lang/target/release/lux test +cd ../packages/markdown && ../../lang/target/release/lux test ``` +**Do NOT commit if any check fails.** Fix the issue first. + ### Commit after every piece of work **After completing each logical unit of work, commit immediately.** Do not let changes accumulate uncommitted across multiple features. Each commit should be a single logical change (one feature, one bugfix, etc.). Use `--no-gpg-sign` flag for all commits. @@ -109,7 +128,7 @@ When working on any major task that involves writing Lux code, **document every ## Code Quality - Fix all compiler warnings before committing -- Ensure all tests pass (currently 381 tests) +- Ensure all tests pass (currently 387 tests) - Add new tests when adding features - Keep examples and documentation in sync diff --git a/scripts/release.sh b/scripts/release.sh index f43fa59..fbe2db6 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -11,6 +11,10 @@ set -euo pipefail # GITEA_TOKEN - API token for git.qrty.ink (prompted if not set) # GITEA_URL - Gitea instance URL (default: https://git.qrty.ink) +# cd to repo root (directory containing this script's parent) +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +cd "$SCRIPT_DIR/.." + GITEA_URL="${GITEA_URL:-https://git.qrty.ink}" REPO_OWNER="blu" REPO_NAME="lux" diff --git a/scripts/validate.sh b/scripts/validate.sh new file mode 100755 index 0000000..d0fe381 --- /dev/null +++ b/scripts/validate.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Lux Full Validation Script +# Runs all checks: Rust tests, package tests, type checking, formatting, linting. +# Run after every committable change to ensure no regressions. + +# cd to repo root (directory containing this script's parent) +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +cd "$SCRIPT_DIR/.." + +LUX="$(pwd)/target/release/lux" +PACKAGES_DIR="$(pwd)/../packages" +RED='\033[0;31m' +GREEN='\033[0;32m' +CYAN='\033[0;36m' +BOLD='\033[1m' +NC='\033[0m' + +FAILED=0 +TOTAL=0 + +step() { + TOTAL=$((TOTAL + 1)) + printf "${CYAN}[%d]${NC} %s... " "$TOTAL" "$1" +} + +ok() { printf "${GREEN}ok${NC} %s\n" "${1:-}"; } +fail() { printf "${RED}FAIL${NC} %s\n" "${1:-}"; FAILED=$((FAILED + 1)); } + +# --- Rust checks --- +step "cargo check" +if nix develop --command cargo check 2>&1 | grep -q "Finished"; then ok; else fail; fi + +step "cargo test" +OUTPUT=$(nix develop --command cargo test 2>&1 || true) +RESULT=$(echo "$OUTPUT" | grep "test result:" || echo "no result") +if echo "$RESULT" | grep -q "0 failed"; then ok "$RESULT"; else fail "$RESULT"; fi + +# --- Build release binary --- +step "cargo build --release" +if nix develop --command cargo build --release 2>&1 | grep -q "Finished"; then ok; else fail; fi + +# --- Package tests --- +for pkg in path frontmatter xml rss markdown; do + PKG_DIR="$PACKAGES_DIR/$pkg" + if [ -d "$PKG_DIR" ]; then + step "lux test ($pkg)" + OUTPUT=$(cd "$PKG_DIR" && "$LUX" test 2>&1 || true) + RESULT=$(echo "$OUTPUT" | grep "passed" | tail -1 || echo "no result") + if echo "$RESULT" | grep -q "passed"; then ok "$RESULT"; else fail "$RESULT"; fi + fi +done + +# --- Lux check on packages --- +for pkg in path frontmatter xml rss markdown; do + PKG_DIR="$PACKAGES_DIR/$pkg" + if [ -d "$PKG_DIR" ]; then + step "lux check ($pkg)" + OUTPUT=$(cd "$PKG_DIR" && "$LUX" check 2>&1 || true) + RESULT=$(echo "$OUTPUT" | grep "passed" | tail -1 || echo "no result") + if echo "$RESULT" | grep -q "passed"; then ok; else fail "$RESULT"; fi + fi +done + +# --- Summary --- +printf "\n${BOLD}═══ Validation Summary ═══${NC}\n" +if [ $FAILED -eq 0 ]; then + printf "${GREEN}All %d checks passed.${NC}\n" "$TOTAL" +else + printf "${RED}%d/%d checks failed.${NC}\n" "$FAILED" "$TOTAL" + exit 1 +fi diff --git a/src/typechecker.rs b/src/typechecker.rs index cd3d236..fdffd98 100644 --- a/src/typechecker.rs +++ b/src/typechecker.rs @@ -1536,7 +1536,7 @@ impl TypeChecker { // Use the declared type if present, otherwise use inferred let final_type = if let Some(ref type_expr) = let_decl.typ { let declared = self.resolve_type(type_expr); - if let Err(e) = unify(&inferred, &declared) { + if let Err(e) = unify_with_env(&inferred, &declared, &self.env) { self.errors.push(TypeError { message: format!( "Variable '{}' has type {}, but declared type is {}: {}", @@ -1783,7 +1783,7 @@ impl TypeChecker { match op { BinaryOp::Add => { // Add supports both numeric types and string concatenation - if let Err(e) = unify(&left_type, &right_type) { + if let Err(e) = unify_with_env(&left_type, &right_type, &self.env) { self.errors.push(TypeError { message: format!("Operands of '{}' must have same type: {}", op, e), span, @@ -1806,7 +1806,7 @@ impl TypeChecker { BinaryOp::Sub | BinaryOp::Mul | BinaryOp::Div | BinaryOp::Mod => { // Arithmetic: both operands must be same numeric type - if let Err(e) = unify(&left_type, &right_type) { + if let Err(e) = unify_with_env(&left_type, &right_type, &self.env) { self.errors.push(TypeError { message: format!("Operands of '{}' must have same type: {}", op, e), span, @@ -1830,7 +1830,7 @@ impl TypeChecker { BinaryOp::Eq | BinaryOp::Ne => { // Equality: operands must have same type - if let Err(e) = unify(&left_type, &right_type) { + if let Err(e) = unify_with_env(&left_type, &right_type, &self.env) { self.errors.push(TypeError { message: format!("Operands of '{}' must have same type: {}", op, e), span, @@ -1841,7 +1841,7 @@ impl TypeChecker { BinaryOp::Lt | BinaryOp::Le | BinaryOp::Gt | BinaryOp::Ge => { // Comparison: operands must be same orderable type - if let Err(e) = unify(&left_type, &right_type) { + if let Err(e) = unify_with_env(&left_type, &right_type, &self.env) { self.errors.push(TypeError { message: format!("Operands of '{}' must have same type: {}", op, e), span, @@ -1852,13 +1852,13 @@ impl TypeChecker { BinaryOp::And | BinaryOp::Or => { // Logical: both must be Bool - if let Err(e) = unify(&left_type, &Type::Bool) { + if let Err(e) = unify_with_env(&left_type, &Type::Bool, &self.env) { self.errors.push(TypeError { message: format!("Left operand of '{}' must be Bool: {}", op, e), span: left.span(), }); } - if let Err(e) = unify(&right_type, &Type::Bool) { + if let Err(e) = unify_with_env(&right_type, &Type::Bool, &self.env) { self.errors.push(TypeError { message: format!("Right operand of '{}' must be Bool: {}", op, e), span: right.span(), @@ -1872,7 +1872,7 @@ impl TypeChecker { // right must be a function that accepts left's type let result_type = Type::var(); let expected_fn = Type::function(vec![left_type.clone()], result_type.clone()); - if let Err(e) = unify(&right_type, &expected_fn) { + if let Err(e) = unify_with_env(&right_type, &expected_fn, &self.env) { self.errors.push(TypeError { message: format!( "Pipe target must be a function accepting {}: {}", @@ -1904,7 +1904,7 @@ impl TypeChecker { } }, UnaryOp::Not => { - if let Err(e) = unify(&operand_type, &Type::Bool) { + if let Err(e) = unify_with_env(&operand_type, &Type::Bool, &self.env) { self.errors.push(TypeError { message: format!("Operator '!' requires Bool operand: {}", e), span, @@ -1955,7 +1955,7 @@ impl TypeChecker { self.current_effects.clone(), ); - match unify(&func_type, &expected_fn) { + match unify_with_env(&func_type, &expected_fn, &self.env) { Ok(subst) => result_type.apply(&subst), Err(e) => { // Provide more detailed error message based on the type of mismatch @@ -2032,7 +2032,7 @@ impl TypeChecker { let result_type = Type::var(); let expected_fn = Type::function(arg_types, result_type.clone()); - if let Err(e) = unify(field_type, &expected_fn) { + if let Err(e) = unify_with_env(field_type, &expected_fn, &self.env) { self.errors.push(TypeError { message: format!( "Type mismatch in {}.{} call: {}", @@ -2104,7 +2104,7 @@ impl TypeChecker { for (i, (arg_type, (_, param_type))) in arg_types.iter().zip(op.params.iter()).enumerate() { - if let Err(e) = unify(arg_type, param_type) { + if let Err(e) = unify_with_env(arg_type, param_type, &self.env) { self.errors.push(TypeError { message: format!( "Argument {} of '{}.{}' has type {}, expected {}: {}", @@ -2137,6 +2137,7 @@ impl TypeChecker { fn infer_field(&mut self, object: &Expr, field: &Ident, span: Span) -> Type { let object_type = self.infer_expr(object); + let object_type = self.env.expand_type_alias(&object_type); match &object_type { Type::Record(fields) => match fields.iter().find(|(n, _)| n == &field.name) { @@ -2217,7 +2218,7 @@ impl TypeChecker { // Check return type if specified let ret_type = if let Some(rt) = return_type { let declared = self.resolve_type(rt); - if let Err(e) = unify(&body_type, &declared) { + if let Err(e) = unify_with_env(&body_type, &declared, &self.env) { self.errors.push(TypeError { message: format!( "Lambda body type {} doesn't match declared {}: {}", @@ -2283,7 +2284,7 @@ impl TypeChecker { span: Span, ) -> Type { let cond_type = self.infer_expr(condition); - if let Err(e) = unify(&cond_type, &Type::Bool) { + if let Err(e) = unify_with_env(&cond_type, &Type::Bool, &self.env) { self.errors.push(TypeError { message: format!("If condition must be Bool, got {}: {}", cond_type, e), span: condition.span(), @@ -2293,7 +2294,7 @@ impl TypeChecker { let then_type = self.infer_expr(then_branch); let else_type = self.infer_expr(else_branch); - match unify(&then_type, &else_type) { + match unify_with_env(&then_type, &else_type, &self.env) { Ok(subst) => then_type.apply(&subst), Err(e) => { self.errors.push(TypeError { @@ -2334,7 +2335,7 @@ impl TypeChecker { // Check guard if present if let Some(ref guard) = arm.guard { let guard_type = self.infer_expr(guard); - if let Err(e) = unify(&guard_type, &Type::Bool) { + if let Err(e) = unify_with_env(&guard_type, &Type::Bool, &self.env) { self.errors.push(TypeError { message: format!("Match guard must be Bool: {}", e), span: guard.span(), @@ -2350,7 +2351,7 @@ impl TypeChecker { match &result_type { None => result_type = Some(body_type), Some(prev) => { - if let Err(e) = unify(prev, &body_type) { + if let Err(e) = unify_with_env(prev, &body_type, &self.env) { self.errors.push(TypeError { message: format!( "Match arm has incompatible type: expected {}, got {}: {}", @@ -2400,7 +2401,7 @@ impl TypeChecker { Pattern::Literal(lit) => { let lit_type = self.infer_literal(lit); - if let Err(e) = unify(&lit_type, expected) { + if let Err(e) = unify_with_env(&lit_type, expected, &self.env) { self.errors.push(TypeError { message: format!("Pattern literal type mismatch: {}", e), span: lit.span, @@ -2414,7 +2415,7 @@ impl TypeChecker { // For now, handle Option specially match name.name.as_str() { "None" => { - if let Err(e) = unify(expected, &Type::Option(Box::new(Type::var()))) { + if let Err(e) = unify_with_env(expected, &Type::Option(Box::new(Type::var())), &self.env) { self.errors.push(TypeError { message: format!( "None pattern doesn't match type {}: {}", @@ -2427,7 +2428,7 @@ impl TypeChecker { } "Some" => { let inner_type = Type::var(); - if let Err(e) = unify(expected, &Type::Option(Box::new(inner_type.clone()))) + if let Err(e) = unify_with_env(expected, &Type::Option(Box::new(inner_type.clone())), &self.env) { self.errors.push(TypeError { message: format!( @@ -2456,7 +2457,7 @@ impl TypeChecker { Pattern::Tuple { elements, span } => { let element_types: Vec = elements.iter().map(|_| Type::var()).collect(); - if let Err(e) = unify(expected, &Type::Tuple(element_types.clone())) { + if let Err(e) = unify_with_env(expected, &Type::Tuple(element_types.clone()), &self.env) { self.errors.push(TypeError { message: format!("Tuple pattern doesn't match type {}: {}", expected, e), span: *span, @@ -2506,7 +2507,7 @@ impl TypeChecker { if let Some(type_expr) = typ { let declared = self.resolve_type(type_expr); - if let Err(e) = unify(&value_type, &declared) { + if let Err(e) = unify_with_env(&value_type, &declared, &self.env) { self.errors.push(TypeError { message: format!( "Variable '{}' has type {}, but declared type is {}: {}", @@ -2549,7 +2550,7 @@ impl TypeChecker { let first_type = self.infer_expr(&elements[0]); for elem in &elements[1..] { let elem_type = self.infer_expr(elem); - if let Err(e) = unify(&first_type, &elem_type) { + if let Err(e) = unify_with_env(&first_type, &elem_type, &self.env) { self.errors.push(TypeError { message: format!("List elements must have same type: {}", e), span, @@ -2855,7 +2856,7 @@ impl TypeChecker { // Check return type matches if specified if let Some(ref return_type_expr) = impl_method.return_type { let return_type = self.resolve_type(return_type_expr); - if let Err(e) = unify(&body_type, &return_type) { + if let Err(e) = unify_with_env(&body_type, &return_type, &self.env) { self.errors.push(TypeError { message: format!( "Method '{}' body has type {}, but declared return type is {}: {}",