all repos

clerk @ 5d20567

missing tooling for ledger/hledger
12 files changed, 100 insertions(+), 95 deletions(-)
linter: refactor to use semantic context
Author: Oleksandr Smirnov olexsmir@gmail.com
Committed at: 2026-06-27 12:41:04 +0300
Authored at: 2026-06-26 17:20:49 +0300
Change ID: qzpnrppomruxxqvmlmlvxrplxsutxprp
Parent: a69e534
M internal/linter/linter.go
···
        1
        1
         package linter

      
        2
        2
         

      
        3
        3
         import (

      
        4
        
        -	"olexsmir.xyz/clerk/journal/ast"

      
        5
        4
         	"olexsmir.xyz/clerk/journal/token"

      
        
        5
        +

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

      
        6
        7
         )

      
        7
        8
         

      
        8
        9
         // A Find represents a single lint finding.

      ···
        23
        24
         	return &Linter{rules: rules}

      
        24
        25
         }

      
        25
        26
         

      
        26
        
        -// Run runs all rules against the journal.

      
        27
        
        -func (l *Linter) Run(j *ast.Journal) []Find {

      
        
        27
        +// Run runs all rules against the semantic context.

      
        
        28
        +func (l *Linter) Run(ctx *semantic.Context) []Find {

      
        28
        29
         	var finds []Find

      
        29
        30
         

      
        30
        
        -	// single traversal

      
        31
        
        -	for _, entry := range j.Entries {

      
        32
        
        -		for _, rule := range l.rules {

      
        33
        
        -			if ec, ok := rule.(EntryChecker); ok {

      
        34
        
        -				finds = append(finds, ec.CheckEntry(entry)...)

      
        
        31
        +	// per-entry traversal

      
        
        32
        +	for _, pf := range ctx.Files {

      
        
        33
        +		for _, entry := range pf.Ast.Entries {

      
        
        34
        +			for _, rule := range l.rules {

      
        
        35
        +				if ec, ok := rule.(EntryChecker); ok {

      
        
        36
        +					finds = append(finds, ec.CheckEntry(entry)...)

      
        
        37
        +				}

      
        35
        38
         			}

      
        36
        39
         		}

      
        37
        40
         	}

      ···
        39
        42
         	// post-traversal

      
        40
        43
         	for _, rule := range l.rules {

      
        41
        44
         		if jc, ok := rule.(JournalChecker); ok {

      
        42
        
        -			finds = append(finds, jc.CheckJournal(j)...)

      
        
        45
        +			finds = append(finds, jc.CheckJournal(ctx)...)

      
        43
        46
         		}

      
        44
        47
         	}

      
        45
        48
         

      
M internal/linter/linter_test.go
···
        6
        6
         

      
        7
        7
         	"olexsmir.xyz/clerk/internal/testutil/golden"

      
        8
        8
         	"olexsmir.xyz/clerk/journal"

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

      
        9
        10
         )

      
        10
        11
         

      
        11
        12
         var tests = map[string][]Rule{

      ···
        32
        33
         				t.Fatalf("failed to load test journal: %v\n", err)

      
        33
        34
         			}

      
        34
        35
         

      
        35
        
        -			finds := NewLinter(trules).Run(pf.Ast)

      
        
        36
        +			ctx := semantic.Build([]*journal.ParsedFile{pf})

      
        
        37
        +			finds := NewLinter(trules).Run(ctx)

      
        36
        38
         

      
        37
        39
         			var b strings.Builder

      
        38
        40
         			Fprint(&b, PathBasename, finds)

      ···
        42
        44
         }

      
        43
        45
         

      
        44
        46
         func BenchmarkLinter(b *testing.B) {

      
        45
        
        -	pf, err := journal.NewLoader().Load(

      
        
        47
        +	ldr := journal.NewLoader()

      
        
        48
        +	_, err := ldr.Load(

      
        46
        49
         		"../../journal/testdata/journals/actual-1ktxns-100accts.journal",

      
        47
        50
         	)

      
        48
        51
         	if err != nil {

      
        49
        52
         		b.Fatalf("failed to load benchmark journal: %v", err)

      
        50
        53
         	}

      
        
        54
        +

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

      
        51
        56
         	l := NewLinter(Rules)

      
        52
        57
         

      
        53
        58
         	b.ResetTimer()

      
        54
        59
         	b.ReportAllocs()

      
        55
        60
         	for b.Loop() {

      
        56
        
        -		l.Run(pf.Ast)

      
        
        61
        +		l.Run(ctx)

      
        57
        62
         	}

      
        58
        63
         }

      
M internal/linter/rule_duplicated_account.go
···
        1
        1
         package linter

      
        2
        2
         

      
        3
        
        -import "olexsmir.xyz/clerk/journal/ast"

      
        
        3
        +import (

      
        
        4
        +	"fmt"

      
        
        5
        +

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

      
        
        7
        +)

      
        4
        8
         

      
        5
        9
         // DuplicatedAccount flags account declarations that appear more than once.

      
        6
        10
         type DuplicatedAccount struct{}

      
        7
        11
         

      
        8
        12
         func (DuplicatedAccount) ID() RuleID         { return "duplicated-account" }

      
        9
        13
         func (DuplicatedAccount) Severity() Severity { return SeverityWarning }

      
        10
        
        -func (d *DuplicatedAccount) CheckJournal(j *ast.Journal) []Find {

      
        
        14
        +func (d *DuplicatedAccount) CheckJournal(ctx *semantic.Context) []Find {

      
        11
        15
         	var finds []Find

      
        12
        
        -	seen := make(map[string]bool)

      
        13
        
        -

      
        14
        
        -	for _, entry := range j.Entries {

      
        15
        
        -		ad, ok := entry.(*ast.AccountDirective)

      
        16
        
        -		if !ok {

      
        
        16
        +	for _, info := range ctx.Accounts {

      
        
        17
        +		if len(info.Directives) <= 1 {

      
        17
        18
         			continue

      
        18
        19
         		}

      
        19
        
        -

      
        20
        
        -		name := ad.Account.String()

      
        21
        
        -		if seen[name] {

      
        
        20
        +		for _, ad := range info.Directives {

      
        22
        21
         			finds = append(finds, Find{

      
        23
        22
         				Code:     d.ID(),

      
        24
        23
         				Severity: d.Severity(),

      
        25
        
        -				Message:  "duplicated account declaration: " + name,

      
        
        24
        +				Message:  fmt.Sprintf("duplicated account declaration: %s", ad.Account.String()),

      
        26
        25
         				Span:     ad.Account.Span,

      
        27
        26
         			})

      
        28
        27
         		}

      
        29
        
        -		seen[name] = true

      
        30
        28
         	}

      
        31
        29
         	return finds

      
        32
        30
         }

      
M internal/linter/rule_duplicated_commodity.go
···
        1
        1
         package linter

      
        2
        2
         

      
        3
        
        -import "olexsmir.xyz/clerk/journal/ast"

      
        
        3
        +import (

      
        
        4
        +	"fmt"

      
        
        5
        +

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

      
        
        7
        +)

      
        4
        8
         

      
        5
        9
         // DuplicatedCommodity flags commodity declarations that appear more than once.

      
        6
        10
         type DuplicatedCommodity struct{}

      
        7
        11
         

      
        8
        12
         func (DuplicatedCommodity) ID() RuleID         { return "duplicated-commodity" }

      
        9
        13
         func (DuplicatedCommodity) Severity() Severity { return SeverityWarning }

      
        10
        
        -func (d *DuplicatedCommodity) CheckJournal(j *ast.Journal) []Find {

      
        
        14
        +func (d *DuplicatedCommodity) CheckJournal(ctx *semantic.Context) []Find {

      
        11
        15
         	var finds []Find

      
        12
        
        -	seen := make(map[string]bool)

      
        13
        
        -

      
        14
        
        -	for _, entry := range j.Entries {

      
        15
        
        -		cd, ok := entry.(*ast.CommodityDirective)

      
        16
        
        -		if !ok {

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

      
        
        17
        +		if len(info.Directives) <= 1 {

      
        17
        18
         			continue

      
        18
        19
         		}

      
        19
        
        -

      
        20
        
        -		name := cd.Commodity

      
        21
        
        -		if seen[name] {

      
        
        20
        +		for _, cd := range info.Directives {

      
        22
        21
         			finds = append(finds, Find{

      
        23
        22
         				Code:     d.ID(),

      
        24
        23
         				Severity: d.Severity(),

      
        25
        
        -				Message:  "duplicated commodity declaration: " + name,

      
        
        24
        +				Message:  fmt.Sprintf("duplicated commodity declaration: %s", sym),

      
        26
        25
         				Span:     cd.Span,

      
        27
        26
         			})

      
        28
        27
         		}

      
        29
        
        -

      
        30
        
        -		seen[name] = true

      
        31
        28
         	}

      
        32
        
        -

      
        33
        29
         	return finds

      
        34
        30
         }

      
M internal/linter/rule_orderdate.go
···
        4
        4
         	"fmt"

      
        5
        5
         

      
        6
        6
         	"olexsmir.xyz/clerk/journal/ast"

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

      
        7
        8
         )

      
        8
        9
         

      
        9
        10
         // OrderDate checks that transactions are in chronological order by date.

      ···
        11
        12
         

      
        12
        13
         func (OrderDate) ID() RuleID         { return "orderdate" }

      
        13
        14
         func (OrderDate) Severity() Severity { return SeverityWarning }

      
        14
        
        -func (r *OrderDate) CheckJournal(j *ast.Journal) []Find {

      
        
        15
        +func (r *OrderDate) CheckJournal(ctx *semantic.Context) []Find {

      
        15
        16
         	var finds []Find

      
        16
        17
         	var anchor *ast.Date

      
        17
        
        -	for _, entry := range j.Entries {

      
        18
        
        -		txn, ok := entry.(*ast.Transaction)

      
        19
        
        -		if !ok {

      
        20
        
        -			continue

      
        21
        
        -		}

      
        22
        
        -

      
        23
        
        -		d := txn.Date

      
        24
        
        -		if anchor != nil {

      
        25
        
        -			cmp := compareDate(d, *anchor)

      
        26
        
        -			if cmp < 0 {

      
        
        18
        +	for _, pf := range ctx.Files {

      
        
        19
        +		for _, entry := range pf.Ast.Entries {

      
        
        20
        +			txn, ok := entry.(*ast.Transaction)

      
        
        21
        +			if !ok {

      
        
        22
        +				continue

      
        
        23
        +			}

      
        
        24
        +			if anchor != nil && r.compareDate(txn.Date, *anchor) < 0 {

      
        27
        25
         				finds = append(finds, Find{

      
        28
        26
         					Code:     r.ID(),

      
        29
        27
         					Severity: r.Severity(),

      
        30
        
        -					Message: fmt.Sprintf("transaction is out of chronological order (date %s before %s)",

      
        31
        
        -						dateString(d), dateString(*anchor)),

      
        32
        
        -					Span: d.Span,

      
        
        28
        +					Message:  fmt.Sprintf("transaction is out of chronological order (date %s before %s)", r.dateString(txn.Date), r.dateString(*anchor)),

      
        
        29
        +					Span:     txn.Date.Span,

      
        33
        30
         				})

      
        34
        31
         				continue

      
        35
        32
         			}

      
        
        33
        +			anchor = &txn.Date

      
        36
        34
         		}

      
        37
        
        -

      
        38
        
        -		anchor = &d

      
        39
        35
         	}

      
        40
        
        -

      
        41
        36
         	return finds

      
        42
        37
         }

      
        43
        38
         

      
        44
        39
         // compareDate returns -1 if a < b, 0 if equal, 1 if a > b.

      
        45
        
        -func compareDate(a, b ast.Date) int {

      
        
        40
        +func (r OrderDate) compareDate(a, b ast.Date) int {

      
        46
        41
         	if a.Year != b.Year {

      
        47
        42
         		if a.Year < b.Year {

      
        48
        43
         			return -1

      ···
        64
        59
         	return 0

      
        65
        60
         }

      
        66
        61
         

      
        67
        
        -func dateString(d ast.Date) string {

      
        
        62
        +func (r OrderDate) dateString(d ast.Date) string {

      
        68
        63
         	return fmt.Sprintf("%d-%02d-%02d", d.Year, d.Month, d.Day)

      
        69
        64
         }

      
M internal/linter/rule_parse_error.go
···
        1
        1
         package linter

      
        2
        2
         

      
        3
        
        -import "olexsmir.xyz/clerk/journal/ast"

      
        
        3
        +import "olexsmir.xyz/clerk/journal/semantic"

      
        4
        4
         

      
        5
        5
         // ParseError wraps parser errors into lint findings.

      
        6
        6
         type ParseError struct{}

      
        7
        7
         

      
        8
        8
         func (ParseError) ID() RuleID         { return "parse-error" }

      
        9
        9
         func (ParseError) Severity() Severity { return SeverityError }

      
        10
        
        -func (e *ParseError) CheckJournal(j *ast.Journal) []Find {

      
        
        10
        +func (e *ParseError) CheckJournal(ctx *semantic.Context) []Find {

      
        11
        11
         	var finds []Find

      
        12
        
        -	for _, err := range j.Errors {

      
        13
        
        -		finds = append(finds, Find{

      
        14
        
        -			Code:     e.ID(),

      
        15
        
        -			Severity: e.Severity(),

      
        16
        
        -			Message:  err.Message,

      
        17
        
        -			Span:     err.Span,

      
        18
        
        -		})

      
        
        12
        +	for _, pf := range ctx.Files {

      
        
        13
        +		for _, err := range pf.Errors {

      
        
        14
        +			finds = append(finds, Find{

      
        
        15
        +				Code:     e.ID(),

      
        
        16
        +				Severity: e.Severity(),

      
        
        17
        +				Message:  err.Message,

      
        
        18
        +				Span:     err.Span,

      
        
        19
        +			})

      
        
        20
        +		}

      
        19
        21
         	}

      
        20
        22
         	return finds

      
        21
        23
         }

      
M internal/linter/rule_undeclared_account.go
···
        1
        1
         package linter

      
        2
        2
         

      
        3
        
        -import "olexsmir.xyz/clerk/journal/ast"

      
        
        3
        +import (

      
        
        4
        +	"fmt"

      
        
        5
        +

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

      
        
        7
        +)

      
        4
        8
         

      
        5
        9
         // UndeclaredAccount flags postings that reference an account not declared via `account` directive.

      
        6
        10
         type UndeclaredAccount struct{}

      
        7
        11
         

      
        8
        12
         func (UndeclaredAccount) ID() RuleID         { return "undeclared-account" }

      
        9
        13
         func (UndeclaredAccount) Severity() Severity { return SeverityWarning }

      
        10
        
        -func (r *UndeclaredAccount) CheckJournal(j *ast.Journal) []Find {

      
        11
        
        -	declared := make(map[string]bool)

      
        12
        
        -

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

      
        13
        15
         	var finds []Find

      
        14
        
        -	for _, entry := range j.Entries {

      
        15
        
        -		switch e := entry.(type) {

      
        16
        
        -		case *ast.AccountDirective:

      
        17
        
        -			declared[e.Account.String()] = true

      
        18
        
        -		case *ast.Transaction:

      
        19
        
        -			for _, p := range e.Postings {

      
        20
        
        -				name := p.Account.String()

      
        21
        
        -				if !declared[name] {

      
        22
        
        -					finds = append(finds, Find{

      
        23
        
        -						Code:     r.ID(),

      
        24
        
        -						Severity: r.Severity(),

      
        25
        
        -						Message:  "undeclared account: " + name,

      
        26
        
        -						Span:     p.Account.Span,

      
        27
        
        -					})

      
        28
        
        -				}

      
        29
        
        -			}

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

      
        
        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.Posting.Account.Span,

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

      
        
        26
        +			})

      
        30
        27
         		}

      
        31
        28
         	}

      
        32
        
        -

      
        33
        29
         	return finds

      
        34
        30
         }

      
M internal/linter/rules.go
···
        1
        1
         package linter

      
        2
        2
         

      
        3
        
        -import "olexsmir.xyz/clerk/journal/ast"

      
        
        3
        +import (

      
        
        4
        +	"olexsmir.xyz/clerk/journal/ast"

      
        
        5
        +

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

      
        
        7
        +)

      
        4
        8
         

      
        5
        9
         type RuleID string

      
        6
        10
         

      ···
        10
        14
         	Severity() Severity

      
        11
        15
         }

      
        12
        16
         

      
        13
        
        -// EntryChecker implements pre entry linting during.

      
        
        17
        +// EntryChecker implements per-entry linting.

      
        14
        18
         type EntryChecker interface {

      
        15
        19
         	Rule

      
        16
        20
         	CheckEntry(entry ast.Entry) []Find

      
        17
        21
         }

      
        18
        22
         

      
        19
        
        -// JournalChecker implements whole journal linting.

      
        
        23
        +// JournalChecker implements whole-journal linting using the semantic context.

      
        20
        24
         type JournalChecker interface {

      
        21
        25
         	Rule

      
        22
        
        -	CheckJournal(journal *ast.Journal) []Find

      
        
        26
        +	CheckJournal(ctx *semantic.Context) []Find

      
        23
        27
         }

      
        24
        28
         

      
        25
        29
         // Rules is list of all available rules.

      
M internal/linter/testdata/duplicated-account.golden
···
        
        1
        +duplicated-account.journal:1:9: duplicated-account: duplicated account declaration: Expenses:Food

      
        1
        2
         duplicated-account.journal:4:9: duplicated-account: duplicated account declaration: Expenses:Food

      
        
        3
        +duplicated-account.journal:2:9: duplicated-account: duplicated account declaration: Expenses:Transport

      
        2
        4
         duplicated-account.journal:6:9: duplicated-account: duplicated account declaration: Expenses:Transport

      
M internal/linter/testdata/duplicated-commodity.golden
···
        
        1
        +duplicated-commodity.journal:1:1: duplicated-commodity: duplicated commodity declaration: USD

      
        1
        2
         duplicated-commodity.journal:4:1: duplicated-commodity: duplicated commodity declaration: USD

      
        
        3
        +duplicated-commodity.journal:2:1: duplicated-commodity: duplicated commodity declaration: EUR

      
        2
        4
         duplicated-commodity.journal:6:1: duplicated-commodity: duplicated commodity declaration: EUR

      
M internal/linter/testdata/undeclared-account.input
···
        7
        7
         2026-01-16 * "Dinner"

      
        8
        8
           Expenses:Food      $15

      
        9
        9
           Assets:Cash

      
        10
        
        -

      
        11
        
        -account Assets:Cash

      
M lint.go
···
        10
        10
         

      
        11
        11
         	"olexsmir.xyz/clerk/internal/linter"

      
        12
        12
         	"olexsmir.xyz/clerk/journal"

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

      
        13
        14
         )

      
        14
        15
         

      
        15
        16
         func runLint(args []string) {

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

      
        81
        82
         		return nil, true

      
        82
        83
         	}

      
        83
        
        -	finds := l.Run(pf.Ast)

      
        
        84
        +	_ = pf // top-level file kept via ldr, not directly needed

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

      
        
        86
        +	finds := l.Run(ctx)

      
        84
        87
         	return finds, hasFatal(finds)

      
        85
        88
         }

      
        86
        89
         

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

      
        96
        99
         		os.Exit(1)

      
        97
        100
         	}

      
        98
        
        -	return l.Run(pf.Ast)

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

      
        
        102
        +	return l.Run(ctx)

      
        99
        103
         }

      
        100
        104
         

      
        101
        105
         func hasFatal(finds []linter.Find) bool {