all repos

clerk @ cddf442e7f66ba3c73d9f4f9bcb13693d28aa6b1

missing tooling for ledger/hledger
14 files changed, 299 insertions(+), 4 deletions(-)
linter: add different rules related to tags

rules:
- unused-tag
- undeclared-tag
- duplicated-tag
- invalid-type-tag
- invalid-date-tag
Author: Oleksandr Smirnov olexsmir@gmail.com
Committed at: 2026-08-06 16:47:26 +0300
Authored at: 2026-08-05 15:41:42 +0300
Change ID: xpytvnqtkxtwrooyzxzxwsowuqwsokuq
Parent: 8f83d37
M internal/analyzer/testdata/tags.txtar
···
        11
        11
         2024-02-03 Client work  ; billed:extra

      
        12
        12
             expenses:consulting  $50

      
        13
        13
             assets:receivable

      
        
        14
        +

      
        14
        15
         -- expect --

      
        15
        16
         files (1):

      
        16
        17
           0: in.journal

      
M internal/linter/linter_test.go
···
        24
        24
         	"duplicated-account":       {&DuplicatedAccount{}},

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

      
        26
        26
         	"duplicated-transaction":   {&DuplicatedTransaction{}},

      
        
        27
        +	"duplicated-tag":           {&DuplicatedTag{}},

      
        
        28
        +	"undeclared-tag":           {&UndeclaredTag{}},

      
        27
        29
         	"undeclared-commodity":     {&UndeclaredCommodity{}},

      
        28
        30
         	"undeclared-account":       {&UndeclaredAccount{}},

      
        29
        31
         	"unbalanced-transaction":   {&UnbalancedTransaction{}},

      
        30
        32
         	"unused-account":           {&UnusedAccount{}},

      
        
        33
        +	"unused-tag":               {&UnusedTag{}},

      
        
        34
        +	"invalid-date-tag":         {&InvalidDateTag{}},

      
        
        35
        +	"invalid-type-tag":         {&InvalidTypeTag{}},

      
        31
        36
         }

      
        32
        37
         

      
        33
        38
         func TestLinter(t *testing.T) {

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

      
        
        2
        +

      
        
        3
        +import (

      
        
        4
        +	"fmt"

      
        
        5
        +

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

      
        
        7
        +)

      
        
        8
        +

      
        
        9
        +// DuplicatedTag flags tag declarations that appear more than once.

      
        
        10
        +type DuplicatedTag struct{}

      
        
        11
        +

      
        
        12
        +func (DuplicatedTag) ID() RuleID         { return "duplicated-tag" }

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

      
        
        14
        +func (d *DuplicatedTag) CheckJournal(an *analyzer.Analysis) []Find {

      
        
        15
        +	var finds []Find

      
        
        16
        +	for name, info := range an.Tags {

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

      
        
        18
        +			continue

      
        
        19
        +		}

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

      
        
        21
        +			finds = append(finds, Find{

      
        
        22
        +				Code:     d.ID(),

      
        
        23
        +				Severity: d.Severity(),

      
        
        24
        +				Message:  fmt.Sprintf("duplicated tag declaration: %s", name),

      
        
        25
        +				Span:     td.Span,

      
        
        26
        +			})

      
        
        27
        +		}

      
        
        28
        +	}

      
        
        29
        +	return finds

      
        
        30
        +}

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

      
        
        2
        +

      
        
        3
        +import (

      
        
        4
        +	"fmt"

      
        
        5
        +

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

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

      
        
        8
        +)

      
        
        9
        +

      
        
        10
        +// InvalidDateTag flags date: and date2: tag values that are not valid dates.

      
        
        11
        +type InvalidDateTag struct{}

      
        
        12
        +

      
        
        13
        +func (InvalidDateTag) ID() RuleID         { return "invalid-date-tag" }

      
        
        14
        +func (InvalidDateTag) Severity() Severity { return SeverityError }

      
        
        15
        +func (i *InvalidDateTag) CheckJournal(an *analyzer.Analysis) []Find {

      
        
        16
        +	var finds []Find

      
        
        17
        +	for _, name := range []string{"date", "date2"} {

      
        
        18
        +		info, ok := an.Tags[name]

      
        
        19
        +		if !ok {

      
        
        20
        +			continue

      
        
        21
        +		}

      
        
        22
        +		for _, usage := range info.Usage {

      
        
        23
        +			if _, _, _, _, err := parser.ParseDateLiteral(usage.Tag.Value); err != nil {

      
        
        24
        +				finds = append(finds, Find{

      
        
        25
        +					Code:     i.ID(),

      
        
        26
        +					Severity: i.Severity(),

      
        
        27
        +					Span:     usage.Tag.Span,

      
        
        28
        +					Message:  fmt.Sprintf("invalid %s: tag value %q", name, usage.Tag.Value),

      
        
        29
        +				})

      
        
        30
        +			}

      
        
        31
        +		}

      
        
        32
        +	}

      
        
        33
        +	return finds

      
        
        34
        +}

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

      
        
        2
        +

      
        
        3
        +import (

      
        
        4
        +	"fmt"

      
        
        5
        +	"strings"

      
        
        6
        +

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

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

      
        
        9
        +)

      
        
        10
        +

      
        
        11
        +// InvalidTypeTag flags type tag on account directive whose value is not valid account type code.

      
        
        12
        +type InvalidTypeTag struct{}

      
        
        13
        +

      
        
        14
        +func (InvalidTypeTag) ID() RuleID         { return "invalid-type-tag" }

      
        
        15
        +func (InvalidTypeTag) Severity() Severity { return SeverityError }

      
        
        16
        +func (i *InvalidTypeTag) CheckJournal(an *analyzer.Analysis) []Find {

      
        
        17
        +	var finds []Find

      
        
        18
        +	for _, entry := range an.Directives {

      
        
        19
        +		ad, ok := entry.(*ast.AccountDirective)

      
        
        20
        +		if !ok || ad.Comment == nil {

      
        
        21
        +			continue

      
        
        22
        +		}

      
        
        23
        +		for _, tag := range ad.Comment.Tags {

      
        
        24
        +			if tag.Key != "type" {

      
        
        25
        +				continue

      
        
        26
        +			}

      
        
        27
        +			if err := i.parseAccountTypeCode(tag.Value); err != nil {

      
        
        28
        +				finds = append(finds, Find{

      
        
        29
        +					Code:     i.ID(),

      
        
        30
        +					Severity: i.Severity(),

      
        
        31
        +					Span:     tag.Span,

      
        
        32
        +					Message:  fmt.Sprintf("invalid type: tag value %q", tag.Value),

      
        
        33
        +				})

      
        
        34
        +			}

      
        
        35
        +		}

      
        
        36
        +	}

      
        
        37
        +	return finds

      
        
        38
        +}

      
        
        39
        +

      
        
        40
        +// parseAccountTypeCode validates account type code.

      
        
        41
        +// Keep in sync with: https://github.com/simonmichael/hledger/blob/b589824e713eb53a1f91ae74b3038e75fb38ea6b/hledger-lib/Hledger/Read/JournalReader.hs#L550

      
        
        42
        +func (i *InvalidTypeTag) parseAccountTypeCode(s string) error {

      
        
        43
        +	switch strings.ToLower(s) {

      
        
        44
        +	case "a", "asset":

      
        
        45
        +	case "l", "liability":

      
        
        46
        +	case "e", "equity":

      
        
        47
        +	case "r", "revenue":

      
        
        48
        +	case "x", "expense":

      
        
        49
        +	case "c", "cash":

      
        
        50
        +	case "v", "conversion":

      
        
        51
        +	case "g", "gains":

      
        
        52
        +	case "u", "unrealised", "unrealised-gain", "unrealised-gains", "unrealized", "unrealized-gain", "unrealized-gains":

      
        
        53
        +	default:

      
        
        54
        +		return fmt.Errorf("invalid account type code %q, should be one of "+

      
        
        55
        +			"A, L, E, R, X, C, V, G, U, Asset, Liability, Equity, Revenue, "+

      
        
        56
        +			"Expense, Cash, Conversion, Gains, UnrealisedGain", s)

      
        
        57
        +	}

      
        
        58
        +	return nil

      
        
        59
        +}

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

      
        
        2
        +

      
        
        3
        +import (

      
        
        4
        +	"fmt"

      
        
        5
        +

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

      
        
        7
        +)

      
        
        8
        +

      
        
        9
        +// UndeclaredTag flags used tag that's not declared via `tag` directive.

      
        
        10
        +type UndeclaredTag struct{}

      
        
        11
        +

      
        
        12
        +func (UndeclaredTag) ID() RuleID         { return "undeclared-tag" }

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

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

      
        
        15
        +	var finds []Find

      
        
        16
        +	for name, info := range an.Tags {

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

      
        
        18
        +			continue

      
        
        19
        +		}

      
        
        20
        +		// default tags

      
        
        21
        +		if name == "date" || name == "date2" || name == "type" {

      
        
        22
        +			continue

      
        
        23
        +		}

      
        
        24
        +		for _, usage := range info.Usage {

      
        
        25
        +			finds = append(finds, Find{

      
        
        26
        +				Code:     u.ID(),

      
        
        27
        +				Severity: u.Severity(),

      
        
        28
        +				Span:     usage.Tag.Span,

      
        
        29
        +				Message:  fmt.Sprintf("undeclared tag: %s", name),

      
        
        30
        +			})

      
        
        31
        +		}

      
        
        32
        +	}

      
        
        33
        +	return finds

      
        
        34
        +}

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

      
        
        2
        +

      
        
        3
        +import (

      
        
        4
        +	"fmt"

      
        
        5
        +

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

      
        
        7
        +)

      
        
        8
        +

      
        
        9
        +// UnusedTag flags declared tags that are not used.

      
        
        10
        +type UnusedTag struct{}

      
        
        11
        +

      
        
        12
        +func (UnusedTag) ID() RuleID         { return "unused-tag" }

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

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

      
        
        15
        +	var finds []Find

      
        
        16
        +	for name, info := range an.Tags {

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

      
        
        18
        +			continue

      
        
        19
        +		}

      
        
        20
        +		if len(info.Usage) > 0 {

      
        
        21
        +			continue

      
        
        22
        +		}

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

      
        
        24
        +			finds = append(finds, Find{

      
        
        25
        +				Code:     u.ID(),

      
        
        26
        +				Severity: u.Severity(),

      
        
        27
        +				Span:     d.Span,

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

      
        
        29
        +			})

      
        
        30
        +		}

      
        
        31
        +	}

      
        
        32
        +	return finds

      
        
        33
        +}

      
M internal/linter/rules.go
···
        26
        26
         	&DuplicatedAccount{},

      
        27
        27
         	&DuplicatedCommodity{},

      
        28
        28
         	&DuplicatedTransaction{},

      
        
        29
        +	&DuplicatedTag{},

      
        29
        30
         	&UndeclaredCommodity{},

      
        30
        31
         	&UndeclaredAccount{},

      
        31
        32
         	&UnbalancedTransaction{},

      
        
        33
        +	&UndeclaredTag{},

      
        
        34
        +	&InvalidDateTag{},

      
        
        35
        +	&InvalidTypeTag{},

      
        32
        36
         	&UnusedAccount{},

      
        
        37
        +	&UnusedTag{},

      
        33
        38
         }

      
A internal/linter/testdata/duplicated-tag.txtar
···
        
        1
        +-- in.journal --

      
        
        2
        +tag used

      
        
        3
        +tag single

      
        
        4
        +tag used

      
        
        5
        +tag used

      
        
        6
        +

      
        
        7
        +; used:

      
        
        8
        +

      
        
        9
        +-- expect --

      
        
        10
        +in.journal:1:1: duplicated-tag: duplicated tag declaration: used

      
        
        11
        +in.journal:3:1: duplicated-tag: duplicated tag declaration: used

      
        
        12
        +in.journal:4:1: duplicated-tag: duplicated tag declaration: used

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

      
        
        2
        +2026-01-15 * "Refund"           ; date:2024-01-16

      
        
        3
        +  Assets:Cash        20.00 EUR

      
        
        4
        +  Expenses:Food                 ; date:2024-13-99

      
        
        5
        +

      
        
        6
        +2026-01-16 Groceries            ; date:2024-1-5, date2:2024-01-20

      
        
        7
        +  Expenses:Food   $10

      
        
        8
        +  Assets:Cash

      
        
        9
        +

      
        
        10
        +2026-01-17 Slash                ; date:2024/01/20

      
        
        11
        +  Expenses:Food   $10

      
        
        12
        +  Assets:Cash

      
        
        13
        +

      
        
        14
        +2026-01-18 Broken               ; date:

      
        
        15
        +  Expenses:Food   $10

      
        
        16
        +  Assets:Cash

      
        
        17
        +

      
        
        18
        +-- expect --

      
        
        19
        +in.journal:3:35: invalid-date-tag: invalid date: tag value "2024-13-99"

      
        
        20
        +in.journal:13:35: invalid-date-tag: invalid date: tag value ""

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

      
        
        2
        +account assets:cash    ; type:C

      
        
        3
        +account expenses:food  ; type:X

      
        
        4
        +account equity         ; type:V

      
        
        5
        +account revenue:gains  ; type:G

      
        
        6
        +account unknown:place  ; type:U

      
        
        7
        +

      
        
        8
        +account long:name      ; type:Asset

      
        
        9
        +account lower:case     ; type:x

      
        
        10
        +account unrealized     ; type:unrealized-gain

      
        
        11
        +

      
        
        12
        +account bad:type       ; type:Z

      
        
        13
        +account empty:type     ; type:

      
        
        14
        +account typo:code      ; type:Expenc

      
        
        15
        +

      
        
        16
        +account caps:key       ; TYPE:asset

      
        
        17
        +2026-01-15 Shopping    ; type:whatever

      
        
        18
        +  Expenses:Food   $10

      
        
        19
        +  Assets:Cash

      
        
        20
        +

      
        
        21
        +; type:standalone

      
        
        22
        +

      
        
        23
        +-- expect --

      
        
        24
        +in.journal:11:26: invalid-type-tag: invalid type: tag value "Z"

      
        
        25
        +in.journal:12:26: invalid-type-tag: invalid type: tag value ""

      
        
        26
        +in.journal:13:26: invalid-type-tag: invalid type: tag value "Expenc"

      
A internal/linter/testdata/undeclared-tag.txtar
···
        
        1
        +-- in.journal --

      
        
        2
        +account expenses  ; type:E

      
        
        3
        +

      
        
        4
        +tag test1

      
        
        5
        +tag test2

      
        
        6
        +tag billable

      
        
        7
        +

      
        
        8
        +; test1:value

      
        
        9
        +; test2:

      
        
        10
        +; test3:

      
        
        11
        +

      
        
        12
        +2026-01-16 * "Refund"           ; billable:

      
        
        13
        +  Assets:Cash        20.00 EUR

      
        
        14
        +  Expenses:Food                 ; date:2024-01-16

      
        
        15
        +

      
        
        16
        +-- expect --

      
        
        17
        +in.journal:9:3: undeclared-tag: undeclared tag: test3

      
A internal/linter/testdata/unused-tag.txtar
···
        
        1
        +-- in.journal --

      
        
        2
        +tag used-txn

      
        
        3
        +tag used-posting

      
        
        4
        +tag used-comment

      
        
        5
        +tag unused

      
        
        6
        +tag twice

      
        
        7
        +tag twice

      
        
        8
        +

      
        
        9
        +2026-01-15 Shopping ; used-txn:

      
        
        10
        +  Expenses:Food   $10 ; used-posting:

      
        
        11
        +  Assets:Cash

      
        
        12
        +

      
        
        13
        +; used-comment:

      
        
        14
        +

      
        
        15
        +-- expect --

      
        
        16
        +in.journal:4:1: unused-tag: unused tag: unused

      
        
        17
        +in.journal:5:1: unused-tag: unused tag: twice

      
        
        18
        +in.journal:6:1: unused-tag: unused tag: twice

      
M journal/parser/parser.go
···
        1047
        1047
         		return ast.Date{Span: p.span(s)}

      
        1048
        1048
         	}

      
        1049
        1049
         

      
        1050
        
        -	year, month, day, sep, err := parseDateLiteral(tok.Literal)

      
        
        1050
        +	year, month, day, sep, err := ParseDateLiteral(tok.Literal)

      
        1051
        1051
         	if err != nil {

      
        1052
        1052
         		p.errorf("%v", err)

      
        1053
        1053
         		return ast.Date{Span: p.span(s)}

      ···
        1288
        1288
         

      
        1289
        1289
         // parseSimpleDate  parses full YYYY/MM/DD date literal embedded in free text.

      
        1290
        1290
         func parseSimpleDate(s string) ast.Date {

      
        1291
        
        -	year, month, day, sep, err := parseDateLiteral(s)

      
        
        1291
        +	year, month, day, sep, err := ParseDateLiteral(s)

      
        1292
        1292
         	if err != nil {

      
        1293
        1293
         		return ast.Date{}

      
        1294
        1294
         	}

      
        1295
        1295
         	return ast.Date{Year: year, Month: month, Day: day, Sep: sep}

      
        1296
        1296
         }

      
        1297
        1297
         

      
        1298
        
        -// parseDateLiteral parses and validates a date literal.

      
        1299
        
        -func parseDateLiteral(lit string) (year, month, day int, sep byte, err error) {

      
        
        1298
        +// ParseDateLiteral parses and validates a date literal.

      
        
        1299
        +// It accepts full YYYY/MM/DD and partial MM/DD forms, with '-', '/' or '.' as separators.

      
        
        1300
        +func ParseDateLiteral(lit string) (year, month, day int, sep byte, err error) {

      
        1300
        1301
         	sep = dateSeparator(lit)

      
        1301
        1302
         	if sep == 0 {

      
        1302
        1303
         		return 0, 0, 0, 0, fmt.Errorf("invalid date format: %q", lit)