all repos

clerk @ 8ff5e11

missing tooling for ledger/hledger
12 files changed, 144 insertions(+), 36 deletions(-)
linter: add invalid-include rule
Author: Oleksandr Smirnov olexsmir@gmail.com
Committed at: 2026-07-31 17:45:31 +0300
Authored at: 2026-07-31 17:36:50 +0300
Change ID: rszqqkywmxktspwrosklyzsklzuvovvy
Parent: de3dc94
M internal/linter/linter_test.go
···
        11
        11
         

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

      
        13
        13
         	"correct":                  Rules,

      
        
        14
        +	"invalid-include":          {&InvalidInclude{}},

      
        14
        15
         	"empty-postings":           {&EmptyPostings{}},

      
        15
        16
         	"parse-error":              {&ParseError{}},

      
        16
        17
         	"omitted-precision":        {&OmittedPrecision{}},

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

      
        
        2
        +

      
        
        3
        +import (

      
        
        4
        +	"fmt"

      
        
        5
        +	"path/filepath"

      
        
        6
        +

      
        
        7
        +	"olexsmir.xyz/clerk/internal/analyzer"

      
        
        8
        +	"olexsmir.xyz/clerk/journal"

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

      
        
        10
        +)

      
        
        11
        +

      
        
        12
        +// InvalidInclude flags include directives that don't point to an existing journal file.

      
        
        13
        +type InvalidInclude struct{}

      
        
        14
        +

      
        
        15
        +func (InvalidInclude) ID() RuleID         { return "invalid-include" }

      
        
        16
        +func (InvalidInclude) Severity() Severity { return SeverityError }

      
        
        17
        +func (i *InvalidInclude) CheckJournal(an *analyzer.Analysis) []Find {

      
        
        18
        +	var finds []Find

      
        
        19
        +	for _, pf := range an.Files {

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

      
        
        21
        +			inc, ok := entry.(*ast.IncludeDirective)

      
        
        22
        +			if !ok {

      
        
        23
        +				continue

      
        
        24
        +			}

      
        
        25
        +			target := filepath.Clean(filepath.Join(filepath.Dir(pf.Path), inc.Path))

      
        
        26
        +

      
        
        27
        +			if !i.resolved(target, an.Files) {

      
        
        28
        +				finds = append(finds, Find{

      
        
        29
        +					Code:     i.ID(),

      
        
        30
        +					Severity: i.Severity(),

      
        
        31
        +					Message:  fmt.Sprintf("include not found: %s", inc.Path),

      
        
        32
        +					Span:     inc.Span,

      
        
        33
        +				})

      
        
        34
        +				continue

      
        
        35
        +			}

      
        
        36
        +			if !journal.IsJournalFile(target) {

      
        
        37
        +				finds = append(finds, Find{

      
        
        38
        +					Code:     i.ID(),

      
        
        39
        +					Severity: i.Severity(),

      
        
        40
        +					Message:  fmt.Sprintf("include is not a journal file: %s", inc.Path),

      
        
        41
        +					Span:     inc.Span,

      
        
        42
        +				})

      
        
        43
        +			}

      
        
        44
        +		}

      
        
        45
        +	}

      
        
        46
        +	return finds

      
        
        47
        +}

      
        
        48
        +

      
        
        49
        +func (i *InvalidInclude) resolved(target string, files []*journal.ParsedFile) bool {

      
        
        50
        +	for _, pf := range files {

      
        
        51
        +		if ok, _ := filepath.Match(target, pf.Path); ok {

      
        
        52
        +			return true

      
        
        53
        +		}

      
        
        54
        +	}

      
        
        55
        +	return false

      
        
        56
        +}

      
M internal/linter/rule_orderdate.go
···
        12
        12
         

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

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

      
        15
        
        -func (r *OrderDate) CheckJournal(an *analyzer.Analysis) []Find {

      
        
        15
        +func (o *OrderDate) CheckJournal(an *analyzer.Analysis) []Find {

      
        16
        16
         	var finds []Find

      
        17
        17
         	var anchor *ast.Date

      
        18
        18
         	for _, pf := range an.Files {

      ···
        21
        21
         			if !ok {

      
        22
        22
         				continue

      
        23
        23
         			}

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

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

      
        25
        25
         				finds = append(finds, Find{

      
        26
        
        -					Code:     r.ID(),

      
        27
        
        -					Severity: r.Severity(),

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

      
        
        26
        +					Code:     o.ID(),

      
        
        27
        +					Severity: o.Severity(),

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

      
        29
        29
         					Span:     txn.Date.Span,

      
        30
        30
         				})

      
        31
        31
         				continue

      ···
        37
        37
         }

      
        38
        38
         

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

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

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

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

      
        42
        42
         		if a.Year < b.Year {

      
        43
        43
         			return -1

      ···
        59
        59
         	return 0

      
        60
        60
         }

      
        61
        61
         

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

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

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

      
        64
        64
         }

      
M internal/linter/rule_parse_error.go
···
        7
        7
         

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

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

      
        10
        
        -func (e *ParseError) CheckJournal(an *analyzer.Analysis) []Find {

      
        
        10
        +func (p *ParseError) CheckJournal(an *analyzer.Analysis) []Find {

      
        11
        11
         	var finds []Find

      
        12
        12
         	for _, pf := range an.Files {

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

      
        14
        14
         			finds = append(finds, Find{

      
        15
        
        -				Code:     e.ID(),

      
        16
        
        -				Severity: e.Severity(),

      
        
        15
        +				Code:     p.ID(),

      
        
        16
        +				Severity: p.Severity(),

      
        17
        17
         				Message:  err.Message,

      
        18
        18
         				Span:     err.Span,

      
        19
        19
         			})

      
M internal/linter/rule_unbalanced_transaction.go
···
        14
        14
         

      
        15
        15
         func (UnbalancedTransaction) ID() RuleID         { return "unbalanced-transaction" }

      
        16
        16
         func (UnbalancedTransaction) Severity() Severity { return SeverityError }

      
        17
        
        -func (r *UnbalancedTransaction) CheckJournal(an *analyzer.Analysis) []Find {

      
        
        17
        +func (u *UnbalancedTransaction) CheckJournal(an *analyzer.Analysis) []Find {

      
        18
        18
         	var finds []Find

      
        19
        19
         	for _, txn := range an.Transactions {

      
        20
        
        -		finds = append(finds, r.check(txn.Postings, txn.Span)...)

      
        
        20
        +		finds = append(finds, u.check(txn.Postings, txn.Span)...)

      
        21
        21
         	}

      
        22
        22
         	for _, ptx := range an.PeriodicTransactions {

      
        23
        
        -		finds = append(finds, r.check(ptx.Postings, ptx.Span)...)

      
        
        23
        +		finds = append(finds, u.check(ptx.Postings, ptx.Span)...)

      
        24
        24
         	}

      
        25
        25
         	for _, atx := range an.AutomatedTransactions {

      
        26
        
        -		finds = append(finds, r.check(atx.Postings, atx.Span)...)

      
        
        26
        +		finds = append(finds, u.check(atx.Postings, atx.Span)...)

      
        27
        27
         	}

      
        28
        28
         	return finds

      
        29
        29
         }

      
        30
        30
         

      
        31
        
        -func (r *UnbalancedTransaction) check(postings []*ast.Posting, span token.Span) []Find {

      
        
        31
        +func (u *UnbalancedTransaction) check(postings []*ast.Posting, span token.Span) []Find {

      
        32
        32
         	var hasExpr, hasCost bool

      
        33
        33
         	var realPostings []*ast.Posting

      
        34
        34
         	autoBalancingPostings := 0

      ···
        75
        75
         				msg = fmt.Sprintf("transaction is unbalanced; net balance is %s", sum.String())

      
        76
        76
         			}

      
        77
        77
         			finds = append(finds, Find{

      
        78
        
        -				Code:     r.ID(),

      
        79
        
        -				Severity: r.Severity(),

      
        
        78
        +				Code:     u.ID(),

      
        
        79
        +				Severity: u.Severity(),

      
        80
        80
         				Span:     span,

      
        81
        81
         				Message:  msg,

      
        82
        82
         			})

      
M internal/linter/rule_undeclared_account.go
···
        11
        11
         

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

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

      
        14
        
        -func (r *UndeclaredAccount) CheckJournal(an *analyzer.Analysis) []Find {

      
        
        14
        +func (u *UndeclaredAccount) CheckJournal(an *analyzer.Analysis) []Find {

      
        15
        15
         	var finds []Find

      
        16
        16
         	for name, info := range an.Accounts {

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

      ···
        19
        19
         		}

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

      
        21
        21
         			finds = append(finds, Find{

      
        22
        
        -				Code:     r.ID(),

      
        23
        
        -				Severity: r.Severity(),

      
        
        22
        +				Code:     u.ID(),

      
        
        23
        +				Severity: u.Severity(),

      
        24
        24
         				Span:     usage.Posting.Account.Span,

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

      
        26
        26
         			})

      
M internal/linter/rule_undeclared_commodity.go
···
        11
        11
         

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

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

      
        14
        
        -func (r *UndeclaredCommodity) CheckJournal(an *analyzer.Analysis) []Find {

      
        
        14
        +func (u *UndeclaredCommodity) CheckJournal(an *analyzer.Analysis) []Find {

      
        15
        15
         	var finds []Find

      
        16
        16
         	for name, info := range an.Commodities {

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

      ···
        19
        19
         		}

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

      
        21
        21
         			finds = append(finds, Find{

      
        22
        
        -				Code:     r.ID(),

      
        23
        
        -				Severity: r.Severity(),

      
        
        22
        +				Code:     u.ID(),

      
        
        23
        +				Severity: u.Severity(),

      
        24
        24
         				Span:     usage.Amount.Span,

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

      
        26
        26
         			})

      
M internal/linter/rule_unused_account.go
···
        11
        11
         

      
        12
        12
         func (UnusedAccount) ID() RuleID         { return "unused-account" }

      
        13
        13
         func (UnusedAccount) Severity() Severity { return SeverityWarning }

      
        14
        
        -func (a *UnusedAccount) CheckJournal(an *analyzer.Analysis) []Find {

      
        
        14
        +func (u *UnusedAccount) CheckJournal(an *analyzer.Analysis) []Find {

      
        15
        15
         	var finds []Find

      
        16
        16
         	for name, info := range an.Accounts {

      
        17
        17
         		if len(info.Directives) == 0 {

      ···
        22
        22
         		}

      
        23
        23
         		for _, d := range info.Directives {

      
        24
        24
         			finds = append(finds, Find{

      
        25
        
        -				Code:     a.ID(),

      
        26
        
        -				Severity: a.Severity(),

      
        
        25
        +				Code:     u.ID(),

      
        
        26
        +				Severity: u.Severity(),

      
        27
        27
         				Span:     d.Account.Span,

      
        28
        28
         				Message:  fmt.Sprintf("unused account: %s", name),

      
        29
        29
         			})

      
M internal/linter/rules.go
···
        14
        14
         // Rules is list of all available rules.

      
        15
        15
         var Rules = []Rule{

      
        16
        16
         	&ParseError{},

      
        
        17
        +	&InvalidInclude{},

      
        17
        18
         	&EmptyPostings{},

      
        18
        19
         	&OmittedPrecision{},

      
        19
        20
         	&MissingCommodity{},

      
A internal/linter/testdata/invalid-include.txtar
···
        
        1
        +-- in.journal --

      
        
        2
        +include child.journal

      
        
        3
        +include missing.journal

      
        
        4
        +include notes.txt

      
        
        5
        +

      
        
        6
        +year 2024

      
        
        7
        +

      
        
        8
        +-- child.journal --

      
        
        9
        +include sub/grand.journal

      
        
        10
        +

      
        
        11
        +-- sub/grand.journal --

      
        
        12
        +2024/06/15 lunch

      
        
        13
        +  expenses:food  $5

      
        
        14
        +  assets:cash

      
        
        15
        +

      
        
        16
        +-- notes.txt --

      
        
        17
        +; just a note

      
        
        18
        +

      
        
        19
        +-- expect --

      
        
        20
        +in.journal:2:1: invalid-include: include not found: missing.journal

      
        
        21
        +in.journal:3:1: invalid-include: include is not a journal file: notes.txt

      
M journal/loader.go
···
        91
        91
         	}

      
        92
        92
         	defer os.RemoveAll(dir)

      
        93
        93
         

      
        94
        
        -	if err := os.CopyFS(dir, fsys); err != nil {

      
        95
        
        -		return nil, fmt.Errorf("copying fs to temp dir: %w", err)

      
        
        94
        +	if cerr := os.CopyFS(dir, fsys); cerr != nil {

      
        
        95
        +		return nil, fmt.Errorf("copying fs to temp dir: %w", cerr)

      
        96
        96
         	}

      
        97
        97
         

      
        98
        98
         	rj, err := l.Resolve(filepath.Join(dir, fpath))

      ···
        256
        256
         }

      
        257
        257
         

      
        258
        258
         func resolveIncludePath(parentPath, incPattern string) (string, error) {

      
        259
        
        -	base := filepath.Dir(parentPath)

      
        260
        
        -	target := filepath.Join(base, incPattern)

      
        261
        
        -	// normalise to detect traversal

      
        262
        
        -	clean := filepath.Clean(target)

      
        263
        
        -	if !strings.HasPrefix(clean, filepath.Clean(base)+string(filepath.Separator)) &&

      
        264
        
        -		clean != filepath.Clean(base) &&

      
        265
        
        -		!filepath.IsAbs(incPattern) {

      
        
        259
        +	base := filepath.Clean(filepath.Dir(parentPath))

      
        
        260
        +	target := filepath.Clean(filepath.Join(base, incPattern))

      
        
        261
        +	if filepath.IsAbs(incPattern) {

      
        
        262
        +		return target, nil

      
        
        263
        +	}

      
        
        264
        +

      
        
        265
        +	// reject includes that escape the parent directory, e.g. "../../other.journal"

      
        
        266
        +	rel, err := filepath.Rel(base, target)

      
        
        267
        +	if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {

      
        266
        268
         		return "", fmt.Errorf("path traversal: %s", incPattern)

      
        267
        269
         	}

      
        268
        
        -	return clean, nil

      
        
        270
        +	return target, nil

      
        269
        271
         }

      
        270
        272
         

      
        271
        273
         func canonicalPath(path string) string {

      
M journal/loader_test.go
···
        163
        163
         	}

      
        164
        164
         }

      
        165
        165
         

      
        
        166
        +func TestResolveIncludePath(t *testing.T) {

      
        
        167
        +	tests := []struct {

      
        
        168
        +		parent, pattern string

      
        
        169
        +		wantPath        string

      
        
        170
        +		wantErr         bool

      
        
        171
        +	}{

      
        
        172
        +		{"main.journal", "child.journal", "child.journal", false},

      
        
        173
        +		{"main.journal", "sub/child.journal", "sub/child.journal", false},

      
        
        174
        +		{"main.journal", "../other.journal", "", true},

      
        
        175
        +		{"sub/main.journal", "child.journal", "sub/child.journal", false},

      
        
        176
        +		{"sub/main.journal", "../other.journal", "", true},

      
        
        177
        +	}

      
        
        178
        +	for _, tt := range tests {

      
        
        179
        +		got, err := resolveIncludePath(tt.parent, tt.pattern)

      
        
        180
        +		if tt.wantErr {

      
        
        181
        +			if err == nil {

      
        
        182
        +				t.Errorf("resolveIncludePath(%q, %q): want error, got %q", tt.parent, tt.pattern, got)

      
        
        183
        +			}

      
        
        184
        +			continue

      
        
        185
        +		}

      
        
        186
        +		if err != nil || got != tt.wantPath {

      
        
        187
        +			t.Errorf("resolveIncludePath(%q, %q) = %q, %v; want %q", tt.parent, tt.pattern, got, err, tt.wantPath)

      
        
        188
        +		}

      
        
        189
        +	}

      
        
        190
        +}

      
        
        191
        +

      
        
        192
        +

      
        166
        193
         // helpers

      
        167
        194
         

      
        168
        195
         func resolveTxtar(t *testing.T, rootFile, archive string) *ResolvedJournal {