ajhahn.de
← eeco
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)
}