all repos

clerk @ a84ac41971367ffe99899b05b6562f66a5adf7fc

missing tooling for ledger/hledger
7 files changed, 94 insertions(+), 13 deletions(-)
linter: add undeclared-commodity
Author: Oleksandr Smirnov olexsmir@gmail.com
Committed at: 2026-06-27 12:41:04 +0300
Authored at: 2026-06-26 17:40:03 +0300
Change ID: zpkkkzprtusnqnmlyrwkvomqvpslyuxy
Parent: 5d20567
M internal/linter/linter_test.go
···
        21
        21
         	"orderdate":                {&OrderDate{}},

      
        22
        22
         	"duplicated-account":       {&DuplicatedAccount{}},

      
        23
        23
         	"duplicated-commodity":     {&DuplicatedCommodity{}},

      
        
        24
        +	"undeclared-commodity":     {&UndeclaredCommodity{}},

      
        24
        25
         	"undeclared-account":       {&UndeclaredAccount{}},

      
        25
        26
         }

      
        26
        27
         

      
A internal/linter/rule_undeclared_commodity.go
···
        
        1
        +package linter

      
        
        2
        +

      
        
        3
        +import (

      
        
        4
        +	"fmt"

      
        
        5
        +

      
        
        6
        +	"olexsmir.xyz/clerk/journal/semantic"

      
        
        7
        +)

      
        
        8
        +

      
        
        9
        +// UndeclaredCommodity flags amounts that reference a commodity not declared via `commodity` directive.

      
        
        10
        +type UndeclaredCommodity struct{}

      
        
        11
        +

      
        
        12
        +func (UndeclaredCommodity) ID() RuleID         { return "undeclared-commodity" }

      
        
        13
        +func (UndeclaredCommodity) Severity() Severity { return SeverityWarning }

      
        
        14
        +func (r *UndeclaredCommodity) CheckJournal(ctx *semantic.Context) []Find {

      
        
        15
        +	var finds []Find

      
        
        16
        +	for name, info := range ctx.Commodities {

      
        
        17
        +		if len(info.Directives) > 0 {

      
        
        18
        +			continue

      
        
        19
        +		}

      
        
        20
        +		for _, usage := range info.Usages {

      
        
        21
        +			finds = append(finds, Find{

      
        
        22
        +				Code:     r.ID(),

      
        
        23
        +				Severity: r.Severity(),

      
        
        24
        +				Span:     usage.Amount.Span,

      
        
        25
        +				Message:  fmt.Sprintf("undeclared commodity: %s", name),

      
        
        26
        +			})

      
        
        27
        +		}

      
        
        28
        +	}

      
        
        29
        +	return finds

      
        
        30
        +}

      
M internal/linter/rules.go
···
        38
        38
         	&OrderDate{},

      
        39
        39
         	&DuplicatedAccount{},

      
        40
        40
         	&DuplicatedCommodity{},

      
        
        41
        +	&UndeclaredCommodity{},

      
        41
        42
         	&UndeclaredAccount{},

      
        42
        43
         }

      
M internal/linter/testdata/correct.input
···
        1
        1
         account expenses:food

      
        2
        2
         account assets:cash

      
        3
        3
         

      
        
        4
        +commodity GBP

      
        
        5
        +commodity 1,000.00 UAH

      
        
        6
        +commodity 1000.00 USD

      
        
        7
        +

      
        4
        8
         2024-05-01 * "groceries"

      
        5
        9
             expenses:food   50.00 USD

      
        6
        10
             assets:cash

      
A internal/linter/testdata/undeclared-commodity.golden
···
        
        1
        +undeclared-commodity.journal:13:22: undeclared-commodity: undeclared commodity: GBP

      
A internal/linter/testdata/undeclared-commodity.input
···
        
        1
        +commodity USD

      
        
        2
        +commodity EUR

      
        
        3
        +

      
        
        4
        +2026-01-15 * "Purchase"

      
        
        5
        +  Expenses:Food      50.00 USD

      
        
        6
        +  Assets:Cash

      
        
        7
        +

      
        
        8
        +2026-01-16 * "Refund"

      
        
        9
        +  Assets:Cash        20.00 EUR

      
        
        10
        +  Expenses:Food

      
        
        11
        +

      
        
        12
        +2026-01-17 * "Transfer"

      
        
        13
        +  Assets:Cash        100 GBP

      
        
        14
        +  Assets:Bank

      
M lint.go
···
        47
        47
         			continue

      
        48
        48
         		}

      
        49
        49
         		if info.IsDir() {

      
        50
        
        -			filepath.Walk(path, func(fpath string, finfo os.FileInfo, err error) error {

      
        51
        
        -				if err != nil || finfo.IsDir() || !journal.IsJournalFile(fpath) {

      
        52
        
        -					return nil

      
        53
        
        -				}

      
        54
        
        -				finds, fatal := lintFile(l, fpath)

      
        55
        
        -				reporter.Collect(finds)

      
        56
        
        -				if fatal {

      
        57
        
        -					exitCode = 1

      
        58
        
        -				}

      
        59
        
        -				return nil

      
        60
        
        -			})

      
        
        50
        +			exitCode = lintDir(l, reporter, path)

      
        61
        51
         			continue

      
        62
        52
         		}

      
        63
        53
         		finds, fatal := lintFile(l, path)

      ···
        70
        60
         	os.Exit(exitCode)

      
        71
        61
         }

      
        72
        62
         

      
        
        63
        +func lintDir(l *linter.Linter, reporter *linter.Reporter, dir string) int {

      
        
        64
        +	ldr := journal.NewLoader()

      
        
        65
        +	var paths []string

      
        
        66
        +	filepath.Walk(dir, func(fpath string, finfo os.FileInfo, err error) error {

      
        
        67
        +		if err != nil || finfo.IsDir() || !journal.IsJournalFile(fpath) {

      
        
        68
        +			return nil

      
        
        69
        +		}

      
        
        70
        +		paths = append(paths, fpath)

      
        
        71
        +		return nil

      
        
        72
        +	})

      
        
        73
        +	if len(paths) == 0 {

      
        
        74
        +		return 0

      
        
        75
        +	}

      
        
        76
        +	var lastErr error

      
        
        77
        +	for _, fpath := range paths {

      
        
        78
        +		src, err := os.ReadFile(fpath)

      
        
        79
        +		if err != nil {

      
        
        80
        +			fmt.Fprintf(os.Stderr, "error: %s: %v\n", fpath, err)

      
        
        81
        +			lastErr = err

      
        
        82
        +			continue

      
        
        83
        +		}

      
        
        84
        +		if _, err := ldr.LoadBytes(fpath, src); err != nil {

      
        
        85
        +			fmt.Fprintf(os.Stderr, "error: %s: %v\n", fpath, err)

      
        
        86
        +			lastErr = err

      
        
        87
        +		}

      
        
        88
        +	}

      
        
        89
        +	ctx := semantic.Build(ldr.Ordered())

      
        
        90
        +	finds := l.Run(ctx)

      
        
        91
        +	reporter.Collect(finds)

      
        
        92
        +	if lastErr != nil {

      
        
        93
        +		return 1

      
        
        94
        +	}

      
        
        95
        +	if hasFatal(finds) {

      
        
        96
        +		return 1

      
        
        97
        +	}

      
        
        98
        +	return 0

      
        
        99
        +}

      
        
        100
        +

      
        73
        101
         func lintFile(l *linter.Linter, path string) ([]linter.Find, bool) {

      
        74
        102
         	src, err := os.ReadFile(path)

      
        75
        103
         	if err != nil {

      
        76
        104
         		fmt.Fprintf(os.Stderr, "error: %s: %v\n", path, err)

      
        77
        105
         		return nil, true

      
        78
        106
         	}

      
        79
        
        -	pf, err := journal.NewLoader().LoadBytes(path, src)

      
        
        107
        +	ldr := journal.NewLoader()

      
        
        108
        +	pf, err := ldr.LoadBytes(path, src)

      
        80
        109
         	if err != nil {

      
        81
        110
         		fmt.Fprintf(os.Stderr, "error: %s: %v\n", path, err)

      
        82
        111
         		return nil, true

      ···
        93
        122
         		fmt.Fprintf(os.Stderr, "error reading stdin: %v\n", err)

      
        94
        123
         		os.Exit(1)

      
        95
        124
         	}

      
        96
        
        -	pf, err := journal.NewLoader().LoadBytes("stdin", src)

      
        
        125
        +	ldr := journal.NewLoader()

      
        
        126
        +	_, err = ldr.LoadBytes("stdin", src)

      
        97
        127
         	if err != nil {

      
        98
        128
         		fmt.Fprintf(os.Stderr, "error: %v\n", err)

      
        99
        129
         		os.Exit(1)