12 files changed,
100 insertions(+),
95 deletions(-)
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
jump to
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
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 {