diff --git a/AGENTS.md b/AGENTS.md index 9d0185d..be24195 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,9 +1,9 @@ # AGENTS.md — plugin-task Standalone plugin repo for the generic declarative TASK runner (`kind:task`, -`command:task`, `verb:task`) plus five generic maintenance verbs +`command:task`, `verb:task`) plus six generic maintenance verbs (`verb:git-submodules`, `verb:file-parity`, `verb:splice-region`, -`verb:module-pins`, `verb:prune`). The Go module lives at `candy/plugin-task/` (module path +`verb:module-pins`, `verb:pin-closure`, `verb:prune`). The Go module lives at `candy/plugin-task/` (module path `github.com/opencharly/plugin-task/candy/plugin-task`); the root `charly.yml` declares `discover: candy` so the repo is a project and its candy is scanned. @@ -11,16 +11,16 @@ Canonical files: - `candy/plugin-task/charly.yml` — the `plugin-task:` candy entity (`plugin:` block, `plan:` checks). -- `candy/plugin-task/plugin.go` — `NewProvider`/`NewMeta` and the seven +- `candy/plugin-task/plugin.go` — `NewProvider`/`NewMeta` and the nine capability declarations. - `candy/plugin-task/grammar.go`, `graph.go`, `runner.go`, `incremental.go`, `verbs.go`, `cli.go` — the task engine, dependency graph, incremental - semantics, the five maintenance verbs, and the CLI. + semantics, the six maintenance verbs, and the CLI. - `candy/plugin-task/schema/task.cue` + `params/cue_types_gen.go` — the self-contained schema and its generated types. - `candy/plugin-task/cmd/serve/main.go` — the out-of-process serve shim. - `scripts/gate-task.sh` — the repo's own gate helper: builds a real charly binary - with this candy compiled in and drives `command:task` + the five maintenance + with this candy compiled in and drives `command:task` + the six maintenance verbs live. - `scripts/check-coherence.sh` — the control that charly's own `sdk`/`spec` pins lead this module's requires. Called by `gate-task.sh` (before its build) and by @@ -59,7 +59,7 @@ when one is authored, add it here. `plugin:` block, CUE schema). - `scripts/gate-task.sh` at the repo root — the live gate: it clones charly at `CHARLY_REF` (default `main`), builds a binary with this candy compiled in, and - drives `command:task` plus the five maintenance verbs against real git fixtures. + drives `command:task` plus the six maintenance verbs against real git fixtures. It is the ONLY live execution of those verbs, so it must keep running. - CI runs two jobs besides the Go gates: `coherence` on every PR (seconds — the same `scripts/check-coherence.sh` control the gate runs, so the vintage-coherence @@ -78,7 +78,7 @@ when one is authored, add it here. - The task body is the base-schema `#Task`, validated host-side against `#TaskValue`; keep the engine reusing `kit.RunPlan` + `checkkit.VerbResolver` over `kit.ShellExecutor{}` — there is no second execution engine. -- The five maintenance verbs stay domain-neutral and parameterized by the repo's +- The six maintenance verbs stay domain-neutral and parameterized by the repo's own data; never bake an org, repo, or distro name into the plugin. ## Landing diff --git a/README.md b/README.md index 4b6af8d..f7695a3 100644 --- a/README.md +++ b/README.md @@ -17,11 +17,12 @@ compiled into charly. | `verb:file-parity` | `file-parity:` verb step | check / sync that paired files are byte-identical | | `verb:splice-region` | `splice-region:` verb step | splice a marked region from a fragment into a target | | `verb:module-pins` | `module-pins:` verb step | adopt a source go.mod's require pins across every module a glob matches, then tidy | +| `verb:pin-closure` | `pin-closure:` verb step | resolve the TRANSITIVE CLOSURE of a pin advance: compile the composed set, classify every failing module BUMP (a published version already carries the fix) or EDIT (it does not), and iterate to a fixed point | | `verb:prune` | `prune:` verb step | garbage-collect MERGED-UPSTREAM session worktrees + branches across the project root and every submodule (a dry run by default) | ## Generic maintenance verbs -The five maintenance verbs replace repository shell scripts. Each is **domain-neutral** +The six maintenance verbs replace repository shell scripts. Each is **domain-neutral** and parameterized by the repo's OWN data (paths, pairs, pins) carried in its authored input, so the plugin stays reusable by any repository — no org name, repo name, or distro list is baked in. They are host-native: like the task engine they act on the diff --git a/candy/plugin-task/charly.yml b/candy/plugin-task/charly.yml index 44ec88a..96af8d2 100644 --- a/candy/plugin-task/charly.yml +++ b/candy/plugin-task/charly.yml @@ -42,6 +42,7 @@ plugin-task: - verb:file-parity - verb:splice-region - verb:module-pins + - verb:pin-closure - verb:prune primary: task: task @@ -51,8 +52,8 @@ plugin-task: context: - build command: "test -f plugin.go && test -f schema/task.cue && test -f params/cue_types_gen.go && echo 'plugin.go schema/task.cue params/cue_types_gen.go all present'" - - check: the provider source declares all eight capabilities (the three task classes individually + the five maintenance verbs) — a deterministic probe that fails if a capability is dropped + - check: the provider source declares all nine capabilities (the three task classes individually + the six maintenance verbs) — a deterministic probe that fails if a capability is dropped id: task-capabilities-declared context: - build - command: "grep -q 'Class: \"kind\", Word: \"task\"' plugin.go && grep -q 'Class: \"command\", Word: \"task\"' plugin.go && grep -q 'Class: \"verb\", Word: \"task\"' plugin.go && grep -q 'Word: \"git-submodules\"' plugin.go && grep -q 'Word: \"file-parity\"' plugin.go && grep -q 'Word: \"splice-region\"' plugin.go && grep -q 'Word: \"module-pins\"' plugin.go && grep -q 'Word: \"prune\"' plugin.go && echo 'kind/command/verb:task + five maintenance verbs declared'" + command: "grep -q 'Class: \"kind\", Word: \"task\"' plugin.go && grep -q 'Class: \"command\", Word: \"task\"' plugin.go && grep -q 'Class: \"verb\", Word: \"task\"' plugin.go && grep -q 'Word: \"git-submodules\"' plugin.go && grep -q 'Word: \"file-parity\"' plugin.go && grep -q 'Word: \"splice-region\"' plugin.go && grep -q 'Word: \"module-pins\"' plugin.go && grep -q 'Word: \"pin-closure\"' plugin.go && grep -q 'Word: \"prune\"' plugin.go && echo 'kind/command/verb:task + six maintenance verbs declared'" diff --git a/candy/plugin-task/params/cue_types_gen.go b/candy/plugin-task/params/cue_types_gen.go index 51251b2..4e23435 100644 --- a/candy/plugin-task/params/cue_types_gen.go +++ b/candy/plugin-task/params/cue_types_gen.go @@ -94,6 +94,22 @@ type SpliceRegionInput struct { End string `yaml:"end,omitempty" json:"end"` } +// #PinClosureInput resolves the TRANSITIVE CLOSURE of a pin advance: compile the +// composed set, collect EVERY failing module, classify each BUMP (its newest +// published version already carries the fix) or EDIT (it does not), and iterate to +// a fixed point. Domain-neutral: the caller names the anchor module, any modules it +// has already composed, and the pin prefix whose versions matter. +type PinClosureInput struct { + // anchor is the module dir whose go.mod names the pins (default: /charly). + Anchor string `yaml:"anchor,omitempty" json:"anchor,omitempty"` + + // compose lists module dirs already adopted into the probe (they win over the pins). + Compose []string `yaml:"compose,omitempty" json:"compose,omitempty"` + + // module_prefix limits the reported pins to one org (empty = every require line). + ModulePrefix string `yaml:"module_prefix,omitempty" json:"module_prefix,omitempty"` +} + // #ModulePinsInput adopts a set of module pins from a source go.mod into every // module a glob matches, then tidies. type ModulePinsInput struct { diff --git a/candy/plugin-task/pin_closure.go b/candy/plugin-task/pin_closure.go new file mode 100644 index 0000000..54429e3 --- /dev/null +++ b/candy/plugin-task/pin_closure.go @@ -0,0 +1,515 @@ +package task + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "regexp" + "sort" + "strings" + + "github.com/opencharly/plugin-task/candy/plugin-task/params" + "github.com/opencharly/spec/spec" +) + +// pin_closure.go — verb:pin-closure: the TRANSITIVE CLOSURE of a pin advance. +// +// A pin advance is a claim about a SET of modules, and the set is DISCOVERED by +// compiling, never declared by a diff. Measured 2026-10-15 (charly#906): advancing +// `spec` alone broke the pinned `sdk` INSIDE THE SDK'S OWN UNEDITED FILE; with that +// pair advanced, the anchor failed on `plugin-vm`, `plugin-settings`, `plugin-clean` +// and `plugin-build` AND on the core's own `BuildEngine:` literal — five modules, +// three of them named by no plan anyone had. Each rung was invisible from the one +// below it, and every one of them was found by LOOKING rather than by pushing. +// +// Four properties, each paid for by a failure that night: +// +// 1. FIX-POINT, never enumerate — compile, collect EVERY failing module, resolve +// the ones that only need a pin advance, compile again, repeat until the set +// stops shrinking. A check that asks "do these N modules compile" fails the +// moment N is unknown; and stopping at the first failure hid three layers +// (the alias failure masked the sdk, the sdk masked plugin-vm, plugin-vm +// masked the rest). +// 2. CLOSURE-DISCOVERED — the set is found, never declared. +// 3. BUMP-vs-EDIT CLASSIFIED — for each member, say whether its newest published +// version already carries the fix (BUMP: advance the pin) or still references +// the retired symbol (EDIT: somebody owns a cutover). This is the reader's +// next action, so a gate that only says "does not compile" sends them to +// patch modules that need a bump. +// 4. NEVER AN EMPTY ANSWER — if the closure cannot be resolved, the Message says +// what was TRIED. An empty answer meaning "I gave up" is indistinguishable +// from "nothing is wrong", which is this campaign's recurring defect. +// +// The report rides the verb's Message (runMaintenanceVerbIn's second return), +// because in this repo's own words "a `command:` step's stdout does not survive +// the pass path at all" — a gate that finds the closure and then discards it on +// the pass path is worse than none, because it would look like it had answered. + +const closureMaxRungs = 4 + +// closureFailure is one failing PACKAGE from a `go build ./...` run: the import +// path `go` named, and the `file:line: message` lines beneath it. +type closureFailure struct { + Pkg string + Lines []string +} + +// closureVerdict is what a reader has to act on. +type closureVerdict struct { + Kind string // "BUMP" or "EDIT" + Evidence string // the version that proves a BUMP, or the file:line that needs an EDIT +} + +// closurePkgLine matches go's per-package failure header, e.g. "# github.com/x/y/z". +var closurePkgLine = regexp.MustCompile(`^# (.+)$`) + +// closureErrLine matches go's error lines, e.g. "f.go:12:3: undefined: spec.Foo". +var closureErrLine = regexp.MustCompile(`^(.+?\.go:\d+:\d+): (.*)$`) + +// parseClosureFailures returns EVERY failing package in go build output — never +// only the first. A run that reports one red line reports the first rung only. +func parseClosureFailures(out string) []closureFailure { + var failures []closureFailure + index := map[string]int{} + for _, line := range strings.Split(out, "\n") { + line = strings.TrimRight(line, "\r") + if m := closurePkgLine.FindStringSubmatch(strings.TrimSpace(line)); m != nil { + pkg := strings.TrimSpace(m[1]) + if _, seen := index[pkg]; !seen { + index[pkg] = len(failures) + failures = append(failures, closureFailure{Pkg: pkg}) + } + continue + } + m := closureErrLine.FindStringSubmatch(strings.TrimSpace(line)) + if m == nil || len(failures) == 0 { + continue + } + last := &failures[len(failures)-1] + last.Lines = append(last.Lines, m[1]+": "+m[2]) + } + return failures +} + +// retiredSymbols pulls the identifiers a failure names as missing, newest-last: +// "undefined: spec.KubernetesHostname" → KubernetesHostname, +// "req.BuildEngine undefined" → BuildEngine, "cfg.Engine.Build undefined" → Build. +// The last dotted segment is the one a source scan can see, and choosing it is +// deliberately CONSERVATIVE: a too-common name yields more hits, hence an EDIT, +// and "if you are unsure it is blocking" is the rule the gate exists to serve. +func retiredSymbols(lines []string) []string { + seen := map[string]bool{} + var syms []string + for _, l := range lines { + msg := l + if i := strings.Index(l, ": "); i >= 0 { + msg = l[i+2:] + } + // go appends the reason in parentheses: "(type spec.X has no field or + // method Y)". Strip it GENERICALLY — matching one module's type name would + // bake that name into this plugin, and the symbol is what precedes it. + if i := strings.LastIndex(msg, " ("); i >= 0 && strings.HasSuffix(msg, ")") { + msg = strings.TrimSpace(msg[:i]) + } + var expr string + switch { + case strings.HasPrefix(msg, "undefined: "): + expr = strings.TrimSpace(strings.TrimPrefix(msg, "undefined: ")) + case strings.HasSuffix(msg, " undefined"): + expr = strings.TrimSpace(strings.TrimSuffix(msg, " undefined")) + case strings.Contains(msg, "not enough arguments"), strings.Contains(msg, "too many arguments"): + continue // a signature change is not a retired symbol: never a BUMP + default: + continue + } + expr = strings.Trim(expr, "()") + parts := strings.Split(expr, ".") + sym := parts[len(parts)-1] + if sym == "" || seen[sym] { + continue + } + seen[sym] = true + syms = append(syms, sym) + } + return syms +} + +// classifyClosure decides BUMP or EDIT for one failing package. newestDir is the +// source dir of the module's NEWEST published version (empty when it could not be +// fetched — then the answer is EDIT, because an unproven bump is a claim nobody +// checked). +func classifyClosure(symbols []string, newestDir string, symbolCount func(dir, sym string) int) closureVerdict { + if newestDir == "" { + return closureVerdict{Kind: "EDIT", Evidence: "newest version could not be fetched — not proven fixable by a bump"} + } + if len(symbols) == 0 { + return closureVerdict{Kind: "EDIT", Evidence: "no retired symbol named in the failure — cannot be a bump"} + } + for _, sym := range symbols { + if n := symbolCount(newestDir, sym); n > 0 { + return closureVerdict{Kind: "EDIT", Evidence: fmt.Sprintf("%s still referenced in the newest version (%d site(s))", sym, n)} + } + } + return closureVerdict{Kind: "BUMP", Evidence: strings.Join(symbols, ",") + " absent from the newest version"} +} + +// renderClosureReport is the Message: the pair it resolved at, every rung it took, +// every failing module with its verdict, and an explicit verdict about the advance. +func renderClosureReport(pair string, rungs []string, order []string, verdicts map[string]closureVerdict, edits []string) string { + var b strings.Builder + b.WriteString("pin-closure: resolved at " + pair + "\n") + for _, r := range rungs { + b.WriteString(" rung: " + r + "\n") + } + if len(order) == 0 { + b.WriteString("closure: EMPTY — every module in the composed set compiles at this pair\n") + return b.String() + } + bumps, editCount := 0, 0 + for _, m := range order { + v := verdicts[m] + if v.Kind == "BUMP" { + bumps++ + } else { + editCount++ + } + fmt.Fprintf(&b, "closure: %-8s %-58s %s\n", v.Kind, m, v.Evidence) + } + if editCount > 0 { + fmt.Fprintf(&b, "advance: REFUSED — %d module(s) need an EDIT (their own cutover), %d can be adopted by a pin bump: %s\n", + editCount, bumps, strings.Join(edits, ", ")) + } else { + fmt.Fprintf(&b, "advance: CLEAN at this pair — %d module(s) need only a pin bump\n", bumps) + } + return b.String() +} + +// newestModuleVersion returns the newest published version of a module path and +// the local dir of that version's sources (empty dir when it cannot be fetched). +// newestModuleVersionFn is the version-lookup SEAM (a package-level var for the same reason the +// report/classify seams exist): the loop's fixed-point iteration must be provable without a +// registry, and a bump that is never adopted is the one arm a fixture cannot reach hermetically. +var newestModuleVersionFn = newestModuleVersion + +func newestModuleVersion(anchorDir, module string) (string, string) { + out, err := runGo(anchorDir, "", "list", "-m", "-versions", module) + if err != nil { + return "", "" + } + fields := strings.Fields(out) + if len(fields) < 2 { + return "", "" + } + version := fields[len(fields)-1] + if version == module || !strings.HasPrefix(version, "v") { + return "", "" // no tagged release: nothing to advance to + } + dir := moduleCacheDir(anchorDir, module+"@"+version) + return version, dir +} + +// runGo runs the go tool in dir, with the overlay in force when workFile is set. +func runGo(dir, workFile string, args ...string) (string, error) { + cmd := exec.Command("go", args...) + cmd.Dir = dir + env := append(os.Environ(), "GOTMPDIR="+goTmpDir()) + if workFile != "" { + // Workspace mode FORBIDS `-mod` outright ("go: -mod may only be set to readonly or vendor + // when in workspace mode"), so an ambient GOFLAGS carrying it fails this build with a flag + // error the operator never set against this verb — measured: this repo's own gate exports + // `GOFLAGS=-mod=mod` for its charly build, and the fixture run inherited it. The closure + // defines its own composed module set, so `-mod` is meaningless here: drop it rather than + // letting the caller's build flags decide whether this verb works. + env = append(env, "GOWORK="+workFile, "GOFLAGS="+goFlagsWithoutMod(os.Getenv("GOFLAGS"))) + } + cmd.Env = env + var buf strings.Builder + cmd.Stdout = &buf + cmd.Stderr = &buf + err := cmd.Run() + return buf.String(), err +} + +// goFlagsWithoutMod drops every `-mod` flag from a GOFLAGS value. It exists because the closure +// build runs in WORKSPACE mode, where the Go toolchain rejects `-mod` outright, so inheriting one +// from the caller's environment would fail the verb with an error about a flag it never set. +func goFlagsWithoutMod(flags string) string { + var kept []string + for _, f := range strings.Fields(flags) { + if f == "-mod" || strings.HasPrefix(f, "-mod=") { + continue + } + kept = append(kept, f) + } + return strings.Join(kept, " ") +} + +func goTmpDir() string { + if dir := os.Getenv("GOTMPDIR"); dir != "" { + return dir + } + // The dir is MADE here, not assumed: pointing GOTMPDIR at a path that does not exist makes the + // toolchain's own temp-file creation fail on a fresh host, which would surface as a build error + // this verb would then have to explain. + dir := os.TempDir() + if home, err := os.UserHomeDir(); err == nil { + dir = filepath.Join(home, ".cache", "go-tmp") + } + if err := os.MkdirAll(dir, 0o755); err != nil { + return os.TempDir() // the toolchain's own default, rather than a path we could not create + } + return dir +} + +// moduleCacheDir resolves a downloaded module version's source dir, or "". +func moduleCacheDir(anchorDir, ref string) string { + out, err := runGo(anchorDir, "", "mod", "download", "-json", ref) + if err != nil { + return "" + } + const key = `"Dir": ` + for _, line := range strings.Split(out, "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, key) { + return strings.Trim(strings.TrimPrefix(line, key), `",`) + } + } + return "" +} + +// runPinClosure is the live loop: resolve the pair, compose the overlay, build the +// whole closure, classify every failure, adopt the bumps, and repeat until the set +// stops shrinking — then report, in the Message, what a reader must do next. +func runPinClosure(projDir string, in params.PinClosureInput) (spec.Status, string) { + anchor := in.Anchor + if anchor == "" { + anchor = filepath.Join(projDir, "charly") + } + if _, err := os.Stat(filepath.Join(anchor, "go.mod")); err != nil { + return spec.StatusFail, fmt.Sprintf( + "pin-closure: no anchor module at %s (tried: anchor input, then /charly); set `anchor:` to the module whose go.mod names the pins — %v", + anchor, err) + } + workDir, err := os.MkdirTemp("", "charly-pin-closure-") + if err != nil { + return spec.StatusFail, fmt.Sprintf("pin-closure: temp overlay dir: %v", err) + } + defer func() { _ = os.RemoveAll(workDir) }() // best-effort: the overlay is a temp dir + pair := resolvedPair(anchor, in.ModulePrefix) + // The workspace names the anchor MODULE dir itself: naming its parent makes go + // reject the workspace ("module not found"), which fails the build with no "# pkg" + // line at all — the shape of failure that produced a false EMPTY report once. + members := []string{anchor} + members = append(members, in.Compose...) + replaces := map[string]string{} + goLine := workspaceGo(anchor) + workFile := writeOverlay(workDir, goLine, members, replaces) + + rungs := []string{} + order := []string{} + verdicts := map[string]closureVerdict{} + edits := []string{} + for rung := 1; rung <= closureMaxRungs; rung++ { + out, buildErr := runGo(anchor, workFile, "build", "./...") + failures := parseClosureFailures(out) + if buildErr != nil && len(failures) == 0 { + // The build failed for a reason this parser cannot attribute to a module + // (a workspace or resolution error, for instance). Say WHAT WAS TRIED and + // what the tool said: an empty answer that means "I gave up" is + // indistinguishable from "nothing is wrong", which is this gate's whole + // reason to exist. + return spec.StatusFail, fmt.Sprintf( + "pin-closure: the closure could not be resolved at %s\ntried: go build ./... with GOWORK=%s over %d composed module(s) (%s)\ngo said: %s", + pair, workFile, len(members), strings.Join(members, ", "), firstLines(out, 6)) + } + if len(failures) == 0 { + rungs = append(rungs, fmt.Sprintf("%d: the composed set compiles", rung)) + return spec.StatusPass, renderClosureReport(pair, rungs, order, verdicts, edits) + } + rungs = append(rungs, fmt.Sprintf("%d: %d failing package(s)", rung, len(failures))) + adopted := 0 + for _, f := range failures { + module := moduleOf(anchor, workFile, f.Pkg) + if prev, seen := verdicts[module]; seen { + // A module ADOPTED at an earlier rung that STILL fails is the false-BUMP direction, + // the one classifyClosure's own comment warns about: its newest version fixed the + // symbol we named and the composed set still does not compile, so the bump was not + // the fix. Dropping it here made the verdicts map the only record, editCount 0, and + // the report say "advance: CLEAN — need only a pin bump" about a module that + // provably does not compile. Record the EDIT, with the failure that proves it. + if prev.Kind == "BUMP" { + evidence := "adopted a bump and the composed set still fails" + if len(f.Lines) > 0 { + evidence += ": " + f.Lines[0] + } + verdicts[module] = closureVerdict{Kind: "EDIT", Evidence: evidence} + edits = append(edits, module) + } + continue + } + // Classify against the module the FAILING PACKAGE belongs to, not the + // repo root: these repos tag both the repo and the subdir module, and + // the repo-root tag can already be fixed while the module tag a + // consumer actually pins still carries the retired symbol. Classifying + // against the wrong one yields a FALSE BUMP — the dangerous direction, + // because it tells a reader to advance a pin where a cutover is owed. + version, dir := newestModuleVersionFn(anchor, module) + v := classifyClosure(retiredSymbols(f.Lines), dir, grepSymbol) + if v.Kind == "BUMP" { + v.Evidence = version + " — " + v.Evidence + replaces[module] = adoptionTarget(version, dir) // adopted by the workspace + adopted++ + } else { + if len(f.Lines) > 0 { + v.Evidence = f.Lines[0] // the file:line its owner needs + } + edits = append(edits, module) + } + verdicts[module] = v + order = append(order, module) + } + if adopted == 0 { + rungs = append(rungs, fmt.Sprintf("%d: no member can be advanced by a pin bump — fixed point", rung)) + return spec.StatusFail, renderClosureReport(pair, rungs, order, verdicts, edits) + } + workFile = writeOverlay(workDir, goLine, members, replaces) + } + return spec.StatusFail, renderClosureReport(pair, rungs, order, verdicts, edits) +} + +// moduleOf maps a failing PACKAGE to the MODULE a consumer would advance, ASKED of the go +// toolchain when it can answer: `go list -f {{.Module.Path}}` reports the module a package really +// belongs to, which is what both the version lookup and the `replace` key must use — the same key +// for both. closureModule is the fallback for a package the toolchain cannot resolve. +func moduleOf(dir, workFile, pkg string) string { + if out, err := runGo(dir, workFile, "list", "-f", "{{.Module.Path}}", pkg); err == nil { + if m := strings.TrimSpace(out); m != "" && strings.Contains(m, "/") && !strings.Contains(m, " ") { + return m + } + } + return closureModule(pkg) +} + +// adoptionTarget is what the workspace overlay replaces `module` WITH. A downloaded module dir is +// preferred over its version: the source is already on disk (the version lookup fetched it), and a +// version form would make the NEXT build re-resolve it — needing the network at build time and, for +// a module the registry cannot serve, reintroducing exactly the failure this verb exists to find. +func adoptionTarget(version, dir string) string { + if dir != "" { + return dir + } + return version +} + +// closureModule is the RESOLUTION FALLBACK: the package's repo root, the first three path segments +// (`host/org/repo`). It is used only when moduleOf cannot ask the toolchain. +func closureModule(pkg string) string { + parts := strings.Split(pkg, "/") + if len(parts) > 3 { + return strings.Join(parts[:3], "/") + } + return pkg +} + +// grepSymbol counts the references to sym in a downloaded module's Go sources. +func grepSymbol(dir, sym string) int { + if dir == "" || sym == "" { + return 0 + } + out, _ := exec.Command("grep", "-rl", "--include=*.go", sym, dir).Output() + n := 0 + for _, l := range strings.Split(strings.TrimSpace(string(out)), "\n") { + if l != "" && !strings.HasSuffix(l, "_test.go") { + n++ + } + } + return n +} + +// resolvedPair states the pins the anchor resolves at — a reader must be able to +// tell a real answer from a stale one. prefix keeps this verb domain-neutral: the +// CALLER names the org whose pins matter (empty = every require line). +func resolvedPair(anchor, prefix string) string { + data, err := os.ReadFile(filepath.Join(anchor, "go.mod")) + if err != nil { + return "" + } + var pins []string + for _, line := range strings.Split(string(data), "\n") { + line = strings.TrimSpace(line) + if prefix != "" && !strings.HasPrefix(line, prefix) { + continue + } + if strings.HasPrefix(line, "//") || strings.HasPrefix(line, "module ") || strings.HasPrefix(line, "go ") { + continue + } + fields := strings.Fields(line) + if len(fields) >= 2 && strings.Contains(fields[0], "/") && strings.HasPrefix(fields[1], "v") { + pins = append(pins, fields[0]+"@"+fields[1]) + } + } + sort.Strings(pins) + if len(pins) == 0 { + return "" + } + return strings.Join(pins, " ") +} + +// writeOverlay writes the temp go.work naming the composed members (the anchor plus +// whatever the caller or the loop has adopted). +func writeOverlay(workDir, goLine string, members []string, replaces map[string]string) string { + var b strings.Builder + b.WriteString("go " + goLine + "\n\nuse (\n") + for _, m := range members { + b.WriteString("\t" + m + "\n") + } + b.WriteString(")\n") + if len(replaces) > 0 { + var mods []string + for m := range replaces { + mods = append(mods, m) + } + sort.Strings(mods) + b.WriteString("\nreplace (\n") + for _, m := range mods { + if strings.HasPrefix(replaces[m], "/") { + b.WriteString("\t" + m + " => " + replaces[m] + "\n") + continue + } + b.WriteString("\t" + m + " => " + m + " " + replaces[m] + "\n") + } + b.WriteString(")\n") + } + path := filepath.Join(workDir, "go.work") + _ = os.WriteFile(path, []byte(b.String()), 0o644) + return path +} + +// workspaceGo is the `go` line the generated workspace carries. It is the ANCHOR's own directive, +// read from the module whose pins this verb resolves — a fixed literal over-constrains every closure +// build to one toolchain (a module declaring go 1.24 built under a workspace demanding 1.26.4) and +// fails outright on a host whose toolchain is older. It falls back to a conservative floor only when +// the anchor's go.mod cannot be read. +func workspaceGo(anchorDir string) string { + data, err := os.ReadFile(filepath.Join(anchorDir, "go.mod")) + if err == nil { + for _, line := range strings.Split(string(data), "\n") { + fields := strings.Fields(line) + if len(fields) == 2 && fields[0] == "go" { + return fields[1] + } + } + } + return "1.21" +} + +// firstLines bounds a tool's own words for the Message: enough to see the cause, +// never enough to swamp the report. +func firstLines(s string, n int) string { + lines := strings.Split(strings.TrimSpace(s), "\n") + if len(lines) > n { + lines = append(lines[:n], fmt.Sprintf("… (%d more line(s))", len(lines)-n)) + } + return strings.Join(lines, " | ") +} diff --git a/candy/plugin-task/pin_closure_test.go b/candy/plugin-task/pin_closure_test.go new file mode 100644 index 0000000..7c27b93 --- /dev/null +++ b/candy/plugin-task/pin_closure_test.go @@ -0,0 +1,318 @@ +package task + +// pin_closure_test.go — the acceptance case for verb:pin-closure, taken from the +// ladder that was walked BY HAND on 2026-10-15 (charly#906): advancing `spec` alone +// broke the pinned sdk in the sdk's own unedited file; with that pair advanced the +// build named four plugins AND the core's own literal — five modules, three of them +// named by no plan. The fixtures below are the REAL `go build` output, so the gate +// is tested against a case that happened rather than one that was imagined. + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/opencharly/plugin-task/candy/plugin-task/params" + "github.com/opencharly/spec/spec" +) + +// ladderOutput is verbatim `go build ./...` output from the overlay probe: two +// packages, one with a signature change and no retired symbol, one with both. +const ladderOutput = `# github.com/opencharly/plugin-build/candy/plugin-build +/home/atrawog/go/pkg/mod/github.com/opencharly/plugin-build/candy/plugin-build@v0.2026286.1121/drive.go:75:25: reply.PodmanJobs undefined (type spec.BuildResolveReply has no field or method PodmanJobs) +/home/atrawog/go/pkg/mod/github.com/opencharly/plugin-build/candy/plugin-build@v0.2026286.1121/ensure.go:71:9: req.BuildEngine undefined (type spec.BuildEnsureRequest has no field or method BuildEngine) +# github.com/opencharly/plugin-clean/candy/plugin-clean +/home/atrawog/go/pkg/mod/github.com/opencharly/plugin-clean/candy/plugin-clean@v0.2026271.2313/retention.go:202:29: rt.BuildEngine undefined (type *hostenv.ResolvedRuntime has no field or method BuildEngine) +# github.com/opencharly/plugin-vm/candy/plugin-vm +/home/atrawog/go/pkg/mod/github.com/opencharly/plugin-vm/candy/plugin-vm@v0.2026286.856/vm_box_emit.go:104:63: not enough arguments in call to deploykit.EmitVmBoxAt +` + +// TestParseClosureFailures_EveryPackageNotTheFirst pins property 1's second half: +// early exit is what hid three layers in one night, so the parser must return EVERY +// failing package, in order, with their lines. +func TestParseClosureFailures_EveryPackageNotTheFirst(t *testing.T) { + got := parseClosureFailures(ladderOutput) + if len(got) != 3 { + t.Fatalf("got %d failing package(s), want 3 (never stop at the first): %+v", len(got), got) + } + wantPkgs := []string{ + "github.com/opencharly/plugin-build/candy/plugin-build", + "github.com/opencharly/plugin-clean/candy/plugin-clean", + "github.com/opencharly/plugin-vm/candy/plugin-vm", + } + for i, want := range wantPkgs { + if got[i].Pkg != want { + t.Errorf("failure %d = %q, want %q", i, got[i].Pkg, want) + } + } + if len(got[0].Lines) != 2 || len(got[1].Lines) != 1 { + t.Errorf("line capture wrong: %d and %d, want 2 and 1", len(got[0].Lines), len(got[1].Lines)) + } +} + +// TestRetiredSymbols pins the extraction, including the case that must NOT look +// like a retired symbol: a signature change can never be fixed by a pin bump, so +// it must not produce a symbol that a source scan could mistakenly clear. +func TestRetiredSymbols(t *testing.T) { + cases := []struct { + name string + in []string + want []string + }{ + {"undefined with package qualifier", []string{"f.go:1:2: undefined: spec.KubernetesHostname"}, []string{"KubernetesHostname"}}, + {"trailing undefined", []string{"f.go:1:2: req.BuildEngine undefined (type spec.BuildEnsureRequest has no field or method BuildEngine)"}, []string{"BuildEngine"}}, + {"trailing undefined, dotted", []string{"f.go:1:2: cfg.Engine.Build undefined (type hostenv.EngineConfig has no field or method Build)"}, []string{"Build"}}, + {"signature change names no symbol", []string{"f.go:1:2: not enough arguments in call to deploykit.EmitVmBoxAt"}, nil}, + } + for _, c := range cases { + got := retiredSymbols(c.in) + if strings.Join(got, ",") != strings.Join(c.want, ",") { + t.Errorf("%s: got %v, want %v", c.name, got, c.want) + } + } +} + +// TestClassifyClosure_BumpVersusEdit is the property that decides the reader's next +// action. A member whose NEWEST published version no longer references the retired +// symbol is a BUMP (advance the pin); one that still does is an EDIT (its own +// cutover); and one that could not be fetched is an EDIT because an unproven bump is +// a claim nobody checked. +func TestClassifyClosure_BumpVersusEdit(t *testing.T) { + absent := func(_, _ string) int { return 0 } + present := func(_, _ string) int { return 3 } + + if v := classifyClosure([]string{"BuildEngine"}, "/newest", absent); v.Kind != "BUMP" { + t.Errorf("newest no longer references it: got %s (%s), want BUMP", v.Kind, v.Evidence) + } + if v := classifyClosure([]string{"BuildEngine"}, "/newest", present); v.Kind != "EDIT" { + t.Errorf("newest still references it: got %s, want EDIT", v.Kind) + } + if v := classifyClosure([]string{"BuildEngine"}, "", absent); v.Kind != "EDIT" { + t.Errorf("unfetched newest: got %s, want EDIT (an unproven bump is a claim)", v.Kind) + } + if v := classifyClosure(nil, "/newest", absent); v.Kind != "EDIT" { + t.Errorf("no symbol named (a signature change): got %s, want EDIT", v.Kind) + } +} + +// TestRenderClosureReport_RefusesWhileAnEditRemains pins property 4: the Message +// states the pair it resolved at, every rung, every member with its verdict, and it +// REFUSES the advance while any EDIT remains — the channel that survives the pass +// path must never answer "clean" over a set that does not compile. +func TestRenderClosureReport_RefusesWhileAnEditRemains(t *testing.T) { + pair := "github.com/opencharly/spec@v0.2026288.554 github.com/opencharly/sdk@v0.2026287.2235" + verdicts := map[string]closureVerdict{ + "github.com/opencharly/plugin-vm/candy/plugin-vm": {Kind: "BUMP", Evidence: "v0.2026287.2320 — BuildEngine absent from the newest version"}, + "github.com/opencharly/plugin-clean/candy/plugin-clean": {Kind: "EDIT", Evidence: "retention.go:202:29: rt.BuildEngine undefined"}, + } + msg := renderClosureReport(pair, []string{"1: 3 failing package(s)", "2: 1 failing package(s)"}, []string{ + "github.com/opencharly/plugin-vm/candy/plugin-vm", + "github.com/opencharly/plugin-clean/candy/plugin-clean", + }, verdicts, []string{"github.com/opencharly/plugin-clean/candy/plugin-clean"}) + + if !strings.Contains(msg, "github.com/opencharly/spec@v0.2026288.554") { + t.Error("the Message must state the resolved pair, so a reader can tell a real answer from a stale one") + } + if !strings.Contains(msg, "1: 3 failing package(s)") || !strings.Contains(msg, "2: 1 failing package(s)") { + t.Error("the Message must say which rung of the loop it is on") + } + if !strings.Contains(msg, "BUMP") || !strings.Contains(msg, "EDIT") { + t.Error("every member must be classified BUMP or EDIT — that is the reader's next action") + } + if !strings.Contains(msg, "REFUSED") { + t.Error("an advance with an EDIT remaining must be REFUSED, never reported clean") + } +} + +// TestRenderClosureReport_NeverEmpty pins the other half of property 4: an empty +// closure is stated as such, never as silence — "I gave up" and "nothing is wrong" +// must be distinguishable. +func TestRenderClosureReport_NeverEmpty(t *testing.T) { + msg := renderClosureReport("github.com/opencharly/spec@v0.2026288.554", []string{"1: the composed set compiles"}, nil, nil, nil) + if !strings.Contains(msg, "EMPTY") || !strings.Contains(msg, "resolved at") { + t.Errorf("an empty closure must be stated, not silent: %q", msg) + } +} + +// TestResolvedPair_UnresolvedSaysWhatItTried pins the third clause of property 4. +func TestResolvedPair_UnresolvedSaysWhatItTried(t *testing.T) { + msg := resolvedPair("/nonexistent-anchor-dir", "") + if !strings.Contains(msg, "unresolved") || !strings.Contains(msg, "/nonexistent-anchor-dir/go.mod") { + t.Errorf("an unresolvable pair must name what was tried: %q", msg) + } +} + +// TestClosureModule maps a failing package to the module a consumer would advance. +func TestClosureModule(t *testing.T) { + got := closureModule("github.com/opencharly/plugin-clean/candy/plugin-clean") + if got != "github.com/opencharly/plugin-clean" { + t.Errorf("got %q, want the repo root a consumer advances", got) + } +} + +// TestGoFlagsWithoutMod pins the workspace-mode guard: a caller's `-mod` flag must never reach the +// closure build, because the Go toolchain rejects it outright in workspace mode (measured — this +// repo's own gate exports GOFLAGS=-mod=mod for its charly build, and the fixture run inherited it +// and failed the verb). Every OTHER flag is preserved: the sanitizer removes one flag, not a +// user's flags. +func TestGoFlagsWithoutMod(t *testing.T) { + for _, c := range []struct{ in, want string }{ + {"", ""}, + {"-mod=mod", ""}, + {"-mod=readonly", ""}, + // GOFLAGS entries are `-flag=value` (or a bare bool flag): `-mod vendor` is NOT a + // representable GOFLAGS value, so the sanitizer is not asked to parse it. + {"-mod -trimpath", "-trimpath"}, + {"-mod=mod -trimpath", "-trimpath"}, + {"-trimpath -mod=mod -tags x", "-trimpath -tags x"}, + {"-trimpath", "-trimpath"}, + } { + if got := goFlagsWithoutMod(c.in); got != c.want { + t.Errorf("goFlagsWithoutMod(%q) = %q, want %q", c.in, got, c.want) + } + } +} + +// TestRunPinClosure_IteratesToAFixedPointAfterABump is the proof the BUMP arm runs — the arm the +// live gate's two fixtures never reach, because both end at rung 1 with nothing adopted. It builds a +// REAL three-module fixture on disk, so the loop is not simulated: +// +// anchor (this project) requires example.com/org/repo/candy/dep, replaced at v0.1.0 with a source tree whose own +// package DOES NOT COMPILE ("undefined: retired"). The newest version's tree compiles. +// +// Rung 1 must classify the failure as BUMP (the symbol is absent from the newest source), ADOPT it +// (write the replace into the workspace, keyed by the SAME module path the lookup used), re-compose +// the overlay, and take a SECOND rung that compiles. A bump that is never adopted — the defect the +// review named — fails this test at rung 1. +func TestRunPinClosure_IteratesToAFixedPointAfterABump(t *testing.T) { + root := t.TempDir() + writeFile := func(rel, body string) { + p := filepath.Join(root, rel) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatalf("mkdir %s: %v", filepath.Dir(p), err) + } + if err := os.WriteFile(p, []byte(body), 0o644); err != nil { + t.Fatalf("write %s: %v", p, err) + } + } + writeFile("anchor/go.mod", "module example.com/anchor\n\ngo 1.24\n\nrequire example.com/org/repo/candy/dep v0.1.0\n\nreplace example.com/org/repo/candy/dep => ../depold\n") + writeFile("anchor/main.go", "package main\n\nimport \"example.com/org/repo/candy/dep\"\n\nfunc main() { dep.Fine() }\n") + // The pinned version's own package does not compile — the shape a retired symbol leaves behind. + writeFile("depold/go.mod", "module example.com/org/repo/candy/dep\n\ngo 1.24\n") + writeFile("depold/dep.go", "package dep\n\nfunc Fine() { retired() }\n") + // The newest published version carries the fix. + writeFile("depnew/go.mod", "module example.com/org/repo/candy/dep\n\ngo 1.24\n") + writeFile("depnew/dep.go", "package dep\n\nfunc Fine() {}\n") + + anchor := filepath.Join(root, "anchor") + depNew := filepath.Join(root, "depnew") + + orig := newestModuleVersionFn + newestModuleVersionFn = func(anchorDir, module string) (string, string) { + if module != "example.com/org/repo/candy/dep" { + t.Fatalf("the version lookup must be keyed on the MODULE the failing package belongs to, got %q", module) + } + return "v0.2.0", depNew + } + defer func() { newestModuleVersionFn = orig }() + + status, msg := runPinClosure(root, params.PinClosureInput{Anchor: anchor}) + if status != spec.StatusPass { + t.Fatalf("runPinClosure = %s, want PASS after the bump is adopted\n%s", status, msg) + } + for _, want := range []string{ + "closure: BUMP", // the classification + "example.com/org/repo/candy/dep", // the module that was adopted, by its REAL path + "2: the composed set compiles", // the SECOND rung: the iteration happened + "1: 1 failing package(s)", // rung 1 saw the breakage + } { + if !strings.Contains(msg, want) { + t.Errorf("report must contain %q — it is the evidence the loop adopted and iterated\n%s", want, msg) + } + } +} + +// TestWorkspaceGoDerivesTheFloorFromTheAnchor pins the `go` line of the generated workspace: the +// anchor's own directive, never a literal, and a conservative floor only when it cannot be read. +func TestWorkspaceGoDerivesTheFloorFromTheAnchor(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module example.com/x\n\ngo 1.24\n"), 0o644); err != nil { + t.Fatalf("write go.mod: %v", err) + } + if got := workspaceGo(dir); got != "1.24" { + t.Errorf("workspaceGo = %q, want the anchor's own directive 1.24", got) + } + if got := workspaceGo(filepath.Join(dir, "missing")); got != "1.21" { + t.Errorf("workspaceGo(no go.mod) = %q, want the conservative floor 1.21", got) + } +} + +// TestWriteOverlay_AdoptsADirOrAVersion pins both replace forms: a downloaded dir is used AS IS (no +// version form, so the next build does not re-resolve it), a version is written in the module form. +func TestWriteOverlay_AdoptsADirOrAVersion(t *testing.T) { + overlay := writeOverlay(t.TempDir(), "1.24", []string{"/src/anchor"}, map[string]string{ + "example.com/by-dir": "/cache/example.com/by-dir@v0.2.0", + "example.com/by-version": "v0.2.0", + }) + data, err := os.ReadFile(overlay) + if err != nil { + t.Fatalf("read overlay: %v", err) + } + got := string(data) + for _, want := range []string{ + "go 1.24", + "example.com/by-dir => /cache/example.com/by-dir@v0.2.0", + "example.com/by-version => example.com/by-version v0.2.0", + } { + if !strings.Contains(got, want) { + t.Errorf("overlay must contain %q\n%s", want, got) + } + } + if strings.Contains(got, "go 1.26.4") { + t.Errorf("the workspace must not carry the retired hardcoded floor\n%s", got) + } +} + +// TestRunPinClosure_AdoptedButStillFailingIsAnEDIT is the false-BUMP guard. A module whose newest +// version fixed the named symbol but whose composed set STILL does not compile must not be dropped +// from the verdicts: dropping it left `edits` empty and the report claiming "advance: CLEAN — need +// only a pin bump" about a module that provably fails, which is the dangerous direction this verb +// exists to refuse. +func TestRunPinClosure_AdoptedButStillFailingIsAnEDIT(t *testing.T) { + root := t.TempDir() + write := func(rel, body string) { + p := filepath.Join(root, rel) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.WriteFile(p, []byte(body), 0o644); err != nil { + t.Fatalf("write %s: %v", p, err) + } + } + write("anchor/go.mod", "module example.com/anchor\n\ngo 1.24\n\nrequire example.com/dep v0.1.0\n\nreplace example.com/dep => ../depold\n") + write("anchor/main.go", "package main\n\nimport \"example.com/dep\"\n\nfunc main() { dep.Fine() }\n") + write("depold/go.mod", "module example.com/dep\n\ngo 1.24\n") + write("depold/dep.go", "package dep\n\nfunc Fine() { retired() }\n") + // The "newest version" fixed `retired` but introduced a DIFFERENT breakage: the bump is not the + // fix, and the verb must say so rather than report the module as bumpable. + write("depnew/go.mod", "module example.com/dep\n\ngo 1.24\n") + write("depnew/dep.go", "package dep\n\nfunc Fine() { alsoMissing() }\n") + + orig := newestModuleVersionFn + newestModuleVersionFn = func(anchorDir, module string) (string, string) { + return "v0.2.0", filepath.Join(root, "depnew") + } + defer func() { newestModuleVersionFn = orig }() + + status, msg := runPinClosure(root, params.PinClosureInput{Anchor: filepath.Join(root, "anchor")}) + if status != spec.StatusFail { + t.Fatalf("runPinClosure = %s, want FAIL — a module whose newest version still does not compile is not bumpable\n%s", status, msg) + } + if strings.Contains(msg, "advance: CLEAN") { + t.Errorf("the report must not claim the pair is CLEAN when the adopted module still fails\n%s", msg) + } + if !strings.Contains(msg, "closure: EDIT") || !strings.Contains(msg, "still fails") { + t.Errorf("the re-failing module must be reported as EDIT carrying the failure\n%s", msg) + } +} diff --git a/candy/plugin-task/plugin.go b/candy/plugin-task/plugin.go index 1cd04c0..fa08f2c 100644 --- a/candy/plugin-task/plugin.go +++ b/candy/plugin-task/plugin.go @@ -55,6 +55,9 @@ func NewMeta() pb.PluginMetaServer { {Class: "verb", Word: "file-parity", InputDef: "#FileParityInput"}, {Class: "verb", Word: "splice-region", InputDef: "#SpliceRegionInput"}, {Class: "verb", Word: "module-pins", InputDef: "#ModulePinsInput"}, + // pin-closure: the TRANSITIVE CLOSURE of a pin advance — compile, collect + // every failing module, classify BUMP-vs-EDIT, repeat to a fixed point. + {Class: "verb", Word: "pin-closure", InputDef: "#PinClosureInput"}, {Class: "verb", Word: "prune", InputDef: "#PruneInput"}, }, schemaFS) } @@ -77,7 +80,7 @@ func (p *provider) Invoke(ctx context.Context, req *pb.InvokeRequest) (*pb.Invok // (`{"args":[...]}`); the verb path carries a plugin_input map // (`{"task":...,"param":[...]}`), distinguished by the input shape. return invokeOpRun(ctx, req) - case "git-submodules", "file-parity", "splice-region", "module-pins", "prune": + case "git-submodules", "file-parity", "splice-region", "module-pins", "pin-closure", "prune": // The generic maintenance verbs: decode the op envelope's nested // plugin_input and run the host-native handler. return invokeMaintenanceVerb(req) diff --git a/candy/plugin-task/schema/task.cue b/candy/plugin-task/schema/task.cue index d7bd8fc..1634bf3 100644 --- a/candy/plugin-task/schema/task.cue +++ b/candy/plugin-task/schema/task.cue @@ -96,6 +96,20 @@ end: string } +// #PinClosureInput resolves the TRANSITIVE CLOSURE of a pin advance: compile the +// composed set, collect EVERY failing module, classify each BUMP (its newest +// published version already carries the fix) or EDIT (it does not), and iterate to +// a fixed point. Domain-neutral: the caller names the anchor module, any modules it +// has already composed, and the pin prefix whose versions matter. +#PinClosureInput: { + // anchor is the module dir whose go.mod names the pins (default: /charly). + anchor?: string + // compose lists module dirs already adopted into the probe (they win over the pins). + compose?: [...string] @go(Compose) + // module_prefix limits the reported pins to one org (empty = every require line). + module_prefix?: string @go(ModulePrefix) +} + // #ModulePinsInput adopts a set of module pins from a source go.mod into every // module a glob matches, then tidies. #ModulePinsInput: { diff --git a/candy/plugin-task/verbs.go b/candy/plugin-task/verbs.go index 04929b7..bea2220 100644 --- a/candy/plugin-task/verbs.go +++ b/candy/plugin-task/verbs.go @@ -85,6 +85,12 @@ func runMaintenanceVerbIn(projDir, word string, input map[string]any) (spec.Stat return spec.StatusFail, derr.Error() } return runModulePins(projDir, in) + case "pin-closure": + var in params.PinClosureInput + if derr := decodeInput(input, &in); derr != nil { + return spec.StatusFail, derr.Error() + } + return runPinClosure(projDir, in) case "prune": var in params.PruneInput if derr := decodeInput(input, &in); derr != nil { diff --git a/scripts/gate-task.sh b/scripts/gate-task.sh index 3ce7b6b..d2dcc86 100755 --- a/scripts/gate-task.sh +++ b/scripts/gate-task.sh @@ -756,6 +756,48 @@ mods_out="$("$CH" task mods)"; echo "$mods_out" | grep -q '0 failed' || { echo " grep -q 'github.com/opencharly/sdk v1.2.3' tools/m1/go.mod || { echo "FAIL: module-pins did not adopt the pin" >&2; exit 1; } mods2_out="$("$CH" task mods-check)"; echo "$mods2_out" | grep -q '0 failed' || { echo "FAIL: module-pins check must pass after adopt" >&2; exit 1; } +# --------------------------------------------------------------------------- +# 2f) verb:pin-closure — the TRANSITIVE CLOSURE of a pin advance, LIVE. +# The verb compiles the composed set under a generated overlay workspace and +# classifies every failing module: a BUMP when a published version already carries +# the fix, an EDIT when none does (the module needs its own cutover). Two anchors +# prove both ends without a network: a COMPILING module must report an EMPTY +# closure and pass, and a module with an undefined symbol must be classified EDIT +# and the advance REFUSED — the shape that must never be silently "adopted". +# --------------------------------------------------------------------------- +CLS="$WORK/closure" +mkdir -p "$CLS/anchor" "$CLS/broken" +printf 'module example.com/anchor\n\ngo 1.24\n' > "$CLS/anchor/go.mod" +printf 'package main\n\nfunc main() { println("ok") }\n' > "$CLS/anchor/main.go" +printf 'module example.com/broken\n\ngo 1.24\n' > "$CLS/broken/go.mod" +printf 'package main\n\nfunc main() { undefinedSymbol() }\n' > "$CLS/broken/main.go" +cat > "$CLS/charly.yml" <&2; printf '%s\n' "$cls_out" >&2; exit 1; } +printf '%s\n' "$cls_out" | grep -q 'closure: EMPTY' || { echo "FAIL: a compiling set must report an EMPTY closure" >&2; printf '%s\n' "$cls_out" >&2; exit 1; } +echo " closure EMPTY — the composed set compiles" +if cls_edit="$(cd "$CLS" && "$CH" task closure-edit)"; then echo "FAIL: an unresolved EDIT must fail, never pass" >&2; printf '%s\n' "$cls_edit" >&2; exit 1; fi +printf '%s\n' "$cls_edit" | grep -q 'closure: EDIT' || { echo "FAIL: the failing package must be classified EDIT" >&2; printf '%s\n' "$cls_edit" >&2; exit 1; } +printf '%s\n' "$cls_edit" | grep -q 'REFUSED' || { echo "FAIL: the advance must be REFUSED" >&2; printf '%s\n' "$cls_edit" >&2; exit 1; } +echo " the failing package classified EDIT; the advance REFUSED" + # --------------------------------------------------------------------------- # 3) verb:prune — reap merged-upstream worktrees + branches LIVE. # The fixture is a real repo whose `main` IS the merge base, with a merged @@ -862,4 +904,4 @@ prune2_out="$(cd "$PRUNE2" && "$CH" task prune-run)" || { echo "FAIL: prune must [ -d "$PRUNE2/gap" ] || { echo "FAIL: the uninitialized submodule dir must remain" >&2; exit 1; } echo " mode=prune completed cleanly; unmerged kept, merged reaped, uninitialized dir intact" -echo "gate-task: PASS — command:task + all five maintenance verbs executed live against charly $CHARLY_REF" +echo "gate-task: PASS — command:task + all six maintenance verbs executed live against charly $CHARLY_REF"