Go 159 lines
package workflow
import (
"fmt"
"os"
"path/filepath"
"sort"
"time"
"github.com/ajhahnde/eeco/internal/gitx"
"github.com/ajhahnde/eeco/internal/memory"
"github.com/ajhahnde/eeco/internal/queue"
)
// memoryDrift flags memory facts whose `ref:` file has changed since the
// fact was written. A fact may carry a `ref:` to the file it documents
// and a `created:` date; when that file has been committed on a later
// calendar day than the fact was authored, the fact may now describe
// stale code. `eeco gc` already catches a `ref:` that no longer exists
// on disk — this workflow catches the complementary case: the file is
// still there but has moved on. Drift is reported and one review item
// per stale fact is routed to the queue (the single decision channel);
// the operator reconciles the fact, eeco never edits it.
type memoryDrift struct{}
func (memoryDrift) Name() string { return "memory-drift" }
func (memoryDrift) Summary() string {
return "flag memory facts whose ref file changed since the fact was written"
}
// memoryDriftCommitDate resolves the last-commit date of a repo-relative
// path. It is overridable in tests; it defaults to gitx.LastCommitDate.
var memoryDriftCommitDate = gitx.LastCommitDate
// staleFact is one memory fact whose ref file outran it, carried from
// the detection loop to the queue-append loop.
type staleFact struct {
name string
ref string
created time.Time
changed time.Time
}
func (memoryDrift) Run(env Env) (Result, error) {
cfg := env.Config
// The whole check is a comparison against git commit history, so a
// host without git cannot run it — report blocked (contract code 2)
// rather than passing a check that never actually ran.
if !gitx.Available() {
return Result{Code: CodeBlocked, Summary: "git not available on PATH"}, nil
}
store, err := memory.Open(cfg)
if err != nil {
return Result{}, fmt.Errorf("memory-drift: %w", err)
}
facts, err := store.LoadAll()
if err != nil {
return Result{}, fmt.Errorf("memory-drift: %w", err)
}
var (
findings []Finding
stale []staleFact
checked int
)
for _, f := range facts {
if f.Ref == "" {
continue
}
abs := filepath.Join(cfg.RepoRoot, filepath.FromSlash(f.Ref))
if _, serr := os.Stat(abs); serr != nil {
// A `ref:` that is missing on disk is `eeco gc`'s job, not
// this workflow's — skip it rather than double-report.
continue
}
commit, ok, derr := memoryDriftCommitDate(cfg.RepoRoot, f.Ref)
if derr != nil {
return Result{}, fmt.Errorf("memory-drift: %s: %w", f.Ref, derr)
}
if !ok {
// The ref file exists but has no commit history (untracked,
// or never committed) — there is no commit date to age the
// fact against, so skip it.
continue
}
checked++
createdDay := utcDay(f.Created)
changedDay := utcDay(commit)
if changedDay.After(createdDay) {
findings = append(findings, Finding{
Path: f.Ref,
Line: 0,
Msg: fmt.Sprintf("fact %q written %s; %s last changed %s",
f.Name, createdDay.Format(memory.DateLayout),
f.Ref, changedDay.Format(memory.DateLayout)),
})
stale = append(stale, staleFact{
name: f.Name, ref: f.Ref, created: createdDay, changed: changedDay,
})
}
}
if len(findings) == 0 {
if checked == 0 {
return Result{Code: CodeClean, Summary: "no memory facts carry a ref to check"}, nil
}
return Result{
Code: CodeClean,
Summary: fmt.Sprintf("%d memory fact(s) with a ref are current", checked),
}, nil
}
sort.Slice(findings, func(i, j int) bool {
if findings[i].Path != findings[j].Path {
return findings[i].Path < findings[j].Path
}
return findings[i].Msg < findings[j].Msg
})
// Route one review item per stale fact to the queue — eeco flags the
// drift, the operator reconciles the fact against the current file.
project := filepath.Base(cfg.RepoRoot)
stateDir := filepath.Join(cfg.Workspace, "state")
today := time.Now().UTC()
for _, s := range stale {
item := queue.Item{
Kind: "memory-drift",
Title: fmt.Sprintf("memory %q may be stale: %s changed since the fact was written", s.name, s.ref),
Project: project,
Detail: fmt.Sprintf("fact written %s; %s last changed %s — review the fact against the current file",
s.created.Format(memory.DateLayout), s.ref, s.changed.Format(memory.DateLayout)),
Date: today,
}
// AppendUnique so a repeated run (for example the post-merge hook)
// does not pile up duplicate items for a finding still open in the
// queue; the finding itself is still real and reported below.
if _, err := queue.AppendUnique(stateDir, item); err != nil {
return Result{}, fmt.Errorf("memory-drift: queue: %w", err)
}
}
return Result{
Code: CodeFinding,
Summary: fmt.Sprintf("%d memory fact(s) may be stale (ref changed since the fact was written)", len(findings)),
Findings: findings,
}, nil
}
// utcDay truncates t to its UTC calendar day, so a fact's `created:`
// date and a commit's timestamp compare on the same footing regardless
// of the commit's original time zone.
func utcDay(t time.Time) time.Time {
u := t.UTC()
return time.Date(u.Year(), u.Month(), u.Day(), 0, 0, 0, 0, time.UTC)
}