Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
112 changes: 112 additions & 0 deletions candy/plugin-task/currency_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"path/filepath"
"strings"
"testing"
"time"

"github.com/opencharly/spec/spec"
)
Expand Down Expand Up @@ -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)
}
}
13 changes: 13 additions & 0 deletions candy/plugin-task/params/cue_types_gen.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 12 additions & 0 deletions candy/plugin-task/schema/task.cue
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
106 changes: 106 additions & 0 deletions candy/plugin-task/verbs.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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{}
Expand Down Expand Up @@ -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:
Expand Down
65 changes: 65 additions & 0 deletions scripts/gate-task.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading