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) }