Epic: make the codebase agent-legible — fix misleading self-description, ambiguous contracts, and legacy leftovers
@ndeloof is already working on this.
Since Aug 17, 2026.
- Dominant language
- Go
- Stars
- 38.2k
- Forks
- 5.8k
- Avg merge
- 2d 14h
- Merged PRs (30d)
- 55
Description
"This issue was filed by an AI agent on a human's behalf. The human submitter may not have independently verified the report."
Goal
Coding agents (and new human contributors) navigate this codebase by reading it locally: file names, doc comments, option structs, docs, and error messages are their map. Today several of those signposts are inaccurate, ambiguous, or point at code that no longer exists, so a reader who trusts them lands on wrong conclusions and produces plausible-looking regressions.
This epic tracks making the codebase self-describing and truthful: fix documentation and comments that lie, make implicit invariants explicit, remove legacy leftovers, and add guardrails so the map stays accurate.
The findings below come from a systematic legibility review (5 parallel deep dives: lifecycle backend, CLI layer, pkg/api contract, build/watch, cross-cutting), with each claim spot-verified against the current code. Every item lists file references. Items marked 🐛 are arguably user-facing bugs discovered along the way and may deserve extraction into standalone issues.
A. The code misdescribes its own structure — split into focused PRs (supersede #14075)
-
→ PR #14129 (merged)pkg/compose/convergence.gois a false friend: theconvergencetype was removed (fbea647b9), but the file keeps its name and its doc comment ("convergence manages service's container lifecycle") now sits on top of the unrelatedgetScale()(convergence.go:47-52). The file is a grab-bag (naming, dependency waiting, container creation, start helpers). Rename/split it. -
8 comments reference deleted code or stale line numbers:→ PR #14130 (merged)reconcile.go:195,:245,:349,:378,:719("matching the previousensureNetwork/ensureVolume/convergence.go behavior"),reconcile.go:1012("same way as convergence.go:138-160" — those lines now hold unrelated code),observed_state.go:154-155,:369. Replace historical references with actual behavioral statements. -
→ PR #14131 (merged)docs/sdk.mddocuments aprogresspackage that does not exist (progress.NewTTYWriter/NewPlainWriter/NewJSONWriter/NewQuietWriter, sdk.md:148-155;WithEventProcessor(progress.EventProcessor)at :110 — actual type isapi.EventProcessor). The real renderers live incmd/display, which an SDK consumer cannot reasonably import. -
→ PR #14132 (merged)AGENTS.mdtest instructions are misleading: "Test unit:go test ./pkg/..." actually starts the e2e suite (no file inpkg/e2ecarries ane2ebuild tag; the documented-tags e2eis a no-op; CI excludes e2e by grep inDockerfile:112). Document the real local-unit command and gate e2e behind a build tag ortesting.Short. -
5 error messages say "set DOCKER_BUILDKIT=1 to use BuildKit" (e.g.→ PR #14133 (merged)build_classic.go:133-147) but the internal BuildKit builder was removed (af579ebd4); the real remedy is installing buildx. Only two build paths remain (bake via buildx subprocess vs. classic daemon API) — update messages and any remaining "three builders" wording. -
AGENTS.mdhas no architecture map. A ~30-line section would prevent most wrong turns: the two lifecycle engines and who calls which (see C below), theRun(ctx, …, "op")wrapper convention distinguishing exported operations from internal helpers, the stdout/stderr conventions, and Docker Desktop integration points (internal/desktop,pkg/compose/up.go:88-92,publish.go:100,cmd/formatter/shortcut.go:249). → PR #14134 (open)
B. pkg/api promises things the implementation does not honor
-
→ PR #14078 (merged)Serviceshas three different semantics across option structs: real project filter (ProjectLoadOptions, read atpkg/compose/loader.go:125,146), recreate-policy selector that does not filter (CreateOptions—CreatewithServices: []string{"web"}still creates the whole project;reconcile.goiterates all ofproject.Services), and never read at all (StartOptions.Services— unused instart.go). The CLI compensates by shrinking*types.Projectupfront (cmd/compose/up.go:70), an invariant written nowhere. Document per-struct, or fix. -
→ PR #14077 (merged)ScaleOptionshas no replica count: the CLI mutates the model (cmd/compose/scale.go:100service.SetScale) before calling the backend, while the interface doc promises "Scale manages numbers of container instances running per service". -
Half of→ PR #14078 (merged)StartOptions(Attach,OnExit,ExitCodeFrom,Watch,NavigationMenu) is only honored byUp, never byStart(pkg/compose/start.go:37-95reads onlyProject/AttachTo/WaitTimeout/Wait). Nothing inapi.gosays "Up-only". -
→ PR #14078 (merged)RunOptionsembedsCreateOptionsbut only 4 fields are propagated (pkg/compose/run.go:283-288);Execreuses the same struct while reading onlyService/Index. The embedding suggests completeness that does not exist. - Dead surface that describes a nonexistent contract:
api.STARTING/RUNNING/…constants never produced (Stack.Statusis actually"running(2)"frompkg/compose/ls.go:61,95),Stack.Reasonnever populated but read incmd/compose/list.go:140,ConfigOptionsorphaned,ServiceStatusdead (and shadowed by an unrelated type independencies.go:33),api/errors.gosentinels (ErrAlreadyExists,ErrForbidden, …) never returned,BuildOptions.Attestationswritten once and never read,PsOptions.Project/AttachOptions.Projectnever read,api.go:748doc comment describes the wrong constant. -
DownOptions.Imagesis stringly-typed; its legal values (ImagePruneNone/Local/All) live inpkg/compose/image_pruner.go:41-47, not inpkg/api, and validation happens mid-downafter containers are already removed. → #14149 - 🐛
--dry-rundoes not intercept every mutating operation:pkg/dryrun/dryrunclient.go:330delegatesContainerCommitto the real client, socompose commit --dry-runactually creates an image. More generally the set of intercepted operations is defined only by method position relative to the comment atdryrunclient.go:308— add a declarative list/test. → #14150 - No stability marking: nothing distinguishes the public SDK surface (
pkg/api,NewComposeService) from incidentally-exported internals (pkg/composeexposesImagePruner,ReconcileOptions,InDependencyOrder, …).Close()isn't even on theapi.Composeinterface (pkg/compose/compose.go:226). Mocks are in sync today but nothing in CI regenerates and diffs them (Makefile:97-99still passes the pre-renameServicearg).
C. Lifecycle invariants are implicit (highest regression risk)
- Two lifecycle engines coexist and the boundary is undocumented: the plan-based reconciler has exactly one caller (
create.go:122);start/stop/restart/downuse the imperativeInDependencyOrderengine. The plan almost never emitsOpStartContainer— startup is a second, disjoint pass (start.go:52-68) with a different dependency traversal. Document (or converge) before anyone "fixes" the reconciler'scase StateCreated: // nothing to do. → convergence tracked in #14081 (redesigned; lot 0 merged: tests PR #14104, startMx fix PR #14106) - Label taxonomy is undocumented and inconsistent:
- the de-facto project-membership invariant is carrying
com.docker.compose.config-hash(containers.go:74-88adds it to every default filter), not project+service labels; - hook helper containers carry project/service labels but no config-hash — their own comment (
pre_start.go:140-147) claimscompose downcan find them; it cannot; oneoffsemantics differ between the Go predicate (containers.go:160-163: missing label ⇒ not one-off) and the daemon filter (filters.go:44-50: missing label ⇒ excluded), and the literal"True"is compared in ≥5 files with no constant;com.docker.compose.depends_onis a serialized mini-language (svc:condition:restart) written from map iteration (non-deterministic order,create.go:593-597) and parsed without validation (compose.go:385-406,Requiredsilently forced to true);container-number: absent on one-offs, parse failures silently become0(observed_state.go:322) while being used as identity in planResourceIDs and container names;- a second, undeclared label registry exists for OCI publish (
internal/oci/push.go:77-91,publish.go:134-211) and bridge (transformers.go:32).
Document the full taxonomy inpkg/api/labels.go(who writes, who reads, membership invariant, compat policy).
- the de-facto project-membership invariant is carrying
-
🐛→ PR #14105 (merged)waitDependenciesswallows its timeout:convergence.go:186-189returnsnilonctx.Done(), making theDeadlineExceededtranslation at:257-261(andstart.go:87-93's "application not healthy after %s") mostly unreachable. Four callers (up --wait,restart,run,start) depend on an error the callee doesn't reliably produce; no test covers either message. -
ObservedStatedocuments 2 buckets but has 4: one-off running containers are silently dropped, and disabled-profile services land inContainersyet are never reconciled (observed_state.go:164-177vsreconcile.go:587) —emitRunningEventswas already patched around this symptom (observed_state.go:371-373, #13882). -
isOrphaned(containers.go:145-158) conflates "service absent from model" with "exitedcompose runcontainer", soup --remove-orphansdeletes exited one-offs of perfectly declared services;down --remove-orphansalso removes running one-offs through a different branch (down.go:47-50vs:86-94). Intentional or not, it deserves a name/doc that says so. → PR #14142 (open) - Load-bearing subtleties with no local warning:
sortContainers(reconcile.go:1012-1034) — an uncommented double inversion is the scale-down policy; three deliberately-divergent views of observed containers (r.observed.Containersmutated as a communication channel atreconcile.go:502-505, the memoized snapshot, and the executor's live view) with the "do not fix" comment present at only one of the three sites;getCreateConfigs(create.go:252-425, 172 lines) mixes hash computation, project mutation via network-map pointer, network I/O and file reads — any added field silently invalidates the config hash of every container in the field.
D. Environment variable resolution is inconsistent and unregistered
- 🐛
COMPOSE_REMOVE_ORPHANShas three resolution mechanisms:upreads it inPreRunEafter the project.envis injected (up.go:124-126);down/killread it at cobra-tree construction time, before.envinjection (down.go:66-67,kill.go:52-53) — so the variable set in the project's.envworks forupbut notdown/kill;runnever reads it. Its siblingCOMPOSE_IGNORE_ORPHANSis read from a different source (project.Environment,up.go:130), andcreatereads neither despite sharing the options struct (createOptions.ignoreOrphansnever assigned, still sent to the API). → PR #14139 (open) -
setEnvWithDotEnv(compose.go:692-728) — the mechanism that re-injectsCOMPOSE_*keys from the project.envinto the process environment — is documented nowhere, is skipped for remote (OCI/Git) configs, and only benefits variables read afterPersistentPreRunE. Which variables are read when is currently unknowable without readingRootCommandline by line. - No central env-var registry: ~25 recognized variables are scattered across 5+ constant files plus compose-go-handled ones (
COMPOSE_FILE,COMPOSE_PROFILES, …);docs/reference/compose.mddocuments 6 of them. A single documented table (name → where read → process-env or project-env → default) would remove a whole class of wrong guesses. → PR #14145 (open) - Legacy/experimental leftovers:
COMPOSE_BAKEis read by no production code (only a stale e2e env); 🐛COMPOSE_EXPERIMENTAL_WATCH_TAR=0now hard-breakswatch("no available sync implementation") since the non-tar syncer was removed (d20340299);COMPOSE_EXPERIMENTAL_GIT_REMOTE/_OCI_REMOTEdefault to true despite the name;internal/experimentalis dead code (no importers) describing a feature-flag mechanism that is not wired. → PR #14085 (open)
E. Global mutable state and output-channel sprawl
-
→ PR #14103 (merged)display.Modepackage global: written from 4 files, not assigned in the default interactive case (compose.go:657-667), compared once against a string literal instead of the constant (up.go:350). -
pkg/compose/watch.go:82— theWatchermutex is package-level (all watchers in a process serialize), and theStart/Stopstate machine leaks a stale error + non-nilstopFnon failed start; exercised by the interactivewshortcut (shortcut.go:286-310). → PR #14116 (community, open) -
swarmEnabledis a package-levelsync.Oncecache (compose.go:474-479) shared across service instances and never reset in tests (errors are cached forever, unlike the instance-levelruntimeVersionCachenext to it). -
--quietis implemented by reassigningos.Stdout(build.go:104-108,config.go:96-100); the defaultpromptandpublishprint via barefmt.PrintlnbypassingWithStreams(compose.go:78-82,publish.go:86). - The shared
*ProjectOptionssingleton is mutated by subcommands (build.go:161setsopts.All = true;completion.gosetsOffline), andpsdeclares its ownAllfield shadowingProjectOptions.All— same identifier, two meanings, neighboring files.buildalso re-registers a hidden--progressflag bound to the same variable as the root flag with a different default (build.go:144). - At least 7 output channels with no written convention (progress→stderr, TTY detection probing
Err()in one place — with a good comment atcompose.go:651-655— andOut()in another (colors.go:80), interactive menu and ANSI cursor sequences hardcoded toos.Stdoutincmd/formatter); theEventProcessorcontract (Start→…→Done, non-reentrant) is undocumented and already violated by nestedpublish→pushoperations, with a blocking unbuffereddonechannel in the TTY renderer (tty.go:44,177-186).
F. Dual code paths where only one is complete
- Bake vs classic build: the decision matrix exists only as code (
build.go:99-106+buildWithBake); classic silently ignorescache_from/cache_to,no_cache_filter,shm_size,ulimits,entitlements,provenance/sbom,dockerfile_inline(compareimageBuildOptions()withbakeTarget); 🐛 classic--pushpushes the whole project once per built service (build_classic.go:102-106); classic-built images lack the compose labels bake applies (getImageBuildLabelshas one caller), skewing everything that filters images by project (down --rmi local, watch prune); dry-run bake returns a differently-keyed map than the real path (build_bake.go:585-596vs:412-424);api.ImageBuilderLabelis write-only. - Watch has per-platform implementations chosen by a build tag set only in
Dockerfile:87: dev builds on macOS run the naive watcher while shipped binaries run FSEvents — debugging a macOS watch bug locally does not exercise shipped code;watcher_naive.go:35-38's platform comment is wrong;go build -tags fsnotifyon Linux doesn't compile. -
handleWatchBatch(watch.go:533-608) has a load-bearing implicit order (rebuild → sync → restart → exec) and stores indices into the rules slice for exec hooks — any reordering ofrulessilently corrupts hook execution. - Project resolution:
projectOrNamevstoProjectName(compose.go:246-289) give opposite precedence, and a loading error is silently swallowed whenCOMPOSE_PROJECT_NAMEis set — commands fall back to label-based reconstruction without saying so. Service-name validation is inconsistent:restart/waitsilently no-op on unknown names whilestop/ps/etc. error, and two commands re-implement validation by hand (ps.go:101-106,volumes.go:66-72). → #14151 - Backend construction happens three ways (
withBackendhelper, inlineNewComposeService(dockerCli, backendOptions.Options...), and bareNewComposeService(dockerCli)inbridge.go:68/config.go) — the bare form silently drops--dry-run,--paralleland the progress mode; a new command written by imitation inherits the bug.
G. Guardrails (keep the map accurate)
-
CI check:→ PR #14102 (merged)make mocks+git diff --exit-code(and fix the stale mockgen invocation). -
Compile-test the→ PR #14136 (merged)docs/sdk.mdexamples. - Unit test the
.env→ env-var → flag resolution order (theCOMPOSE_REMOVE_ORPHANSdivergence in D is currently only pinned by one e2e test onup). → PR #14139 (open) -
A short "legibility" section in CONTRIBUTING/AGENTS.md: comments must state behavior, not history ("matching the previous X behavior" is how items in section A were born).→ PR #14135 (merged)
Suggested sequencing
- Documentation-only truth fixes (A, label taxonomy, env registry) — no runtime risk, immediate payoff for any reader.
- Mechanical deletions/renames (dead API surface,
convergence.go,internal/experimental, stale messages). - Extract the 🐛 items as standalone bug issues (
commit --dry-run, classic--push,waitDependenciestimeout,COMPOSE_REMOVE_ORPHANSdivergence,COMPOSE_EXPERIMENTAL_WATCH_TAR). - Contract honesty in
pkg/api(docs first, then field removal/deprecation). - Structural work (single lifecycle engine story, env resolution unification) — each large enough to deserve its own design discussion.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Assessment
This issue has not been assessed yet.