5 files changed,
147 insertions(+),
12 deletions(-)
Author:
Oleksandr Smirnov
olexsmir@gmail.com
Committed at:
2026-07-11 12:58:15 +0300
Authored at:
2026-06-29 10:52:47 +0300
Change ID:
psmrrlzoqxkqkwrzwzswuxsltwmkpwoz
Parent:
225a2d3
M
internal/cli/cmd_lint.go
··· 43 43 } 44 44 } 45 45 46 - sema := semantic.Build(loader.Ordered()) 47 - reporter.Collect(lint.Run(sema)) 46 + for _, root := range loader.Roots() { 47 + reporter.Collect(lint.Run(semantic.Build(journal.CollectFiles(root)))) 48 + } 48 49 49 50 if err := reporter.Flush(format); err != nil { 50 51 return fmt.Errorf("flushing report: %w", err) ··· 62 63 return err 63 64 } 64 65 65 - sema := semantic.Build(loader.Ordered()) 66 - reporter.Collect(lint.Run(sema)) 66 + for _, root := range loader.Roots() { 67 + reporter.Collect(lint.Run(semantic.Build(journal.CollectFiles(root)))) 68 + } 67 69 68 70 if err := reporter.Flush(format); err != nil { 69 71 return fmt.Errorf("flushing report: %w", err)
M
internal/linter/linter_test.go
··· 1 1 package linter 2 2 3 3 import ( 4 + "os" 5 + "path/filepath" 4 6 "strings" 5 7 "testing" 6 8 ··· 46 48 } 47 49 } 48 50 51 +func TestLinter_CrossFileScoping(t *testing.T) { 52 + t.Run("undeclared across sibling trees", func(t *testing.T) { 53 + loader := loadTestFiles(t, map[string]string{ 54 + "root_a.journal": "2026/01/01 * \"A\"\n Expenses:Food $10\n Assets:Cash\n", 55 + "root_b.journal": "account Expenses:Food\n", 56 + }) 57 + finds := runLint(t, loader) 58 + 59 + n := 0 60 + for _, f := range finds { 61 + if f.Code == "undeclared-account" { 62 + n++ 63 + } 64 + } 65 + if n != 2 { 66 + t.Fatalf("expected 2 undeclared (Expenses:Food + Assets:Cash in root_a), got %d", n) 67 + } 68 + }) 69 + 70 + t.Run("declaration in included file visible", func(t *testing.T) { 71 + loader := loadTestFiles(t, map[string]string{ 72 + "child.journal": "account Expenses:Food\n", 73 + "parent.journal": "include child.journal\n\n2026/01/01 * \"Lunch\"\n Expenses:Food $10\n Assets:Cash\n", 74 + }) 75 + finds := runLint(t, loader) 76 + 77 + n := 0 78 + for _, f := range finds { 79 + if f.Code == "undeclared-account" { 80 + n++ 81 + } 82 + } 83 + if n != 1 { 84 + t.Fatalf("expected 1 undeclared (Assets:Cash), got %d", n) 85 + } 86 + }) 87 + 88 + t.Run("duplicate across sibling trees not flagged", func(t *testing.T) { 89 + loader := loadTestFiles(t, map[string]string{ 90 + "a.journal": "account Expenses:Food\n", 91 + "b.journal": "account Expenses:Food\n", 92 + }) 93 + finds := runLint(t, loader) 94 + 95 + for _, f := range finds { 96 + if f.Code == "duplicated-account" { 97 + t.Fatalf("unexpected duplicate across trees: %v", f) 98 + } 99 + } 100 + }) 101 +} 102 + 49 103 func BenchmarkLinter(b *testing.B) { 50 104 ldr := journal.NewLoader() 51 105 _, err := ldr.Load( ··· 64 118 l.Run(ctx) 65 119 } 66 120 } 121 + 122 +func loadTestFiles(t testing.TB, files map[string]string) *journal.Loader { 123 + t.Helper() 124 + dir := t.TempDir() 125 + loader := journal.NewLoader() 126 + for name, content := range files { 127 + if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644); err != nil { 128 + t.Fatal(err) 129 + } 130 + if _, err := loader.Load(filepath.Join(dir, name)); err != nil { 131 + t.Fatal(err) 132 + } 133 + } 134 + return loader 135 +} 136 + 137 +func runLint(t testing.TB, loader *journal.Loader) []Find { 138 + t.Helper() 139 + lint := NewLinter(Rules) 140 + var finds []Find 141 + for _, root := range loader.Roots() { 142 + finds = append(finds, lint.Run(semantic.Build(journal.CollectFiles(root)))...) 143 + } 144 + return finds 145 +}
M
internal/lsp/diagnostics.go
··· 2 2 3 3 import ( 4 4 "context" 5 + "fmt" 5 6 "time" 6 7 7 8 "go.lsp.dev/jsonrpc2" 8 9 "go.lsp.dev/protocol" 9 10 "go.lsp.dev/uri" 11 + 10 12 "olexsmir.xyz/clerk/internal/linter" 11 13 "olexsmir.xyz/clerk/journal" 12 14 "olexsmir.xyz/clerk/journal/semantic" ··· 31 33 } 32 34 } 33 35 34 - ordered := s.loader.Ordered() 35 - if len(ordered) == 0 { 36 + roots := s.loader.Roots() 37 + if len(roots) == 0 { 36 38 s.logger.Debug("no files in workspace") 37 39 return 38 40 } 39 41 40 - s.sema = semantic.Build(ordered) // TODO: add dynamic relaoder to sema 41 - finds := s.linter.Run(s.sema) 42 + var finds []linter.Find 43 + for _, root := range roots { 44 + finds = append(finds, s.linter.Run(semantic.Build(journal.CollectFiles(root)))...) 45 + } 46 + finds = dedupFinds(finds) 42 47 43 48 diagsByFile := groupFindsByFile(finds) 44 - activeFiles := activeFileSet(s.openDocs, ordered) 49 + activeFiles := activeFileSet(s.openDocs, s.loader.Ordered()) 45 50 for fpath := range activeFiles { 46 51 diags := diagsByFile[fpath] 47 52 if diags == nil { // TODO: not sure if it's needed ··· 55 60 } 56 61 } 57 62 58 - s.logger.Debug("workspace rebuild complete", "files", len(ordered), "findings", len(finds)) 63 + s.logger.Debug("workspace rebuild complete", "files", len(s.loader.Ordered()), "findings", len(finds)) 59 64 } 60 65 61 66 func (s *server) scheduleWorkspaceRebuild(ctx context.Context) { ··· 121 126 diags[file] = append(diags[file], findToDiagnostic(find)) 122 127 } 123 128 return diags 129 +} 130 + 131 +func dedupFinds(finds []linter.Find) []linter.Find { 132 + seen := make(map[string]bool) 133 + dedup := make([]linter.Find, 0, len(finds)) 134 + for _, f := range finds { 135 + s := f.Span.Start 136 + key := fmt.Sprintf("%s:%d:%d:%s", s.File, s.Line, s.Col, f.Code) 137 + if seen[key] { 138 + continue 139 + } 140 + seen[key] = true 141 + dedup = append(dedup, f) 142 + } 143 + return dedup 124 144 } 125 145 126 146 func findToDiagnostic(find linter.Find) protocol.Diagnostic {
M
internal/lsp/server.go
··· 12 12 "olexsmir.xyz/clerk/internal/linter" 13 13 "olexsmir.xyz/clerk/journal" 14 14 "olexsmir.xyz/clerk/journal/printer" 15 - "olexsmir.xyz/clerk/journal/semantic" 16 15 ) 17 16 18 17 const name = "clerk" ··· 34 33 logger *slog.Logger 35 34 36 35 loader *journal.Loader 37 - sema *semantic.Context 38 36 linter *linter.Linter 39 37 debouncer *time.Timer 40 38
M
journal/loader.go
··· 46 46 return l.loadBytes(abs, src, nil) 47 47 } 48 48 49 +// Roots returns files not transitively included by any loaded file. 50 +func (l *Loader) Roots() []*ParsedFile { 51 + included := make(map[string]bool) 52 + for _, pf := range l.files { 53 + for _, inc := range pf.Includes { 54 + included[inc.Path] = true 55 + } 56 + } 57 + var roots []*ParsedFile 58 + for _, pf := range l.files { 59 + if !included[pf.Path] { 60 + roots = append(roots, pf) 61 + } 62 + } 63 + return roots 64 +} 65 + 66 +// CollectFiles returns root and all its transitive includes. 67 +func CollectFiles(root *ParsedFile) []*ParsedFile { 68 + var files []*ParsedFile 69 + visited := make(map[*ParsedFile]bool) 70 + var walk func(*ParsedFile) 71 + walk = func(pf *ParsedFile) { 72 + if visited[pf] { 73 + return 74 + } 75 + visited[pf] = true 76 + files = append(files, pf) 77 + for _, inc := range pf.Includes { 78 + walk(inc) 79 + } 80 + } 81 + walk(root) 82 + return files 83 +} 84 + 49 85 // Ordered returns all files in dependency order (included before includer) 50 86 func (l *Loader) Ordered() []*ParsedFile { 51 87 visited := make(map[string]bool)