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