ollie/fs/log_validator.go

154 lines
4.3 KiB
Go

package fs
import (
"fmt"
"strings"
"sync"
"ollie/format"
)
// block tracks a single open [[[block]]] for nesting validation.
type block struct {
header format.BlockHeader
line int // 1-indexed line number where this block opened
}
// LogValidator enforces mechanical guarantees on the [[[block]]]/[[[end]]] protocol:
// - Every [[[block]]] MUST have an [[[end]]] marker
// - Blocks SHALL NOT be nested
//
// It operates as a streaming state machine, buffering partial lines across calls
// to Validate() so that block headers split across writes are handled correctly.
type LogValidator struct {
mu sync.Mutex
stack []block // stack of currently open blocks (should never exceed depth 1)
buf string // incomplete last line accumulated across calls
errs []violation // accumulated validation errors
fatal bool // once set, all subsequent data is rejected
lineNum int // 1-indexed line counter within buf
}
// violation records a single structural protocol violation.
type violation struct {
line int
msg string
}
func (v violation) Error() string {
return fmt.Sprintf("log structure violation at line %d: %s", v.line, v.msg)
}
// NewLogValidator creates a fresh validator with no open blocks.
func NewLogValidator() *LogValidator {
return &LogValidator{}
}
// Validate processes a chunk of log data through the state machine.
// It buffers incomplete lines across calls so that block markers split
// across writes are handled correctly. Returns nil if valid, or a slice
// of violations if any structural rules are broken.
//
// The state machine recognizes two kinds of lines:
// - Block headers: lines matching "[[[<role>]]]" or "[[[<role>:<name>]]]"
// - End markers: the exact line "[[[end]]]"
//
// Rules enforced:
// 1. A block header while another block is open → "nested block" error
// 2. An end marker with no open block → "unexpected end" error
// 3. At session end, unclosed blocks are reported via UnclosedBlocks()
func (v *LogValidator) Validate(data []byte) []error {
v.mu.Lock()
defer v.mu.Unlock()
if v.fatal {
return []error{fmt.Errorf("validator already failed — rejecting further data")}
}
v.buf += string(data)
lines := strings.Split(v.buf, "\n")
v.buf = lines[len(lines)-1] // keep incomplete trailing content
for i := 0; i < len(lines)-1; i++ {
line := strings.TrimSpace(lines[i])
if line == "" {
continue
}
v.lineNum++
if format.IsEndMarker(line) {
v.handleEndMarker(line)
} else if bh := format.ParseBlockHeader(line); bh != nil {
v.handleBlockHeader(bh, line)
}
// Lines that are neither block headers nor end markers are ignored
// (they're block body content).
}
if v.fatal {
errs := make([]error, len(v.errs))
for i, e := range v.errs {
errs[i] = e
}
return errs
}
return nil
}
func (v *LogValidator) handleEndMarker(line string) {
if len(v.stack) == 0 {
v.errs = append(v.errs, violation{
line: v.lineNum,
msg: fmt.Sprintf("unexpected [%s] with no open block", line),
})
v.fatal = true
return
}
top := v.stack[len(v.stack)-1]
v.stack = v.stack[:len(v.stack)-1]
_ = top // consumed
}
func (v *LogValidator) handleBlockHeader(bh *format.BlockHeader, line string) {
if len(v.stack) > 0 {
outer := v.stack[len(v.stack)-1]
v.errs = append(v.errs, violation{
line: v.lineNum,
msg: fmt.Sprintf("nested block [%s] while [%s:%s] is still open (no nesting allowed)",
line, outer.header.Role, outer.header.Name),
})
v.fatal = true
return
}
v.stack = append(v.stack, block{header: *bh, line: v.lineNum})
}
// UnclosedBlocks returns a list of blocks that were opened but never closed.
// Call this at session/agent teardown to detect missing [[[end]]] markers.
func (v *LogValidator) UnclosedBlocks() []block {
v.mu.Lock()
defer v.mu.Unlock()
out := make([]block, len(v.stack))
copy(out, v.stack)
return out
}
// HasViolations reports whether any violations have been recorded.
func (v *LogValidator) HasViolations() bool {
v.mu.Lock()
defer v.mu.Unlock()
return v.fatal || len(v.errs) > 0
}
// Errors returns all accumulated violations (non-nil only after fatal failure).
func (v *LogValidator) Errors() []error {
v.mu.Lock()
defer v.mu.Unlock()
out := make([]error, len(v.errs))
for i, e := range v.errs {
out[i] = e
}
return out
}