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
}