ajhahn.de
← eeco
Go 167 lines
package cockpit

import (
	"fmt"
	"sort"
	"strings"

	"github.com/ajhahnde/eeco/internal/config"
)

// SyncReport is the result of a read-only drift scan over the whole emitted
// cockpit. Clean is true exactly when Findings is empty.
type SyncReport struct {
	Findings []SyncFinding
	Clean    bool
}

// SyncFinding is one stale-artifact finding. Kind is "drifted" (on-disk
// bytes no longer match a fresh render — a hand-edit or an eeco upgrade that
// changed the neutral source), "missing" (an active target/playbook was
// never emitted), "orphan" (a deselected target's artifact remains), or
// "safety" (the on-disk allowlist now grants a forbidden write-git verb).
// Playbook is empty for aggregate and orphan findings, which key on the
// target alone. Detail is the human line, reused verbatim from the
// underlying VerifyResult where one exists.
type SyncFinding struct {
	Target   string
	Playbook string
	Kind     string
	Detail   string
}

// IsGenerated reports whether the cockpit has been generated in this workspace
// — the ledger carries at least one installed artifact record. It is the cheap
// "is the cockpit in use here" gate (the same empty-ledger signal Sync uses),
// exported for callers outside the package such as the session-start doc-drift
// nudge's time backstop, which must stay silent where the cockpit was never
// generated.
func IsGenerated(cfg *config.Config) bool {
	l, err := loadLedger(cfg)
	if err != nil {
		return false
	}
	return l.hasInstalled()
}

// Sync is the one read-only drift engine behind both `eeco cockpit verify`
// (no scoping flags) and the cockpit-sync builtin. It never writes anything.
//
// The load-bearing gate is the ledger: with zero installed records the
// cockpit was never generated here (init writes a default selection but
// never the ledger), so Sync returns a silent clean — that is what keeps the
// post-merge builtin a no-op on a repo that does not use the cockpit. With
// at least one installed record it verifies every active target against a
// fresh render and scans the ledger for orphaned (deselected) targets,
// deduped by target so a per-playbook target's K records collapse to one
// finding.
func Sync(cfg *config.Config, all []Playbook) (SyncReport, error) {
	l, err := loadLedger(cfg)
	if err != nil {
		return SyncReport{}, err
	}
	if !l.hasInstalled() {
		return SyncReport{Clean: true}, nil
	}

	sel := LoadSelection(cfg)
	resolved := resolvePlaybookSet(all, sel.Playbooks)

	var findings []SyncFinding
	for _, tg := range sel.Targets {
		if IsAggregateTarget(tg) {
			// Aggregate targets emit one shared file for the whole set, so
			// verify the same resolved set generate emitted (a narrowed
			// selection must verify its narrowed bytes, not all).
			res, verr := VerifyAll(cfg, resolved, tg)
			if verr != nil {
				return SyncReport{}, verr
			}
			if !res.Clean {
				findings = append(findings, SyncFinding{
					Target: tg, Kind: classifySync(res.Detail), Detail: res.Detail,
				})
			}
			continue
		}
		for _, pb := range resolved {
			res, verr := Verify(cfg, pb, tg, "")
			if verr != nil {
				return SyncReport{}, verr
			}
			if !res.Clean {
				findings = append(findings, SyncFinding{
					Target: tg, Playbook: pb.Name, Kind: classifySync(res.Detail), Detail: res.Detail,
				})
			}
		}
	}

	findings = append(findings, orphanFindings(l, sel.Targets)...)
	return SyncReport{Findings: findings, Clean: len(findings) == 0}, nil
}

// resolvePlaybookSet returns the playbook subset a selection emits: every
// playbook in all when the selection does not narrow them (the C3 default),
// otherwise the members of all whose names appear in narrow. Filtering all
// (already Name-ordered) keeps the result deterministic and an unknown name
// in narrow is simply skipped (LoadSelection never stores one).
func resolvePlaybookSet(all []Playbook, narrow []string) []Playbook {
	if len(narrow) == 0 {
		return all
	}
	want := make(map[string]bool, len(narrow))
	for _, n := range narrow {
		want[n] = true
	}
	out := make([]Playbook, 0, len(narrow))
	for _, pb := range all {
		if want[pb.Name] {
			out = append(out, pb)
		}
	}
	return out
}

// classifySync coarsely buckets a not-clean VerifyResult.Detail into a Sync
// Kind. The phrases are the stable ones emit.go/emit_aggregate.go produce.
func classifySync(detail string) string {
	switch {
	case strings.Contains(detail, "SAFETY VIOLATION"):
		return "safety"
	case strings.Contains(detail, "not emitted"):
		return "missing"
	default:
		return "drifted"
	}
}

// orphanFindings returns one finding per ledger target that is still
// installed but no longer in the active set, deduped by target and sorted
// for deterministic output.
func orphanFindings(l ledger, active []string) []SyncFinding {
	activeSet := make(map[string]bool, len(active))
	for _, t := range active {
		activeSet[t] = true
	}
	seen := make(map[string]bool)
	var orphans []string
	for _, rec := range l.Records {
		if !rec.Installed || activeSet[rec.Target] || seen[rec.Target] {
			continue
		}
		seen[rec.Target] = true
		orphans = append(orphans, rec.Target)
	}
	sort.Strings(orphans)
	out := make([]SyncFinding, 0, len(orphans))
	for _, t := range orphans {
		out = append(out, SyncFinding{
			Target: t,
			Kind:   "orphan",
			Detail: fmt.Sprintf("%s: deselected but artifact remains — run `eeco cockpit off --target %s`", t, t),
		})
	}
	return out
}