Skip to content
Draft
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
6 changes: 6 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
33 changes: 0 additions & 33 deletions .github/workflows/build.yml

This file was deleted.

47 changes: 47 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
name: CI

on:
pull_request:
workflow_dispatch:
push:
branches:
- main
tags:
- v?[0-9]+.[0-9]+.[0-9]+*

concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true

jobs:
check:
name: Nix flake check (${{ matrix.os }})
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, macos-latest]
permissions:
id-token: write
contents: read
steps:
- uses: actions/checkout@v5
- uses: DeterminateSystems/determinate-nix-action@main
- run: nix flake check

# Publish to FlakeHub after CI passes
publish:
needs: check
# Only publish on main branch or version tags, not on PRs
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v'))
runs-on: ubuntu-latest
permissions:
id-token: write
contents: read
steps:
- uses: actions/checkout@v5
- uses: DeterminateSystems/determinate-nix-action@main
- uses: DeterminateSystems/flakehub-push@main
with:
visibility: private
rolling: ${{ !startsWith(github.ref, 'refs/tags/') }}
tag: ${{ startsWith(github.ref, 'refs/tags/') && github.ref_name || '' }}
41 changes: 41 additions & 0 deletions .github/workflows/update-flake-lock.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
name: Update flake.lock

on:
workflow_dispatch: # Manual trigger
schedule:
- cron: "0 0 * * 0" # Weekly on Sunday at midnight UTC

jobs:
update:
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
id-token: write
steps:
- uses: actions/checkout@v5

- name: Install Nix with Determinate
uses: DeterminateSystems/determinate-nix-action@v3.11.3

- name: Update flake.lock
uses: DeterminateSystems/update-flake-lock@v27
with:
pr-title: "chore: Update flake.lock"
pr-labels: |
dependencies
automated
pr-body: |
## Automated flake.lock Update

This PR updates the flake inputs to their latest versions.

### What's Updated
- All flake inputs (nixpkgs, flake-utils, etc.)

### Testing
- [ ] CI passes with updated dependencies
- [ ] All builds complete successfully
- [ ] Tests pass

🤖 Generated by update-flake-lock action
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,6 @@ output/
.psci_modules/
.whine/
.spec-results
.DS_Store
result
result/
21 changes: 20 additions & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,22 @@
{
"whine.trace.server": "verbose"
"whine.trace.server": "verbose",
"workbench.colorCustomizations": {
"activityBar.activeBackground": "#fed19e",
"activityBar.background": "#fed19e",
"activityBar.foreground": "#15202b",
"activityBar.inactiveForeground": "#15202b99",
"activityBarBadge.background": "#029e55",
"activityBarBadge.foreground": "#e7e7e7",
"commandCenter.border": "#15202b99",
"sash.hoverBorder": "#fed19e",
"statusBar.background": "#feb96b",
"statusBar.foreground": "#15202b",
"statusBarItem.hoverBackground": "#fea138",
"statusBarItem.remoteBackground": "#feb96b",
"statusBarItem.remoteForeground": "#15202b",
"titleBar.activeBackground": "#feb96b",
"titleBar.activeForeground": "#15202b",
"titleBar.inactiveBackground": "#feb96b99",
"titleBar.inactiveForeground": "#15202b99"
}
}
57 changes: 57 additions & 0 deletions PLAN.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# Issue #2: EtaReduce Rule

**Rule name**: `EtaReduce`
**Issue**: https://github.com/collegevine/purescript-whine/issues/2
**Summary**: Detect function bindings where the last parameter is redundantly applied and could be eta-reduced to point-free style.

## Task 1: Write BDD Integration Tests (Red Phase)

- [ ] Create integration test fixtures that run the actual `whine` CLI against PureScript source files
- [ ] Verify tests fail because the rule doesn't exist yet

### Scenarios that SHOULD be flagged

| # | Input | Expected suggestion |
|---|-------|-------------------|
| 1 | `f a = g a` | Could be `f = g` |
| 2 | `f a = h $ g a` | Could be point-free |
| 3 | `f a = j $ h $ g a` | Could be point-free |
| 4 | `f a b = g a b` | Last param `b` is redundant |

### Scenarios that should NOT be flagged

| # | Input | Reason |
|---|-------|--------|
| 5 | `f = g` | Already point-free |
| 6 | `f a = g b` | Different variable |
| 7 | `f a = g (h a)` | `a` is nested, not direct last arg |
| 8 | Guarded: `f a \| c = g a \| otherwise = h a` | Can't eta-reduce guarded bindings |
| 9 | Type class instance method: `instance Foo Bar where foo a = bar a` | Excluded per issue discussion |
| 10 | `f a = a + 1` | `a` is not the last argument of a function application |
| 11 | `f _ = g unit` | Wildcard binder, not a named variable match |

## Task 2: Implement the `EtaReduce` Rule

- [ ] Create `src/Whine/Core/EtaReduce.purs`
- [ ] Use `onDecl` handler matching `DeclValue` bindings
- [ ] Check that the last binder is a simple `BinderVar`
- [ ] Check that the binding is `Unconditional` (no guards)
- [ ] Check that the body expression's last applied argument is `ExprIdent` matching the last binder's name
- [ ] Handle two forms:
- **Direct application** (`ExprApp`): `g a` where `a` matches last binder
- **Dollar-chain** (`ExprOp` with `$`): `h $ g a` where rightmost expression ends with last binder
- [ ] Skip type class instance methods
- [ ] No configuration needed initially (use `CJ.json` / raw JSON codec)

## Task 3: Register the Rule & Add Unit Tests

- [ ] Register `EtaReduce` in `src/Whine/Core/WhineRules.purs`
- [ ] Add unit tests in `test/Core/EtaReduce.purs` using `runRule`/`runRule'`
- [ ] Register test spec in `test/Core/WhineRules.purs`
- [ ] Add rule to `whine.yaml` config

## Task 4: Rebuild Bundle & Verify

- [ ] Rebuild the whine-core bundle (`dist/bundle.sh`)
- [ ] Run `nix flake check` to verify all tests pass (unit + integration)
- [ ] Verify integration tests now pass (green phase)
14 changes: 14 additions & 0 deletions aykua/spago.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package:
name: aykua
dependencies:
- whine-core
- prelude
- language-cst-parser
- codec-json
- maybe
- either
- foldable-traversable
- tuples
- arrays
- ordered-collections
- strings
155 changes: 155 additions & 0 deletions aykua/src/Aykua/WhineRules.purs
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
-- | Aykua company-wide whine rules
-- | These rules apply to all Aykua projects
module Aykua.WhineRules where

import Whine.Prelude

import Data.Array.NonEmpty as Data.Array.NonEmpty
import Data.Codec.JSON as CJ
import Data.Foldable (all) as Data.Foldable
import PureScript.CST.Types (Import(..), ImportDecl(..), Module(..), ModuleHeader(..), ModuleName(..), Name(..), Separated(..), Wrapped(..))
import Whine.Core.UndesirableFunctions as UF
import Whine.Types (Handle(..), Rule, RuleFactories, emptyRule, reportViolation, ruleFactory)

-- | Export all rules for this package
rules :: RuleFactories
rules =
-- Import standard rules
[ ruleFactory "QualifiedImportsOnly" CJ.json qualifiedImportsOnlyRule
, ruleFactory "MatchingAliases" CJ.json matchingAliasesRule
, ruleFactory "NoDuplicateImports" CJ.json noDuplicateImportsRule
-- Granular UndesirableFunctions variants - each can be disabled independently
-- Use: -- #disable UndesirableConsole (instead of disabling all UndesirableFunctions)
, ruleFactory "UndesirableConsole" UF.codec UF.rule -- Effect.Console.* functions
, ruleFactory "UndesirableLiftEffect" UF.codec UF.rule -- liftEffect/liftAff
, ruleFactory "UndesirableUnsafe" UF.codec UF.rule -- Unsafe functions (never allow)
]

-- | Rule: All imports must be qualified (except Prelude and symbol-only imports)
-- | Enforces: import Data.Maybe as Data.Maybe
-- | Rejects: import Data.Maybe
-- | Allows: import Prelude, import Type.Row (type (+)), import Data.Argonaut.Decode.Class (class DecodeJson)
qualifiedImportsOnlyRule :: JSON -> Rule
qualifiedImportsOnlyRule _ = emptyRule { onModuleImport = onImport }
where
onImport = Handle case _ of
ImportDecl { module: Name m, qualified, names } ->
case qualified, m.name, names of
-- Allow: import Prelude (no qualification needed)
Nothing, ModuleName "Prelude", _ -> pure unit

-- Allow: symbol-only imports like import Type.Row (type (+)), import Data.Argonaut.Decode.Class (class DecodeJson)
Nothing, _, Just (_ /\ imports) | isSymbolOnlyImport imports -> pure unit

-- Reject: mixed imports (regular items + operators/classes)
Nothing, _, Just (_ /\ imports) | isMixedImport imports ->
reportViolation
{ source: Just m.token.range
, message: "Mixed import contains both regular items and operators/classes. Split into two imports:\n"
<> " (1) import "
<> unwrap m.name
<> " as "
<> unwrap m.name
<> " -- for types/functions\n"
<> " (2) import "
<> unwrap m.name
<> " (<operators/classes>) -- for operators and type classes only"
}

-- Reject: bare imports without qualification
Nothing, _, _ ->
reportViolation
{ source: Just m.token.range
, message: "Import must be qualified with 'as " <> unwrap m.name <> "'. Use: import " <> unwrap m.name <> " as " <> unwrap m.name
}

-- Allow: qualified imports (checked by MatchingAliases rule)
Just _, _, _ -> pure unit

-- Check if import list contains only symbols (operators, type operators, and type classes)
isSymbolOnlyImport :: forall e. Wrapped (Separated (Import e)) -> Boolean
isSymbolOnlyImport (Wrapped { value: Separated { head, tail } }) =
isSymbol head && Data.Foldable.all (isSymbol <<< snd) tail

-- Check if import list is mixed (contains both operators and non-operators)
isMixedImport :: forall e. Wrapped (Separated (Import e)) -> Boolean
isMixedImport (Wrapped { value: Separated { head, tail } }) =
let
allItems = head : (snd <$> tail)
hasOperator = any isSymbol allItems
hasRegular = any (not <<< isSymbol) allItems
in
hasOperator && hasRegular

isSymbol :: forall e. Import e -> Boolean
isSymbol (ImportTypeOp _ _) = true
isSymbol (ImportOp _) = true
isSymbol (ImportClass _ _) = true -- Allow type class imports like (class DecodeJson)
isSymbol _ = false

-- | Rule: Qualified aliases must match the module name exactly
-- | Enforces: import Data.Maybe as Data.Maybe
-- | Rejects: import Data.Maybe as M, import Data.Maybe as Maybe
matchingAliasesRule :: JSON -> Rule
matchingAliasesRule _ = emptyRule { onModuleImport = onImport }
where
onImport = Handle case _ of
ImportDecl { module: Name m, qualified: Just (_ /\ Name alias) } ->
if m.name == alias.name then pure unit
else
reportViolation
{ source: Just alias.token.range
, message: "Import alias must match module name. Use: as " <> unwrap m.name <> " (not as " <> unwrap alias.name <> ")"
}

-- No qualified alias, rule doesn't apply (handled by QualifiedImportsOnly)
ImportDecl _ -> pure unit

-- | Rule: No duplicate imports of the same module
-- | Checks all imports in a module and reports duplicates
-- | Exception: Allows symbol-only imports (operators, type classes) to coexist with qualified imports
-- | Valid pattern: import Module as Module + import Module ((operator)) or import Module (class ClassName)
noDuplicateImportsRule :: JSON -> Rule
noDuplicateImportsRule _ = emptyRule { onModule = onModule }
where
onModule = Handle case _ of
Module { header: ModuleHeader { imports } } -> do
let
-- Group imports by module name
grouped = groupAllBy compareModuleName imports

-- Find groups with more than one import (length > 1)
duplicates = filter (\g -> Data.Array.NonEmpty.length g > 1) grouped

-- Report each duplicate group
for_ duplicates \group ->
let
{ head: ImportDecl { module: Name m }, tail } = Data.Array.NonEmpty.uncons group
-- Filter out symbol-only imports (operators/classes) from duplicates (they're allowed)
nonSymbolDuplicates = filter (not <<< isSymbolOnlyImportDecl) tail
in
for_ nonSymbolDuplicates \(ImportDecl { module: Name dup }) ->
reportViolation
{ source: Just dup.token.range
, message: "Duplicate import of module '" <> unwrap m.name <> "'. Remove duplicate imports."
}

compareModuleName :: forall e. ImportDecl e -> ImportDecl e -> Ordering
compareModuleName (ImportDecl { module: Name m1 }) (ImportDecl { module: Name m2 }) =
compare m1.name m2.name

-- Check if an import declaration is symbol-only (operators/classes)
isSymbolOnlyImportDecl :: forall e. ImportDecl e -> Boolean
isSymbolOnlyImportDecl (ImportDecl { names: Just (_ /\ imports) }) = isSymbolOnlyImport imports
isSymbolOnlyImportDecl _ = false

-- Check if import list contains only symbols (operators, type operators, and type classes)
isSymbolOnlyImport :: forall e. Wrapped (Separated (Import e)) -> Boolean
isSymbolOnlyImport (Wrapped { value: Separated { head, tail } }) =
isSymbol head && Data.Foldable.all (isSymbol <<< snd) tail

isSymbol :: forall e. Import e -> Boolean
isSymbol (ImportTypeOp _ _) = true
isSymbol (ImportOp _) = true
isSymbol (ImportClass _ _) = true -- Allow type class imports like (class DecodeJson)
isSymbol _ = false
Loading