all repos

clerk @ 50bbb41fc735251033f1ce13bc2abca260d0e8ec

missing tooling for ledger/hledger
14 files changed, 68 insertions(+), 26 deletions(-)
linter: add missing transaction status
Author: Oleksandr Smirnov olexsmir@gmail.com
Committed at: 2026-06-22 18:00:13 +0300
Authored at: 2026-06-17 16:50:16 +0300
Change ID: srowyvkmpwwszlnzqmwzvwqkxtyrlnqr
Parent: 3bf8ce6
M internal/linter/linter_test.go
···
        14
        14
         	"parse-error":       {&ParseError{}},

      
        15
        15
         	"omitted-precision": {&OmittedPrecision{}},

      
        16
        16
         	"missing-commodity": {&MissingCommodity{}},

      
        
        17
        +	"missing-status":    {&MissingStatus{}},

      
        17
        18
         }

      
        18
        19
         

      
        19
        20
         func TestLinter(t *testing.T) {

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

      
        
        2
        +

      
        
        3
        +import "olexsmir.xyz/clerk/journal/ast"

      
        
        4
        +

      
        
        5
        +// MissingStatus flags transactions with missing status.

      
        
        6
        +type MissingStatus struct{}

      
        
        7
        +

      
        
        8
        +func (MissingStatus) ID() RuleID          { return "missing-status" }

      
        
        9
        +func (MissingStatus) Severity() Severity  { return SeverityWarning }

      
        
        10
        +func (MissingStatus) Description() string { return "transaction has no status" }

      
        
        11
        +func (m *MissingStatus) CheckEntry(entry ast.Entry) []Find {

      
        
        12
        +	tnx, ok := entry.(*ast.Transaction)

      
        
        13
        +	if !ok {

      
        
        14
        +		return nil

      
        
        15
        +	}

      
        
        16
        +

      
        
        17
        +	if tnx.Status.Value == ast.StatusNone {

      
        
        18
        +		return []Find{{

      
        
        19
        +			Code:     m.ID(),

      
        
        20
        +			Severity: m.Severity(),

      
        
        21
        +			Message:  m.Description(),

      
        
        22
        +			Span:     tnx.Status.Span,

      
        
        23
        +		}}

      
        
        24
        +	}

      
        
        25
        +	return nil

      
        
        26
        +}

      
M internal/linter/rules.go
···
        29
        29
         	&EmptyPostings{},

      
        30
        30
         	&OmittedPrecision{},

      
        31
        31
         	&MissingCommodity{},

      
        
        32
        +	&MissingStatus{},

      
        32
        33
         }

      
M internal/linter/testdata/correct.input
···
        1
        
        -2024-05-01 "groceries"

      
        
        1
        +2024-05-01 * "groceries"

      
        2
        2
             expenses:food   50.00 USD

      
        3
        3
             assets:cash

      
A internal/linter/testdata/missing-status.golden
···
        
        1
        +missing-status.journal:1:12: missing-status: transaction has no status

      
A internal/linter/testdata/missing-status.input
···
        
        1
        +2024-05-01 "groceries"

      
        
        2
        +    expenses:food   50.00 USD

      
        
        3
        +    assets:cash

      
M journal/ast/dump.go
···
        128
        128
         		indent(b, depth+1)

      
        129
        129
         		fmt.Fprintf(b, "SecondDate: %s\n", dumpDate(*t.SecondDate))

      
        130
        130
         	}

      
        131
        
        -	if t.Status != nil {

      
        
        131
        +	if t.Status.Value != StatusNone {

      
        132
        132
         		indent(b, depth+1)

      
        133
        133
         		fmt.Fprintf(b, "State: %q\n", t.Status.Value)

      
        134
        134
         	}

      ···
        188
        188
         		indent(b, depth+1)

      
        189
        189
         		fmt.Fprintf(b, "To: %s\n", dumpDate(*t.Period.To))

      
        190
        190
         	}

      
        191
        
        -	if t.Status != nil {

      
        
        191
        +	if t.Status.Value != StatusNone {

      
        192
        192
         		indent(b, depth+1)

      
        193
        193
         		fmt.Fprintf(b, "Status: %q\n", t.Status.Value)

      
        194
        194
         	}

      ···
        220
        220
         		indent(b, depth+1)

      
        221
        221
         		fmt.Fprintf(b, "Type: %s\n", p.Type)

      
        222
        222
         	}

      
        223
        
        -	if p.Status != nil {

      
        
        223
        +	if p.Status.Value != StatusNone {

      
        224
        224
         		indent(b, depth+1)

      
        225
        225
         		fmt.Fprintf(b, "Status: %q\n", p.Status.Value)

      
        226
        226
         	}

      
M journal/ast/entries.go
···
        11
        11
         

      
        12
        12
         type Transaction struct {

      
        13
        13
         	Date           Date

      
        14
        
        -	SecondDate     *Date      // optional =2026-05-18 date

      
        15
        
        -	Status         *Status    // optional */! status

      
        16
        
        -	Code           *string    // optional (123) code

      
        17
        
        -	Payee          *Payee     // optional payee

      
        18
        
        -	Note           *string    // part after |

      
        
        14
        +	SecondDate     *Date    // optional =2026-05-18 date

      
        
        15
        +	Status         Status   // optional */! status

      
        
        16
        +	Code           *string  // optional (123) code

      
        
        17
        +	Payee          *Payee   // optional payee

      
        
        18
        +	Note           *string  // part after |

      
        19
        19
         	Comment        *Comment   // inline ; on header line

      
        20
        20
         	HeaderComments []*Comment // indented ; lines before first posting

      
        21
        21
         	Postings       []*Posting

      ···
        35
        35
         

      
        36
        36
         type PeriodicTransaction struct {

      
        37
        37
         	Period         Period   // period-expr

      
        38
        
        -	Status         *Status  // optional */! status

      
        
        38
        +	Status         Status   // optional */! status

      
        39
        39
         	Code           *string  // optional (123) code

      
        40
        40
         	Description    *string  // optional description

      
        41
        41
         	Comment        *Comment // optional inline comment

      ···
        79
        79
         

      
        80
        80
         type Posting struct {

      
        81
        81
         	Type     PostingType

      
        82
        
        -	Status   *Status

      
        
        82
        +	Status   Status

      
        83
        83
         	Account  Account

      
        84
        84
         	Amount   *Amount // nil == auto-balancing

      
        85
        85
         	Cost     *Cost   // @ @@

      
M journal/parser/parser.go
···
        752
        752
         	}

      
        753
        753
         }

      
        754
        754
         

      
        755
        
        -func (p *Parser) parseStatus() *ast.Status {

      
        756
        
        -	if p.got(token.STAR) || p.got(token.BANG) {

      
        757
        
        -		status := ast.StatusPending

      
        758
        
        -		if p.cur.Literal[0] == '*' {

      
        759
        
        -			status = ast.StatusCleared

      
        760
        
        -		}

      
        761
        
        -		st := &ast.Status{Value: status, Span: p.cur.Span}

      
        
        755
        +func (p *Parser) parseStatus() ast.Status {

      
        
        756
        +	s := p.cur.Span

      
        
        757
        +	st := ast.Status{}

      
        
        758
        +	switch p.cur.Type {

      
        
        759
        +	case token.STAR:

      
        
        760
        +		p.advance()

      
        
        761
        +		p.skipWhitespace()

      
        
        762
        +		st.Value = ast.StatusCleared

      
        
        763
        +	case token.BANG:

      
        762
        764
         		p.advance()

      
        763
        765
         		p.skipWhitespace()

      
        764
        
        -		return st

      
        
        766
        +		st.Value = ast.StatusPending

      
        
        767
        +	default:

      
        
        768
        +		st.Value = ast.StatusNone

      
        765
        769
         	}

      
        766
        
        -	return nil

      
        
        770
        +	st.Span = p.span(s)

      
        
        771
        +	return st

      
        767
        772
         }

      
        768
        773
         

      
        769
        774
         func (p *Parser) isAmountStart() bool {

      
M journal/parser/testdata/period_transaction_expressions.golden
···
        2
        2
           BlankLine j:2:0-2:1

      
        3
        3
           PeriodicTransaction j:2:1-2:3

      
        4
        4
             Period: "monthly"

      
        
        5
        +    Status: "*"

      
        5
        6
             Posting j:3:1-4:1

      
        6
        7
               Account j:3:5-3:18

      
        7
        8
                 SubAccount: "expenses" j:3:5-3:13

      ···
        24
        25
             Period: "monthly from 2023-04-15 to 2023-06-16"

      
        25
        26
             From: 2023-04-15

      
        26
        27
             To: 2023-06-16

      
        
        28
        +    Status: "*"

      
        27
        29
             Posting j:7:1-8:1

      
        28
        30
               Account j:7:5-7:23

      
        29
        31
                 SubAccount: "expenses" j:7:5-7:13

      ···
        44
        46
           BlankLine j:10:0-10:1

      
        45
        47
           PeriodicTransaction j:10:1-10:3

      
        46
        48
             Period: "every 2 months"

      
        
        49
        +    Status: "*"

      
        47
        50
             Description: "in 2023, we will review"

      
        48
        51
             Posting j:11:1-12:1

      
        49
        52
               Account j:11:5-11:23

      ···
        65
        68
           BlankLine j:14:0-14:1

      
        66
        69
           PeriodicTransaction j:14:1-14:3

      
        67
        70
             Period: "monthly"

      
        
        71
        +    Status: "*"

      
        68
        72
             Description: "Next year blah blah"

      
        69
        73
             Posting j:15:1-16:1

      
        70
        74
               Account j:15:5-15:18

      ···
        85
        89
           BlankLine j:18:0-18:1

      
        86
        90
           PeriodicTransaction j:18:1-18:3

      
        87
        91
             Period: "monthly from 2018/6"

      
        
        92
        +    Status: "*"

      
        88
        93
             Comment j:18:23-19:0

      
        89
        94
               Marker: ";"

      
        90
        95
               Text: "In 2019 we will change this"

      
A journal/parser/testdata/tags.golden

Not showing binary file.

M journal/printer/testdata/entries.golden
···
        17
        17
           Expenses:Food  30.00$

      
        18
        18
           Assets:Cash  -30.00$

      
        19
        19
         

      
        20
        
        -~ monthly

      
        
        20
        +~ monthly *

      
        21
        21
           Expenses:Food  100.00$

      
        22
        22
           Assets:Cash  -100.00$

      
        23
        23
         

      
M journal/printer/transaction.go
···
        15
        15
         	}

      
        16
        16
         

      
        17
        17
         	// status

      
        18
        
        -	if t.Status != nil && t.Status.Value != ast.StatusNone {

      
        
        18
        +	if t.Status.Value != ast.StatusNone {

      
        19
        19
         		p.buf.WriteByte(' ')

      
        20
        20
         		p.buf.WriteString(t.Status.Value.String())

      
        21
        21
         	}

      ···
        63
        63
         	}

      
        64
        64
         

      
        65
        65
         	// status

      
        66
        
        -	if t.Status != nil && t.Status.Value != ast.StatusNone {

      
        
        66
        +	if t.Status.Value != ast.StatusNone {

      
        67
        67
         		p.buf.WriteByte(' ')

      
        68
        68
         		p.buf.WriteString(t.Status.Value.String())

      
        69
        69
         	}

      
M journal/printer/transaction_postings.go
···
        62
        62
         func (p *printer) writePostingLine(pt *ast.Posting, maxAcct int) {

      
        63
        63
         	p.buf.WriteString(p.indent)

      
        64
        64
         

      
        65
        
        -	if pt.Status != nil && pt.Status.Value != ast.StatusNone {

      
        
        65
        +	if pt.Status.Value != ast.StatusNone {

      
        66
        66
         		p.buf.WriteString(pt.Status.Value.String())

      
        67
        67
         		p.buf.WriteByte(' ')

      
        68
        68
         	}

      ···
        82
        82
         			p.buf.WriteString("  ")

      
        83
        83
         		case AlignRight:

      
        84
        84
         			current := len(p.indent) + len(acct)

      
        85
        
        -			if pt.Status != nil && pt.Status.Value != ast.StatusNone {

      
        
        85
        +			if pt.Status.Value != ast.StatusNone {

      
        86
        86
         				current++

      
        87
        87
         			}

      
        88
        88
         			if pad := p.cfg.AlignColumn - current; pad > 0 {

      ···
        91
        91
         			p.buf.WriteByte('\v')

      
        92
        92
         		case AlignTab:

      
        93
        93
         			current := len(p.indent) + len(acct)

      
        94
        
        -			if pt.Status != nil && pt.Status.Value != ast.StatusNone {

      
        
        94
        +			if pt.Status.Value != ast.StatusNone {

      
        95
        95
         				current++

      
        96
        96
         			}

      
        97
        97
         			if pad := maxAcct + len(p.indent) - current; pad > 0 {