diff --git a/README.md b/README.md index 6f5adf6..1c0375e 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,16 @@ currency: box/arch: box/arch context: [runtime] +# `stale_after` is OPT-IN and bounds NEGLECT, not churn. A pin that is not current +# AND that this repo has not advanced for LONGER than the bound is STALE, and a +# stale pin FAILS. The bound is on the AGE of this repo's last advance of that +# path — never on the pin's distance from the served ref: a busy upstream is behind +# between every wave, so a gate that failed on the distance would be permanently +# red on a healthy tree, which trains every reader to ignore it. Leave it unset and +# the verdict is the structural one alone. +# +# git-submodules: {mode: currency, stale_after: "168h"} + # Assert the harness config files are identical to charly's twins. harness: task: diff --git a/candy/plugin-task/currency_test.go b/candy/plugin-task/currency_test.go index 2b6e5f2..69bfc08 100644 --- a/candy/plugin-task/currency_test.go +++ b/candy/plugin-task/currency_test.go @@ -6,6 +6,7 @@ import ( "path/filepath" "strings" "testing" + "time" "github.com/opencharly/spec/spec" ) @@ -400,3 +401,114 @@ func TestGitSubmodules_CurrencyRefusesDepthForBOTHEdgeShapes(t *testing.T) { }) } } + +// curStale builds the fixture the staleness arms need: a superproject whose `src` pin is +// either behind its upstream (`behind`) or at it (`current`), whose `d` pin is current, and +// whose LAST commit — the one that touched the pins — is dated `ago` in the past. +// +// The date is the fixture's whole point: the age of this repo's own bookkeeping for a pin +// is read from that commit, so the same tree is the healthy regime or the neglect regime +// depending only on when it was last advanced. +func curStale(t *testing.T, tmp, name string, behind bool, ago time.Duration) (dir, c1, c2 string) { + t.Helper() + dir, run, c1, c2, _ := curSuper(t, filepath.Join(tmp, name)) + curRecord(t, dir, "d", c2) // current: isolates the arm under test + if behind { + curRecord(t, dir, "src", c1) + } else { + curRecord(t, dir, "src", c2) + } + stamp := time.Now().Add(-ago).UTC().Format(time.RFC3339) + t.Setenv("GIT_AUTHOR_DATE", stamp) + t.Setenv("GIT_COMMITTER_DATE", stamp) + run("commit", "-qm", "record the pins") + return dir, c1, c2 +} + +// TestGitSubmodules_CurrencyStalenessBoundsNeglect proves the control that the recurring +// stale twin needed — a bound on NEGLECT, not on churn. +// +// The measurement behind it: a twin whose upstream moves daily is behind its served ref +// roughly 75% of wall-clock time, with continuous behind-windows of 8 and 10 days. A gate +// that failed on the DISTANCE would therefore be red on a healthy tree, which trains every +// reader to ignore it. What actually recurred was charly's own ADVANCE CADENCE: gaps of +// 8 and 10 days with no advance at all, against a healthy maximum of 5 days. +// +// So the bound is on the AGE of this repo's last advance of a pin, and the arms below are +// a one-variable differential: the same tree, the same pin, the same bound — only the date +// of the last advance differs, and the verdict flips with it. +func TestGitSubmodules_CurrencyStalenessBoundsNeglect(t *testing.T) { + tmp := t.TempDir() + bound := "168h" + + // Arm 1 — the tree as it is, with the knob UNSET: the verdict is the structural one + // alone, exactly as before the knob existed. The opt-in is real, and a repo that has + // not declared a bound sees no change at all. + oldDir, _, _ := curStale(t, tmp, "old", true, 240*time.Hour) + st, msg := runMaintenanceVerbIn(oldDir, "git-submodules", map[string]any{"mode": "currency"}) + if st != spec.StatusPass { + t.Fatalf("without a declared bound the verdict must be unchanged; got %s: %s", st, msg) + } + if !strings.Contains(msg, "1 of 2 pin(s) NOT current") || strings.Contains(msg, "STALE") { + t.Fatalf("an undeclared bound must not mention staleness; got:\n%s", msg) + } + + // Arm 2 — the same tree WITH the bound: the pin is behind AND this repo has not + // advanced it for 240h, which is past the 168h bound, so it is STALE and FAILS. + st, msg = runMaintenanceVerbIn(oldDir, "git-submodules", map[string]any{ + "mode": "currency", "stale_after": bound, + }) + if st != spec.StatusFail { + t.Fatalf("a pin neglected for longer than the bound must fail; got %s: %s", st, msg) + } + for _, want := range []string{ + "1 STALE pin(s) (neglected for more than 168h)", + "src: behind, and this repo has not advanced it for 240h", + "(bound 168h)", + "FAILED on the STALE pin(s) above", + } { + if !strings.Contains(msg, want) { + t.Fatalf("the stale verdict must name the pin, its age and the bound; missing %q in:\n%s", want, msg) + } + } + + // Arm 3 — THE DIFFERENTIAL. The same tree, the same behind pin, the same bound, and the + // ONLY difference is when this repo last advanced it: an advance a minute ago is + // CHURN-IN-PROGRESS, not neglect, so the run PASSES. A control that failed here would + // be red on a healthy tree, which is the failure this knob exists to avoid. + freshDir, _, _ := curStale(t, tmp, "fresh", true, time.Minute) + st, msg = runMaintenanceVerbIn(freshDir, "git-submodules", map[string]any{ + "mode": "currency", "stale_after": bound, + }) + if st != spec.StatusPass { + t.Fatalf("a pin advanced a minute ago is churn, not neglect; got %s: %s", st, msg) + } + if strings.Contains(msg, "STALE") { + t.Fatalf("a recently advanced pin must not be stale; got:\n%s", msg) + } + if !strings.Contains(msg, "1 of 2 pin(s) NOT current") { + t.Fatalf("the not-current count must still be reported; got:\n%s", msg) + } + + // Arm 4 — the IDLE-UPSTREAM trap: a pin that is CURRENT but was advanced a long time + // ago. An upstream that has not moved is not neglected, and the bound must not fire on + // it — otherwise every quiet repo in the org goes red on its own silence. + idleDir, _, _ := curStale(t, tmp, "idle", false, 240*time.Hour) + st, msg = runMaintenanceVerbIn(idleDir, "git-submodules", map[string]any{ + "mode": "currency", "stale_after": bound, + }) + if st != spec.StatusPass { + t.Fatalf("a current pin is never stale, however old its last advance; got %s: %s", st, msg) + } + if strings.Contains(msg, "STALE") { + t.Fatalf("a current pin must not be reported stale; got:\n%s", msg) + } + + // Arm 5 — a malformed bound is a loud failure, never a silently disabled control. + st, msg = runMaintenanceVerbIn(oldDir, "git-submodules", map[string]any{ + "mode": "currency", "stale_after": "7d", + }) + if st != spec.StatusFail || !strings.Contains(msg, "not a duration") { + t.Fatalf("a malformed bound must fail loud; got %s: %s", st, msg) + } +} diff --git a/candy/plugin-task/params/cue_types_gen.go b/candy/plugin-task/params/cue_types_gen.go index 865f3e2..51251b2 100644 --- a/candy/plugin-task/params/cue_types_gen.go +++ b/candy/plugin-task/params/cue_types_gen.go @@ -46,6 +46,19 @@ type GitSubmodulesInput struct { // skip lists submodule paths the verb must not touch. Skip []string `yaml:"skip,omitempty" json:"skip,omitempty"` + + // stale_after is the OWNER's bound on its OWN bookkeeping, and it is opt-in: unset + // => the knob is off and the currency verdict is the structural one alone. + // + // It is a duration ("168h"). A pin that is not current AND that this repo has not + // advanced for LONGER than this bound is STALE, and a stale pin FAILS. + // + // The bound is on the AGE of this repo's last advance of that path — NOT on the + // pin's distance from the ref it names. The distance is CHURN: a busy upstream is + // behind between every wave, so a gate that failed on the distance would be + // permanently red on a healthy tree, and a permanently red gate trains every reader + // to ignore it. The age is NEGLECT, and neglect is the defect this bounds. + StaleAfter string `yaml:"stale_after,omitempty" json:"stale_after,omitempty"` } // #FileParityInput asserts (or syncs) that paired files are byte-identical. diff --git a/candy/plugin-task/schema/task.cue b/candy/plugin-task/schema/task.cue index 03e5d3b..d7bd8fc 100644 --- a/candy/plugin-task/schema/task.cue +++ b/candy/plugin-task/schema/task.cue @@ -55,6 +55,18 @@ intent?: {[string]: string} @go(Intent) // skip lists submodule paths the verb must not touch. skip?: [...string] + // stale_after is the OWNER's bound on its OWN bookkeeping, and it is opt-in: unset + // => the knob is off and the currency verdict is the structural one alone. + // + // It is a duration ("168h"). A pin that is not current AND that this repo has not + // advanced for LONGER than this bound is STALE, and a stale pin FAILS. + // + // The bound is on the AGE of this repo's last advance of that path — NOT on the + // pin's distance from the ref it names. The distance is CHURN: a busy upstream is + // behind between every wave, so a gate that failed on the distance would be + // permanently red on a healthy tree, and a permanently red gate trains every reader + // to ignore it. The age is NEGLECT, and neglect is the defect this bounds. + stale_after?: string @go(StaleAfter) } // #FileParityInput asserts (or syncs) that paired files are byte-identical. diff --git a/candy/plugin-task/verbs.go b/candy/plugin-task/verbs.go index 8035ee3..5833bff 100644 --- a/candy/plugin-task/verbs.go +++ b/candy/plugin-task/verbs.go @@ -10,7 +10,9 @@ import ( "path/filepath" "regexp" "sort" + "strconv" "strings" + "time" "github.com/opencharly/plugin-task/candy/plugin-task/params" pb "github.com/opencharly/spec/proto" @@ -859,6 +861,25 @@ func runGitSubmodules(projDir string, in params.GitSubmodulesInput) (spec.Status // on them would be permanently red — which trains every reader to ignore it, the // mirror problem inverted. So the Message LEADS with the number of pins that are // not current, and a green run can never read as "everything is fine". + // + // The owner's own bound on its OWN bookkeeping, opt-in (`stale_after`). It answers the + // question the currency report cannot: a pin that is not current is CHURN (a busy + // upstream is behind between every wave), but a pin that is not current AND that this + // repo has not advanced for longer than the bound is NEGLECT. The bound is on the AGE + // of this repo's last advance of that path — NOT on the pin's distance from the ref it + // names. A gate on the DISTANCE would be permanently red on a healthy tree, which + // trains every reader to ignore it; the AGE is what neglect looks like, and it is + // what a healthy tree keeps small. + var staleAfter time.Duration + staleBoundSet := false + if raw := strings.TrimSpace(in.StaleAfter); raw != "" { + d, err := time.ParseDuration(raw) + if err != nil { + return spec.StatusFail, fmt.Sprintf( + "git-submodules: stale_after %q is not a duration this verb can parse (e.g. \"168h\")", raw) + } + staleAfter, staleBoundSet = d, true + } type pin struct { path, intent, recorded, served, which, state string } @@ -935,6 +956,56 @@ func runGitSubmodules(projDir string, in params.GitSubmodulesInput) (spec.Status return strings.TrimSpace(ref) } + // durText renders a bound or an age in the OWNER's own units: whole hours as "168h", + // so the report echoes the bound the owner wrote rather than a Go duration literal. + durText := func(d time.Duration) string { + if d%time.Hour == 0 { + return fmt.Sprintf("%dh", int(d/time.Hour)) + } + return d.String() + } + + // staleEligible names the states that mean "THIS repo's own pin does not hold the + // value its intent names" — the only states whose neglect is THIS repo's business. + // An EDGE that is `blocked-by` is NOT eligible: the producer's neglect is the + // producer's business, and failing here over another repo's PR would be exactly the + // permanently-red gate the org's pin rule forbids. + staleEligible := func(state string) bool { + switch { + case state == "behind", state == "not-on-the-served-line": + return true + case strings.HasPrefix(state, "differs"): + // The recorded value differs from the served one and no checkout was + // available to classify the difference. It is still NOT CURRENT. + return true + } + return false + } + + // lastAdvancedAt reads the AGE of this repo's OWN bookkeeping for a pin: the + // timestamp of the last commit that touched the pin's path. This is the owner's own + // record of when it last advanced the pin — never the pin's distance from the served + // ref, which is churn. An ABSENT record (the path is in no commit of this repo's + // history) is reported as "never advanced here" rather than as a fresh pin, because + // a path with no bookkeeping has not been advanced for as long as the repo has + // existed. + lastAdvancedAt := func(p string) (time.Time, bool) { + out, _, exit := hostCapture(context.Background(), projDir, + fmt.Sprintf("git log -1 --format=%%ct -- %s", shellQuote(p))) + if exit != 0 { + return time.Time{}, false + } + ts := strings.TrimSpace(out) + if ts == "" { + return time.Time{}, false + } + secs, err := strconv.ParseInt(ts, 10, 64) + if err != nil { + return time.Time{}, false + } + return time.Unix(secs, 0), true + } + // THE GRAPH, classified once for EVERY path, then the depth rule walked over EVERY // edge source. This replaces a check that read `pinned_from` and therefore guarded one // of the two documented edge shapes: with `pin_map: {d: inner}` and @@ -1137,6 +1208,33 @@ func runGitSubmodules(projDir string, in params.GitSubmodulesInput) (spec.Status // guard is what retired it. Recording it anyway is the write-only state R2 deletes. } + // The NEGLECT judgement (opt-in). A pin that is not current AND that this repo has + // not advanced for longer than the owner's bound is STALE, and it FAILS. The bound + // is on this repo's OWN bookkeeping age, so a healthy tree stays green while a + // neglected one goes red — the two regimes the bound is calibrated against. + var stale []string + if staleBoundSet { + now := time.Now() + for _, r := range rows { + if !r.leaf || !staleEligible(r.state) { + continue + } + at, known := lastAdvancedAt(r.path) + if !known { + stale = append(stale, fmt.Sprintf( + "%s: %s, and this repo's own history records no advance of it — it has never been advanced here", + r.path, r.state)) + continue + } + age := now.Sub(at) + if age > staleAfter { + stale = append(stale, fmt.Sprintf( + "%s: %s, and this repo has not advanced it for %s (bound %s) — advance it: mode=bump advances every pin to the ref its intent names", + r.path, r.state, durText(age.Round(time.Hour)), durText(staleAfter))) + } + } + } + // Render: the count of pins that are NOT current leads, because a green run must // never be readable as "everything is fine". byState := map[string]int{} @@ -1209,11 +1307,19 @@ func runGitSubmodules(projDir string, in params.GitSubmodulesInput) (spec.Status if len(detail) > 0 { head += "; NOT CURRENT: " + strings.Join(detail, "; ") } + if len(stale) > 0 { + head += fmt.Sprintf("; %d STALE pin(s) (neglected for more than %s): %s", + len(stale), durText(staleAfter), strings.Join(stale, "; ")) + } if len(structural) > 0 { sort.Strings(structural) return spec.StatusFail, fmt.Sprintf("%s; %d STRUCTURAL failure(s): %s; producer-first legs: %s", head, len(structural), strings.Join(structural, "; "), legText) } + if len(stale) > 0 { + return spec.StatusFail, fmt.Sprintf("%s; FAILED on the STALE pin(s) above; producer-first legs: %s", + head, legText) + } return spec.StatusPass, fmt.Sprintf("%s; producer-first legs: %s", head, legText) default: diff --git a/scripts/gate-task.sh b/scripts/gate-task.sh index c63935d..3c40ef4 100755 --- a/scripts/gate-task.sh +++ b/scripts/gate-task.sh @@ -507,6 +507,71 @@ fi echo "$exp_out" | grep -q 'graph depth > 1' || { echo "FAIL: the refusal must name the depth; got: $exp_out" >&2; exit 1; } echo "$exp_out" | grep -q 'which is ITSELF an edge' || { echo "FAIL: the refusal must name the source's shape; got: $exp_out" >&2; exit 1; } +# --------------------------------------------------------------------------- +# 2b) verb:git-submodules currency — the STALENESS bound. +# +# The bound is on THIS repo's OWN advance cadence — the AGE of the last commit +# that touched the pin — and never on the pin's distance from the served ref. +# MEASURED on charly's own six box/* twins: each is behind its served ref about +# 75% of wall-clock time, with continuous behind-windows of 8 and 10 days, while +# the healthy maximum gap between advances was 5 days. A gate on the DISTANCE +# would therefore be red on a healthy tree, which trains every reader to ignore +# it. The AGE is what neglect looks like. +# +# The two fixtures below are the SAME tree with the SAME behind pin and the SAME +# bound; they differ ONLY in the date of the commit that recorded the pin, and +# the verdict flips with it. That difference is the whole point of the knob. +# --------------------------------------------------------------------------- +. "$FIX/shas" +stale_fixture() { + local dir="$1" stamp="$2" + mkdir -p "$dir" + ( + cd "$dir" && git init -q -b main + echo u > u.txt && git add -A && G commit -qm u + printf '[submodule "gap"]\n\tpath = gap\n\turl = %s\n\tbranch = main\n' "$FIX/subsrc" > .gitmodules + git add .gitmodules && G commit -qm gm + git update-index --add --cacheinfo "160000,$C1,gap" + GIT_AUTHOR_DATE="$stamp" GIT_COMMITTER_DATE="$stamp" G commit -qm "pin gap" + # Materialize the checkout so the pin is judged against the served ref (behind), + # not merely "differs (no checkout to judge ancestry)". + git -c protocol.file.allow=always submodule update --init -q gap + ) + cat > "$dir/charly.yml" <<'YML' +currency-stale: + task: + description: ask whether every pin is current, with a bound on this repo's own neglect + dir: . + plan: + - check: no pin is neglected past the bound + git-submodules: {mode: currency, stale_after: "168h"} + context: [runtime] +YML +} + +echo "== verb:git-submodules currency (stale_after: a pin NEGLECTED past the bound must FAIL) ==" +stale_fixture "$WORK/stale-old" "2020-01-01T00:00:00Z" +if stale_out="$(cd "$WORK/stale-old" && "$CH" task currency-stale 2>&1)"; then + echo "FAIL: a pin neglected for years must fail the bound" >&2; exit 1 +fi +printf '%s\n' "$stale_out" | grep -q 'STALE pin(s)' || { + echo "FAIL: the verdict must name the stale pin(s); got: $stale_out" >&2; exit 1; } +printf '%s\n' "$stale_out" | grep -q 'has not advanced it for' || { + echo "FAIL: the verdict must name the age; got: $stale_out" >&2; exit 1; } +printf '%s\n' "$stale_out" | grep -q '(bound 168h)' || { + echo "FAIL: the verdict must name the bound; got: $stale_out" >&2; exit 1; } +printf '%s\n' "$stale_out" | sed 's/^/ | /' + +echo "== verb:git-submodules currency (stale_after: the SAME pin, advanced recently, must PASS) ==" +stale_fixture "$WORK/stale-fresh" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" +fresh_out="$(cd "$WORK/stale-fresh" && "$CH" task currency-stale 2>&1)" || { + echo "FAIL: a pin advanced just now is churn, not neglect" >&2; printf '%s\n' "$fresh_out" >&2; exit 1; } +printf '%s\n' "$fresh_out" | grep -q '0 failed' || { + echo "FAIL: the run must pass; got: $fresh_out" >&2; exit 1; } +printf '%s\n' "$fresh_out" | grep -q '1 of 1 pin(s) NOT current' || { + echo "FAIL: the not-current count must still be reported; got: $fresh_out" >&2; exit 1; } +echo " the same behind pin, advanced recently, passed the bound" + echo "== verb:file-parity (identical must PASS, drift must FAIL) ==" par_out="$("$CH" task parity)"; echo "$par_out" | grep -q '0 failed' || { echo "FAIL: identical pair must pass" >&2; exit 1; } echo drifted >> r.copy