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 }