14 files changed,
299 insertions(+),
4 deletions(-)
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
jump to
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)