feat(formula): add checksum-based auto-update for embedded formulas

Adds infrastructure to automatically update embedded formulas when
the binary is upgraded, while preserving user customizations.

Changes:
- Add CheckFormulaHealth() to detect outdated/modified/missing formulas
- Add UpdateFormulas() to safely update formulas via gt doctor --fix
- Track installed formula checksums in .beads/formulas/.installed.json
- Add FormulaCheck to gt doctor with auto-fix capability
- Compute checksums at runtime from embedded files (no build-time manifest)

Update scenarios:
- Outdated (embedded changed, user unchanged): Update automatically
- Modified (user customized): Skip with warning
- Missing (user deleted): Reinstall with message
- New (never installed): Install

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
julianknutsen
2026-01-07 22:52:55 -08:00
committed by Steve Yegge
parent e124402b7b
commit da2d71c3fe
5 changed files with 1028 additions and 4 deletions
+123
View File
@@ -0,0 +1,123 @@
package doctor
import (
"fmt"
"strings"
"github.com/steveyegge/gastown/internal/formula"
)
// FormulaCheck verifies that embedded formulas are up-to-date.
// It detects outdated formulas (binary updated), missing formulas (user deleted),
// and modified formulas (user customized). Can auto-fix outdated and missing.
type FormulaCheck struct {
FixableCheck
}
// NewFormulaCheck creates a new formula check.
func NewFormulaCheck() *FormulaCheck {
return &FormulaCheck{
FixableCheck: FixableCheck{
BaseCheck: BaseCheck{
CheckName: "formulas",
CheckDescription: "Check embedded formulas are up-to-date",
},
},
}
}
// Run checks if formulas need updating.
func (c *FormulaCheck) Run(ctx *CheckContext) *CheckResult {
report, err := formula.CheckFormulaHealth(ctx.TownRoot)
if err != nil {
return &CheckResult{
Name: c.Name(),
Status: StatusWarning,
Message: fmt.Sprintf("Could not check formulas: %v", err),
}
}
// All good
if report.Outdated == 0 && report.Missing == 0 && report.Modified == 0 && report.New == 0 {
return &CheckResult{
Name: c.Name(),
Status: StatusOK,
Message: fmt.Sprintf("%d formulas up-to-date", report.OK),
}
}
// Build details
var details []string
var needsFix bool
for _, f := range report.Formulas {
switch f.Status {
case "outdated":
details = append(details, fmt.Sprintf(" %s: update available", f.Name))
needsFix = true
case "missing":
details = append(details, fmt.Sprintf(" %s: missing (will reinstall)", f.Name))
needsFix = true
case "modified":
details = append(details, fmt.Sprintf(" %s: locally modified (skipping)", f.Name))
case "new":
details = append(details, fmt.Sprintf(" %s: new formula available", f.Name))
needsFix = true
}
}
// Determine status
status := StatusOK
if needsFix {
status = StatusWarning
}
// Build message
var parts []string
if report.Outdated > 0 {
parts = append(parts, fmt.Sprintf("%d outdated", report.Outdated))
}
if report.Missing > 0 {
parts = append(parts, fmt.Sprintf("%d missing", report.Missing))
}
if report.New > 0 {
parts = append(parts, fmt.Sprintf("%d new", report.New))
}
if report.Modified > 0 {
parts = append(parts, fmt.Sprintf("%d modified", report.Modified))
}
message := fmt.Sprintf("Formulas: %s", strings.Join(parts, ", "))
result := &CheckResult{
Name: c.Name(),
Status: status,
Message: message,
Details: details,
}
if needsFix {
result.FixHint = "Run 'gt doctor --fix' to update formulas"
}
return result
}
// Fix updates outdated and missing formulas.
func (c *FormulaCheck) Fix(ctx *CheckContext) error {
updated, skipped, reinstalled, err := formula.UpdateFormulas(ctx.TownRoot)
if err != nil {
return err
}
// Log what was done (caller will re-run check to show new status)
if updated > 0 || reinstalled > 0 || skipped > 0 {
// The doctor framework will re-run the check after fix
// so we don't need to log here
_ = updated
_ = reinstalled
_ = skipped
}
return nil
}
+103
View File
@@ -0,0 +1,103 @@
package doctor
import (
"os"
"path/filepath"
"testing"
"github.com/steveyegge/gastown/internal/formula"
)
func TestNewFormulaCheck(t *testing.T) {
check := NewFormulaCheck()
if check.Name() != "formulas" {
t.Errorf("Name() = %q, want %q", check.Name(), "formulas")
}
if !check.CanFix() {
t.Error("FormulaCheck should be fixable")
}
}
func TestFormulaCheck_Run_AllOK(t *testing.T) {
tmpDir := t.TempDir()
// Provision formulas fresh
_, err := formula.ProvisionFormulas(tmpDir)
if err != nil {
t.Fatalf("ProvisionFormulas() error: %v", err)
}
check := NewFormulaCheck()
ctx := &CheckContext{TownRoot: tmpDir}
result := check.Run(ctx)
if result.Status != StatusOK {
t.Errorf("Status = %v, want %v", result.Status, StatusOK)
}
}
func TestFormulaCheck_Run_Missing(t *testing.T) {
tmpDir := t.TempDir()
// Provision formulas
_, err := formula.ProvisionFormulas(tmpDir)
if err != nil {
t.Fatalf("ProvisionFormulas() error: %v", err)
}
// Delete a formula
formulasDir := filepath.Join(tmpDir, ".beads", "formulas")
formulaPath := filepath.Join(formulasDir, "mol-deacon-patrol.formula.toml")
if err := os.Remove(formulaPath); err != nil {
t.Fatal(err)
}
check := NewFormulaCheck()
ctx := &CheckContext{TownRoot: tmpDir}
result := check.Run(ctx)
if result.Status != StatusWarning {
t.Errorf("Status = %v, want %v", result.Status, StatusWarning)
}
if result.FixHint == "" {
t.Error("should have FixHint")
}
}
func TestFormulaCheck_Fix(t *testing.T) {
tmpDir := t.TempDir()
// Provision formulas
_, err := formula.ProvisionFormulas(tmpDir)
if err != nil {
t.Fatalf("ProvisionFormulas() error: %v", err)
}
// Delete a formula
formulasDir := filepath.Join(tmpDir, ".beads", "formulas")
formulaPath := filepath.Join(formulasDir, "mol-deacon-patrol.formula.toml")
if err := os.Remove(formulaPath); err != nil {
t.Fatal(err)
}
check := NewFormulaCheck()
ctx := &CheckContext{TownRoot: tmpDir}
// Run fix
if err := check.Fix(ctx); err != nil {
t.Fatalf("Fix() error: %v", err)
}
// Verify formula was restored
if _, err := os.Stat(formulaPath); os.IsNotExist(err) {
t.Error("formula should have been restored")
}
// Re-run check - should be OK now
result := check.Run(ctx)
if result.Status != StatusOK {
t.Errorf("after fix, Status = %v, want %v", result.Status, StatusOK)
}
}