Deep Engineering

ЗАМЕР

bench/gomod/internals.go

Скрипт, которым получены числа в статье, и запись прогона. Файл читается на сборке из репозитория — это тот самый код, который запускали, а не его копия.

Цитируется в статье
/ru/interview/golang/modules
Как запустить
go run bench/gomod/internals.go
/usr/local/go1.25.1/bin/go run bench/gomod/internals.go
go run bench/gomod/practice.go

Запись прогона

Замеры для урока «Модули и зависимости»

Файл Что делает
internals.go десять наблюдений, каждое — запуск настоящей команды go на маленьком графе модулей: выбор минимальной версии (go list -m all, go mod graph), go get вверх (явной версией, -u, без версии и @latest), вниз (без подходящей версии b и с ней), go mod tidy и // indirect, содержимое go.sum и подмена хеша и содержимого версии, две мажорные версии c и c/v2 в одной сборке, replace в главном модуле и в go.mod зависимости, строки go и toolchain при GOTOOLCHAIN=local и auto; после аудита 14.10.2026 — разделы 8–10 в конце: минимальный модуль (go mod init, go run), go build -mod=mod, replace в рабочем пространстве go.work и замена со своими требованиями
practice.go источник ответов двух практических задач урока: какую версию z увидят x и y и сколько строк окажется в go.sum
go run bench/gomod/internals.go
/usr/local/go1.25.1/bin/go run bench/gomod/internals.go
go run bench/gomod/practice.go

Сети не нужно. Оба скрипта сами собирают прокси модулей в файловой системе — протокол GOPROXY это прямо разрешает (go help goproxy: прокси может отдавать файлы и по адресу file:///). Раскладка — <модуль>/@v/list, <версия>.info, <версия>.mod, <версия>.zip; zip собирается archive/zip, все файлы в нём лежат под префиксом <модуль>@<версия>/. Модули example.com/{a,b,c,c/v2,olda,oldb,unused,fresh} и example.com/{x,y,z,w} выдуманы, их исходники — строки внутри скриптов.

Окружение изолировано. Свои GOMODCACHE, GOCACHE и GOPATH во временном каталоге, GOPROXY=file://…, GOSUMDB=off (без сети базы контрольных сумм нет; go.sum проверяется как обычно), GOFLAGS пуст — поведение по умолчанию, -mod=readonly, — GOENV=off, GOWORK=off, GOTOOLCHAIN=local везде, кроме раздела 7, где GOTOOLCHAIN — предмет наблюдения. Общие кеши не трогаются; в конце скрипт делает go clean -modcache своего кеша и удаляет временный каталог.

Вывод детерминирован: хеши h1: зависят только от имён и содержимого файлов модуля. Временные пути заменены плейсхолдерами <tmp> и <proxy>, табуляция — четырьмя пробелами; строки go: downloading … опущены, а подряд идущие go: finding … и go: found … отсортированы — их порядок зависит от параллельной загрузки.

Записи прогонов — в runs/: internals.txt и practice.txt — go1.24.7 linux/amd64, internals-go1.25.txt — go1.25.1. Прогоны совпали строка в строку везде, кроме раздела 7 и строки go в go.mod после go mod init (раздел 8: go 1.24.7 и go 1.25.1). Времени ничего не замеряет; прогон internals.go занимает около 15 секунд, почти всё — холодная сборка fmt в пустом GOCACHE.

Что получилось

Выбрана наибольшая из требуемых, а не последняя. a требует c v1.1.0, b — c v1.3.0, в прокси у c есть v1.1.0 v1.3.0 v1.5.0 v1.9.0. go list -m all даёт example.com/c v1.3.0, и a.CVersion() возвращает v1.3.0 — копия c в сборке одна.

go get пишет требование, а не выбирает версию. go get c@v1.5.0 — upgraded example.com/c v1.3.0 => v1.5.0, и оба модуля видят v1.5.0. go get c@v1.1.0 понизил c и, раз другой версии b в прокси нет, удалил b из требований: removed example.com/b v1.0.0, после чего сборка не собралась. Когда в прокси добавлен b v0.9.0 с требованием c v1.1.0, та же команда понизила и его: downgraded example.com/b v1.0.0 => v0.9.0, оба модуля видят v1.1.0. Выше требуемой версию подняли только явные команды: go get -u ./..., go get example.com/c и go get example.com/c@latest — каждая v1.3.0 => v1.9.0.

go mod tidy сверяет go.mod с импортами, go build — нет. Лишнее требование tidy -v удалил (unused example.com/unused); импорт без требования go build не собрал (no required module provides package), а tidy дописал. Пометка // indirect ушла, как только app импортировал c сам.

go.sum — в этом графе по две строки на модуль сборки; число строк — не правило. После tidy в нём 6 строк: хеш архива и хеш go.mod для a, b, c v1.3.0. Для невыбранной c v1.1.0 строки нет — зависимости объявляют go 1.22, и её go.mod не читался. С go 1.16 у зависимостей строк 7: добавилась example.com/c v1.1.0/go.mod. Её хеш совпал с хешем go.mod у v1.3.0 — файлы одинаковые. Без go.sum — missing go.sum entry; с испорченным хешем и с подменённым в прокси содержимым той же версии — checksum mismatch и SECURITY ERROR, и с тёплым кешем модулей, и с пустым.

c и c/v2 — разные модули. Одна программа напечатала v1.9.0 и v2.0.0. Требование example.com/c v2.0.0 отвергнуто при разборе go.mod: should be v0 or v1, not v2.

replace — только в главном модуле. С replace example.com/c => ../c-local go list -m показал v1.3.0 => ../c-local, оба модуля взяли код из каталога (local copy), строк про c в go.sum не осталось. Несуществующий каталог в replace главного модуля — ошибка replacement directory ../c-missing does not exist; тот же несуществующий каталог в go.mod модуля b — не замечен вовсе.

Строка go — версия языка и наименьший тулчейн. С go 1.21 for i := range 3 не собрался (requires go1.22 or later), с go 1.22 напечатал 012. На go1.24.7 модуль с go 1.25.0 при GOTOOLCHAIN=local не собрался (go.mod requires go >= 1.25.0), при auto — попытка скачать тулчейн (toolchain not available: в файловом прокси его нет); строка toolchain go1.25.1 при local проигнорирована. Зависимость с go 1.25.0: на go1.24.7 go get отказал, на go1.25.1 — upgraded go 1.24 => 1.25.0.

Практика. x требует z v1.2.0, y — z v1.4.0, в прокси есть v1.7.0: оба видят v1.4.0. В go.sum 8 строк — по две на x, y, w и z v1.4.0.

Минимальный модуль (раздел 8). go mod init example.com/hello в пустом каталоге с одним main.go на стандартной библиотеке, go run . печатает module: example.com/hello; в go.mod — путь и go 1.24.7 (на go1.25.1 — go 1.25.1), требований нет.

-mod=mod (раздел 9). Тот же импорт без требования: go build без флагов отказывает, go build -mod=mod находит example.com/unused v1.0.0 и дописывает требование в go.mod.

replace в рабочем пространстве и требования замены (раздел 10). go.work с use ./app ./b-dev, где b-dev — копия b с replace example.com/c => ../c-local: go list -m показывает example.com/c v1.3.0 => ./c-local, оба модуля печатают local copy; без go.work — снова v1.3.0. Замена c => ../c-req, чей go.mod требует example.com/unused: после tidy в go list -m all появился example.com/unused v1.0.0, программа печатает c-req + unused.

Скрипт

617 строк
//go:build ignore

// Модули и зависимости: что на самом деле делают go.mod, go.sum, go get,
// go mod tidy, replace и строка go.
//
// ЗАЧЕМ ЭТА ПРОГРАММА. Вопросы о модулях на собеседовании звучат как
// справочные — «что лежит в go.sum», «какую версию выберет go». Ответ,
// который отличает знающего, — о поведении: из двух требований выбирается
// большее, а не последняя версия в прокси; go.sum хранит хеш даже тех
// версий, что в сборку не попали; replace в чужом go.mod не действует.
// Здесь каждое такое поведение получено запуском команды go, а не
// пересказано.
//
// СЕТИ НЕТ, И ОНА НЕ НУЖНА. Программа сама собирает прокси модулей в
// файловой системе: протокол GOPROXY — это просто файлы
// <модуль>/@v/list, <версия>.info, .mod и .zip, и `go help goproxy`
// прямо разрешает file:///. Модули example.com/{a,b,c,c/v2,olda,oldb,unused,fresh}
// — выдуманные и маленькие; их исходники записаны ниже строками.
//
// ГРАФ, НА КОТОРОМ СТОИТ УРОК:
//
//	app → a v1.0.0 → c v1.1.0
//	app → b v1.0.0 → c v1.3.0
//	в прокси у c есть v1.1.0, v1.3.0, v1.5.0, v1.9.0 и отдельный модуль c/v2
//
// ДЕСЯТЬ СЮЖЕТОВ:
//
//  1. Минимальная версия: выбрана v1.3.0, хотя в прокси есть v1.9.0.
//  2. go get: повышение, понижение (с подходящей версией b и без неё),
//     -u, без версии и @latest.
//  3. go mod tidy: лишнее требование, // indirect, go build не правит go.mod.
//  4. go.sum: две строки на модуль, подмена хеша и подмена содержимого.
//  5. /v2: две мажорные версии в одной сборке.
//  6. replace на локальный каталог; replace в go.mod зависимости.
//  7. Строки go и toolchain: версия языка и минимальный тулчейн.
//  8. Минимальный модуль: go mod init и go run (полный пример).
//  9. go build -mod=mod правит go.mod сам.
//  10. replace в рабочем пространстве go.work и замена со своими требованиями.
//
// ОКРУЖЕНИЕ. Всё во временном каталоге: свои GOMODCACHE, GOCACHE, GOPATH,
// GOPROXY=file://…, GOSUMDB=off (базы контрольных сумм без сети нет; go.sum
// при этом проверяется как обычно), GOFLAGS пуст (поведение по умолчанию,
// -mod=readonly), GOENV=off, GOWORK=off, GOTOOLCHAIN=local — кроме мест, где
// сам GOTOOLCHAIN предмет наблюдения. Кеши пользователя не трогаются.
//
// В выводе временные пути заменены плейсхолдерами <tmp> и <proxy>, табуляция
// — четырьмя пробелами; строки «go: downloading …» опущены, а подряд идущие
// «go: finding …» и «go: found …» отсортированы: их порядок зависит от
// параллельной загрузки.
//
// ЗАПУСК:
//
//	go run bench/gomod/internals.go
//	/usr/local/go1.25.1/bin/go run bench/gomod/internals.go
package main

import (
	"archive/zip"
	"bytes"
	"fmt"
	"os"
	"os/exec"
	"path/filepath"
	"runtime"
	"sort"
	"strings"
)

var (
	work    string // временный каталог программы
	proxy   string // <work>/proxy — корень файлового прокси
	goBin   string // go той же версии, что запустила программу
	baseEnv []string
)

type result struct {
	out  string
	code int
}

// run запускает go в каталоге dir с добавочными переменными окружения.
func run(dir string, env []string, args ...string) result {
	cmd := exec.Command(goBin, args...)
	cmd.Dir = dir
	cmd.Env = append(append([]string{}, baseEnv...), env...)
	out, err := cmd.CombinedOutput()
	code := 0
	if ee, ok := err.(*exec.ExitError); ok {
		code = ee.ExitCode()
	} else if err != nil {
		panic(err)
	}
	return result{string(out), code}
}

func clean(s string) string {
	s = strings.ReplaceAll(s, "file://"+proxy, "<proxy>")
	s = strings.ReplaceAll(s, proxy, "<proxy>")
	s = strings.ReplaceAll(s, work, "<tmp>")
	s = strings.ReplaceAll(s, "\t", "    ")
	return s
}

// lines печатает вывод команды с отступом, без пустых строк и без
// «go: downloading».
func lines(out, indent string) {
	printed := false
	for _, l := range sortFinding(strings.Split(strings.TrimRight(out, "\n"), "\n")) {
		if strings.TrimSpace(l) == "" || strings.HasPrefix(l, "go: downloading ") {
			continue
		}
		fmt.Println(indent + strings.TrimRight(clean(l), " "))
		printed = true
	}
	if !printed {
		fmt.Println(indent + "(вывода нет)")
	}
}

// sortFinding сортирует подряд идущие строки «go: finding module for
// package …» и «go: found …»: tidy ищет пакеты параллельно, и их порядок от
// запуска к запуску разный.
func sortFinding(ls []string) []string {
	out := append([]string(nil), ls...)
	for _, prefix := range []string{"go: finding module for package ", "go: found "} {
		for i := 0; i < len(out); {
			j := i
			for j < len(out) && strings.HasPrefix(out[j], prefix) {
				j++
			}
			if j > i {
				sort.Strings(out[i:j])
				i = j
			} else {
				i++
			}
		}
	}
	return out
}

// cmd печатает команду, её вывод и — если не ноль — код выхода.
func cmd(dir string, env []string, args ...string) result {
	shown := "go " + strings.Join(args, " ")
	for _, e := range env {
		shown = e + " " + shown
	}
	fmt.Println("  $ " + clean(shown))
	r := run(dir, env, args...)
	lines(r.out, "    ")
	if r.code != 0 {
		fmt.Printf("    [код выхода %d]\n", r.code)
	}
	return r
}

func show(title string) {
	fmt.Println()
	fmt.Println(title)
	fmt.Println(strings.Repeat("─", len([]rune(title))))
}

func write(path, body string) {
	if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
		panic(err)
	}
	if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
		panic(err)
	}
}

func read(path string) string {
	b, err := os.ReadFile(path)
	if err != nil {
		panic(err)
	}
	return string(b)
}

// file печатает файл целиком с отступом.
func file(label, path string) {
	fmt.Println("  " + label + ":")
	for _, l := range strings.Split(strings.TrimRight(read(path), "\n"), "\n") {
		if l == "" {
			fmt.Println()
			continue
		}
		fmt.Println("    " + clean(l))
	}
}

// publish кладёт версию модуля в файловый прокси: .mod, .info, .zip и
// строку в list. Раскладка — из протокола GOPROXY: <модуль>/@v/<версия>.*;
// в zip все файлы лежат под префиксом <модуль>@<версия>/.
func publish(mod, ver string, files map[string]string) {
	dir := filepath.Join(proxy, mod, "@v")
	write(filepath.Join(dir, ver+".mod"), files["go.mod"])
	write(filepath.Join(dir, ver+".info"), fmt.Sprintf(`{"Version":%q,"Time":"2026-01-01T00:00:00Z"}`+"\n", ver))
	var buf bytes.Buffer
	zw := zip.NewWriter(&buf)
	names := make([]string, 0, len(files))
	for n := range files {
		names = append(names, n)
	}
	sort.Strings(names)
	for _, n := range names {
		w, err := zw.Create(mod + "@" + ver + "/" + n)
		if err != nil {
			panic(err)
		}
		w.Write([]byte(files[n]))
	}
	if err := zw.Close(); err != nil {
		panic(err)
	}
	write(filepath.Join(dir, ver+".zip"), buf.String())
	list := filepath.Join(dir, "list")
	old, _ := os.ReadFile(list)
	if !strings.Contains(string(old), ver+"\n") {
		write(list, string(old)+ver+"\n")
	}
}

// cFiles — модуль example.com/c версии ver: функция Version сообщает, из
// какой версии собран пакет.
func cFiles(path, ver, goLine string) map[string]string {
	return map[string]string{
		"go.mod": "module " + path + "\n\ngo " + goLine + "\n",
		"c.go":   "package c\n\nfunc Version() string { return \"" + ver + "\" }\n",
	}
}

// user — модуль, который зависит от c: a или b.
func user(name, goLine, cver, extra string) map[string]string {
	return map[string]string{
		"go.mod": "module example.com/" + name + "\n\ngo " + goLine + "\n\nrequire example.com/c " + cver + "\n" + extra,
		name + ".go": "package " + name + "\n\nimport \"example.com/c\"\n\n" +
			"func CVersion() string { return c.Version() }\n",
	}
}

const appMod = `module example.com/app

go 1.24

require (
	example.com/a v1.0.0
	example.com/b v1.0.0
)
`

const appMain = `package main

import (
	"fmt"

	"example.com/a"
	"example.com/b"
)

func main() {
	fmt.Println("a.CVersion() =", a.CVersion())
	fmt.Println("b.CVersion() =", b.CVersion())
}
`

// snapshot и restore сохраняют go.mod и go.sum модуля app между сюжетами.
type snap struct{ mod, sum string }

func snapshot(dir string) snap {
	sum, _ := os.ReadFile(filepath.Join(dir, "go.sum"))
	return snap{read(filepath.Join(dir, "go.mod")), string(sum)}
}

func restore(dir string, s snap) {
	write(filepath.Join(dir, "go.mod"), s.mod)
	write(filepath.Join(dir, "go.sum"), s.sum)
}

// cleanCache очищает кеш модулей: следующая команда скачает всё заново.
func cleanCache() {
	if r := run(work, nil, "clean", "-modcache"); r.code != 0 {
		panic(r.out)
	}
}

// grep печатает строки файла, содержащие needle, или «(строк нет)».
func grep(path, needle, indent string) {
	found := false
	for _, l := range strings.Split(read(path), "\n") {
		if strings.Contains(l, needle) {
			fmt.Println(indent + clean(l))
			found = true
		}
	}
	if !found {
		fmt.Println(indent + "(строк с " + strings.TrimSpace(needle) + " нет)")
	}
}

func main() {
	goBin = filepath.Join(runtime.GOROOT(), "bin", "go")
	if _, err := os.Stat(goBin); err != nil {
		goBin = "go"
	}
	var err error
	work, err = os.MkdirTemp("", "gomod")
	if err != nil {
		panic(err)
	}
	proxy = filepath.Join(work, "proxy")
	defer func() {
		run(work, nil, "clean", "-modcache")
		os.RemoveAll(work)
	}()

	var env []string
	for _, e := range os.Environ() {
		if !strings.HasPrefix(e, "GO") {
			env = append(env, e)
		}
	}
	baseEnv = append(env,
		"GOPROXY=file://"+proxy,
		"GOSUMDB=off",
		"GOFLAGS=",
		"GOENV=off",
		"GOWORK=off",
		"GOTOOLCHAIN=local",
		"GOPATH="+filepath.Join(work, "gopath"),
		"GOMODCACHE="+filepath.Join(work, "modcache"),
		"GOCACHE="+filepath.Join(work, "gocache"),
	)

	// Прокси: c в четырёх версиях, a и b, c/v2, unused и fresh.
	for _, v := range []string{"v1.1.0", "v1.3.0", "v1.5.0", "v1.9.0"} {
		publish("example.com/c", v, cFiles("example.com/c", v, "1.22"))
	}
	publish("example.com/a", "v1.0.0", user("a", "1.22", "v1.1.0", ""))
	publish("example.com/b", "v1.0.0", user("b", "1.22", "v1.3.0", "\nreplace example.com/c => ../c-fork\n"))
	// Те же a и b, но с go 1.16 в go.mod — до прореживания графа (Go 1.17).
	publish("example.com/olda", "v1.0.0", user("olda", "1.16", "v1.1.0", ""))
	publish("example.com/oldb", "v1.0.0", user("oldb", "1.16", "v1.3.0", ""))
	publish("example.com/c/v2", "v2.0.0", cFiles("example.com/c/v2", "v2.0.0", "1.22"))
	publish("example.com/unused", "v1.0.0", map[string]string{
		"go.mod":    "module example.com/unused\n\ngo 1.22\n",
		"unused.go": "package unused\n\nfunc Hello() string { return \"unused\" }\n",
	})
	publish("example.com/fresh", "v1.0.0", map[string]string{
		"go.mod":   "module example.com/fresh\n\ngo 1.25.0\n",
		"fresh.go": "package fresh\n\nfunc Hello() string { return \"fresh\" }\n",
	})

	app := filepath.Join(work, "app")
	write(filepath.Join(app, "go.mod"), appMod)
	write(filepath.Join(app, "main.go"), appMain)

	fmt.Printf("%s %s/%s | модули: go.mod, go.sum, выбор минимальной версии\n", runtime.Version(), runtime.GOOS, runtime.GOARCH)

	// ── 1 ──────────────────────────────────────────────────────────────
	show("1) ВЫБОР МИНИМАЛЬНОЙ ВЕРСИИ")
	fmt.Println("  граф: app → a v1.0.0 → c v1.1.0;  app → b v1.0.0 → c v1.3.0")
	cmd(app, nil, "mod", "tidy")
	cmd(app, nil, "list", "-m", "-versions", "example.com/c")
	cmd(app, nil, "mod", "graph")
	cmd(app, nil, "list", "-m", "all")
	cmd(app, nil, "run", ".")
	file("go.mod после go mod tidy", filepath.Join(app, "go.mod"))
	base := snapshot(app)

	// ── 2 ──────────────────────────────────────────────────────────────
	show("2) go get: ПОВЫСИТЬ, ПОНИЗИТЬ, -u")
	cmd(app, nil, "get", "example.com/c@v1.5.0")
	grep(filepath.Join(app, "go.mod"), "example.com/c ", "    go.mod: ")
	cmd(app, nil, "list", "-m", "example.com/c")
	cmd(app, nil, "run", ".")
	restore(app, base)
	fmt.Println("  понижение; других версий b в прокси нет:")
	cmd(app, nil, "get", "example.com/c@v1.1.0")
	grep(filepath.Join(app, "go.mod"), "example.com/", "    go.mod: ")
	cmd(app, nil, "run", ".")
	restore(app, base)
	cmd(app, nil, "get", "-u", "./...")
	grep(filepath.Join(app, "go.mod"), "example.com/c ", "    go.mod: ")
	restore(app, base)
	fmt.Println("  go get без версии и с @latest:")
	cmd(app, nil, "get", "example.com/c")
	grep(filepath.Join(app, "go.mod"), "example.com/c ", "    go.mod: ")
	restore(app, base)
	cmd(app, nil, "get", "example.com/c@latest")
	grep(filepath.Join(app, "go.mod"), "example.com/c ", "    go.mod: ")
	restore(app, base)
	fmt.Println("  понижение; в прокси добавлен b v0.9.0, он требует c v1.1.0:")
	publish("example.com/b", "v0.9.0", user("b", "1.22", "v1.1.0", ""))
	cmd(app, nil, "get", "example.com/c@v1.1.0")
	grep(filepath.Join(app, "go.mod"), "example.com/", "    go.mod: ")
	cmd(app, nil, "run", ".")
	restore(app, base)

	// ── 3 ──────────────────────────────────────────────────────────────
	show("3) go mod tidy И // indirect")
	fmt.Println("  лишнее требование: в go.mod дописан example.com/unused, импорта нет")
	cmd(app, nil, "mod", "edit", "-require=example.com/unused@v1.0.0")
	cmd(app, nil, "mod", "tidy", "-v")
	grep(filepath.Join(app, "go.mod"), "unused", "    go.mod: ")
	fmt.Println("  импорт без требования: добавлен файл extra.go с import \"example.com/unused\"")
	write(filepath.Join(app, "extra.go"), "package main\n\nimport \"example.com/unused\"\n\nvar _ = unused.Hello\n")
	cmd(app, nil, "build", "-o", os.DevNull, ".")
	cmd(app, nil, "mod", "tidy")
	grep(filepath.Join(app, "go.mod"), "unused", "    go.mod: ")
	os.Remove(filepath.Join(app, "extra.go"))
	restore(app, base)
	fmt.Println("  прямой импорт c: добавлен файл extra.go с import \"example.com/c\"")
	grep(filepath.Join(app, "go.mod"), "example.com/c ", "    go.mod до:    ")
	write(filepath.Join(app, "extra.go"), "package main\n\nimport \"example.com/c\"\n\nvar _ = c.Version\n")
	cmd(app, nil, "mod", "tidy")
	grep(filepath.Join(app, "go.mod"), "example.com/c ", "    go.mod после: ")
	os.Remove(filepath.Join(app, "extra.go"))
	restore(app, base)

	// ── 4 ──────────────────────────────────────────────────────────────
	show("4) go.sum: ХЕШИ И ПОДМЕНА")
	file("go.sum после go mod tidy", filepath.Join(app, "go.sum"))
	n := strings.Count(strings.TrimSpace(base.sum), "\n") + 1
	fmt.Printf("  строк в go.sum: %d\n", n)

	fmt.Println("  те же требования, но a и b объявляют в go.mod go 1.16 (olda, oldb):")
	old := filepath.Join(work, "old")
	write(filepath.Join(old, "go.mod"), "module example.com/old\n\ngo 1.24\n\nrequire (\n\texample.com/olda v1.0.0\n\texample.com/oldb v1.0.0\n)\n")
	write(filepath.Join(old, "main.go"), "package main\n\nimport (\n\t\"fmt\"\n\n\t\"example.com/olda\"\n\t\"example.com/oldb\"\n)\n\nfunc main() { fmt.Println(olda.CVersion(), oldb.CVersion()) }\n")
	cmd(old, nil, "mod", "tidy")
	fmt.Println("    строки go.sum про example.com/c:")
	grep(filepath.Join(old, "go.sum"), "example.com/c ", "      ")
	oldSum := strings.TrimSpace(read(filepath.Join(old, "go.sum")))
	fmt.Printf("    строк в go.sum: %d\n", strings.Count(oldSum, "\n")+1)

	fmt.Println("  без go.sum:")
	os.Remove(filepath.Join(app, "go.sum"))
	cmd(app, nil, "build", "-o", os.DevNull, ".")
	restore(app, base)

	fmt.Println("  в go.sum испорчен один символ хеша c v1.3.0 (h1:… модуля, не /go.mod):")
	const key = "example.com/c v1.3.0 h1:"
	at := strings.Index(base.sum, key) + len(key)
	swap := byte('A')
	if base.sum[at] == 'A' {
		swap = 'B'
	}
	write(filepath.Join(app, "go.sum"), base.sum[:at]+string(swap)+base.sum[at+1:])
	fmt.Println("  кеш модулей тёплый:")
	cmd(app, nil, "build", "-o", os.DevNull, ".")
	cleanCache()
	fmt.Println("  кеш модулей очищен:")
	cmd(app, nil, "build", "-o", os.DevNull, ".")
	restore(app, base)

	fmt.Println("  в прокси подменено содержимое c v1.3.0 (та же версия, другой код), go.sum прежний:")
	tampered := cFiles("example.com/c", "v1.3.0", "1.22")
	tampered["c.go"] = "package c\n\nfunc Version() string { return \"v1.3.0, но другой код\" }\n"
	publish("example.com/c", "v1.3.0", tampered)
	cleanCache()
	cmd(app, nil, "build", "-o", os.DevNull, ".")
	publish("example.com/c", "v1.3.0", cFiles("example.com/c", "v1.3.0", "1.22"))
	cleanCache()
	if r := run(app, nil, "build", "-o", os.DevNull, "."); r.code != 0 {
		panic(r.out)
	}
	fmt.Println("  исходное содержимое возвращено в прокси: go build снова проходит")

	// ── 5 ──────────────────────────────────────────────────────────────
	show("5) /v2: ДВЕ МАЖОРНЫЕ ВЕРСИИ В ОДНОЙ СБОРКЕ")
	two := filepath.Join(work, "two")
	write(filepath.Join(two, "go.mod"), "module example.com/two\n\ngo 1.24\n")
	write(filepath.Join(two, "main.go"), `package main

import (
	"fmt"

	"example.com/c"
	cv2 "example.com/c/v2"
)

func main() {
	fmt.Println("c.Version()   =", c.Version())
	fmt.Println("cv2.Version() =", cv2.Version())
}
`)
	fmt.Println("  main.go импортирует \"example.com/c\" и cv2 \"example.com/c/v2\"")
	cmd(two, nil, "mod", "tidy")
	cmd(two, nil, "list", "-m", "all")
	cmd(two, nil, "run", ".")
	cmd(two, nil, "list", "-m", "-versions", "example.com/c", "example.com/c/v2")
	fmt.Println("  v2 под путём без /v2 — требование дописано в go.mod руками:")
	cmd(two, nil, "mod", "edit", "-require=example.com/c@v2.0.0")
	cmd(two, nil, "build", "-o", os.DevNull, ".")

	// ── 6 ──────────────────────────────────────────────────────────────
	show("6) replace: ЛОКАЛЬНЫЙ КАТАЛОГ")
	write(filepath.Join(work, "c-local", "go.mod"), "module example.com/c\n\ngo 1.22\n")
	write(filepath.Join(work, "c-local", "c.go"), "package c\n\nfunc Version() string { return \"local copy\" }\n")
	cmd(app, nil, "mod", "edit", "-replace=example.com/c=../c-local")
	grep(filepath.Join(app, "go.mod"), "replace", "    go.mod: ")
	cmd(app, nil, "list", "-m", "example.com/c")
	cmd(app, nil, "run", ".")
	cmd(app, nil, "mod", "tidy")
	fmt.Println("    строки go.sum про example.com/c после tidy:")
	grep(filepath.Join(app, "go.sum"), "example.com/c ", "      ")
	restore(app, base)
	fmt.Println("  replace в главном модуле на каталог, которого нет:")
	cmd(app, nil, "mod", "edit", "-replace=example.com/c=../c-missing")
	cmd(app, nil, "build", "-o", os.DevNull, ".")
	restore(app, base)
	fmt.Println("  replace в go.mod зависимости: go.mod модуля b из прокси —")
	for _, l := range strings.Split(strings.TrimRight(read(filepath.Join(proxy, "example.com/b/@v/v1.0.0.mod")), "\n"), "\n") {
		if l != "" {
			fmt.Println("    " + l)
		}
	}
	fmt.Println("  каталога ../c-fork нет нигде, и это не ошибка:")
	cmd(app, nil, "run", ".")

	// ── 7 ──────────────────────────────────────────────────────────────
	show("7) СТРОКИ go И toolchain")
	lang := filepath.Join(work, "lang")
	write(filepath.Join(lang, "main.go"), "package main\n\nimport \"fmt\"\n\nfunc main() {\n\tfor i := range 3 {\n\t\tfmt.Print(i)\n\t}\n\tfmt.Println()\n}\n")
	for _, g := range []string{"1.21", "1.22"} {
		write(filepath.Join(lang, "go.mod"), "module example.com/lang\n\ngo "+g+"\n")
		fmt.Printf("  for i := range 3, в go.mod go %s:\n", g)
		cmd(lang, nil, "run", ".")
	}

	newer := filepath.Join(work, "newer")
	write(filepath.Join(newer, "main.go"), "package main\n\nfunc main() {}\n")
	write(filepath.Join(newer, "go.mod"), "module example.com/newer\n\ngo 1.25.0\n")
	fmt.Println("  в go.mod go 1.25.0:")
	cmd(newer, []string{"GOTOOLCHAIN=local"}, "build", "-o", os.DevNull, ".")
	cmd(newer, []string{"GOTOOLCHAIN=auto"}, "build", "-o", os.DevNull, ".")

	write(filepath.Join(newer, "go.mod"), "module example.com/newer\n\ngo 1.24\n\ntoolchain go1.25.1\n")
	fmt.Println("  в go.mod go 1.24 и toolchain go1.25.1:")
	cmd(newer, []string{"GOTOOLCHAIN=local"}, "env", "GOVERSION")
	cmd(newer, []string{"GOTOOLCHAIN=auto"}, "env", "GOVERSION")

	fmt.Println("  зависимость, чей go.mod требует go 1.25.0:")
	cmd(app, []string{"GOTOOLCHAIN=local"}, "get", "example.com/fresh@v1.0.0")
	grep(filepath.Join(app, "go.mod"), "go 1.", "    go.mod: ")
	grep(filepath.Join(app, "go.mod"), "fresh", "    go.mod: ")
	restore(app, base)

	// ── 8 ──────────────────────────────────────────────────────────────
	// Разделы 8–10 добавлены после внешнего аудита 14.10.2026 — в конец,
	// чтобы не сдвигать вывод, который урок уже цитирует.
	show("8) МИНИМАЛЬНЫЙ МОДУЛЬ: go mod init И go run")
	hello := filepath.Join(work, "hello")
	write(filepath.Join(hello, "main.go"), helloMain)
	fmt.Println("  в пустом каталоге — один файл main.go:")
	for _, l := range strings.Split(strings.TrimRight(helloMain, "\n"), "\n") {
		if l == "" {
			fmt.Println()
			continue
		}
		fmt.Println("    " + strings.ReplaceAll(l, "\t", "    "))
	}
	cmd(hello, nil, "mod", "init", "example.com/hello")
	cmd(hello, nil, "run", ".")
	file("go.mod после go mod init", filepath.Join(hello, "go.mod"))

	// ── 9 ──────────────────────────────────────────────────────────────
	show("9) go build -mod=mod: ПРАВИТЬ go.mod РАЗРЕШЕНО ЯВНО")
	fmt.Println("  импорт без требования: добавлен файл extra.go с import \"example.com/unused\"")
	write(filepath.Join(app, "extra.go"), "package main\n\nimport \"example.com/unused\"\n\nvar _ = unused.Hello\n")
	cmd(app, nil, "build", "-o", os.DevNull, ".")
	cmd(app, nil, "build", "-mod=mod", "-o", os.DevNull, ".")
	grep(filepath.Join(app, "go.mod"), "unused", "    go.mod: ")
	os.Remove(filepath.Join(app, "extra.go"))
	restore(app, base)

	// ── 10 ─────────────────────────────────────────────────────────────
	show("10) replace: РАБОЧЕЕ ПРОСТРАНСТВО И ТРЕБОВАНИЯ ЗАМЕНЫ")
	fmt.Println("  go.work: use ./app и ./b-dev; b-dev — копия b с replace example.com/c => ../c-local")
	bdev := filepath.Join(work, "b-dev")
	for name, body := range user("b", "1.22", "v1.3.0", "\nreplace example.com/c => ../c-local\n") {
		write(filepath.Join(bdev, name), body)
	}
	write(filepath.Join(work, "go.work"), "go 1.24\n\nuse (\n\t./app\n\t./b-dev\n)\n")
	ws := []string{"GOWORK=" + filepath.Join(work, "go.work")}
	cmd(app, ws, "list", "-m", "example.com/b", "example.com/c")
	cmd(app, ws, "run", ".")
	fmt.Println("  без go.work (GOWORK=off) — тот же app:")
	cmd(app, nil, "run", ".")

	fmt.Println("  замена со своими требованиями: go.mod каталога c-req требует example.com/unused")
	write(filepath.Join(work, "c-req", "go.mod"), "module example.com/c\n\ngo 1.22\n\nrequire example.com/unused v1.0.0\n")
	write(filepath.Join(work, "c-req", "c.go"), "package c\n\nimport \"example.com/unused\"\n\nfunc Version() string { return \"c-req + \" + unused.Hello() }\n")
	cmd(app, nil, "list", "-m", "all")
	cmd(app, nil, "mod", "edit", "-replace=example.com/c=../c-req")
	cmd(app, nil, "mod", "tidy")
	cmd(app, nil, "list", "-m", "all")
	cmd(app, nil, "run", ".")
	restore(app, base)
}

// helloMain — полный файл минимального модуля из раздела 8: только
// стандартная библиотека, поэтому его можно повторить в любом пустом каталоге.
const helloMain = `package main

import (
	"fmt"
	"runtime/debug"
)

func main() {
	info, _ := debug.ReadBuildInfo()
	fmt.Println("module:", info.Main.Path)
}
`