Deep Engineering

ЗАМЕР

bench/gotool/internals.go

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

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

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

Замеры для урока «Инструменты go»

Файл Что делает
internals.go одиннадцать наблюдений, каждое — запуск команды go или gofmt на программах из cases/: кеш сборки, где лежит бинарник go run и какой у него код выхода, gofmt -l и -d, компилятор против go vet, подмножество vet внутри go test, golangci-lint сверх vet, ограничения сборки, кросс-компиляция, -ldflags "-X …" и -s -w, go generate, ключ кеша
practice.go источник ответов двух практических задач урока: вывод программы, собранной с -X (константное выражение и ссылка на переменную в инициализаторе), и во сколько раз бинарник HTTP-сервера на net/http больше hello на fmt
cases/ отдельный модуль с подопытными программами; часть намеренно не собирается или не проходит vet
cases/hello fmt и os: кеш, go run, код выхода, кросс-компиляция, размер, паника в бинарнике без символов
cases/messy, cases/brace неотформатированный файл для gofmt -d и открывающая скобка на своей строке
cases/unusedvar, printf, copylock, unreachable, unusedresult, errcheck, ineffassign по одной ошибке, на которой сравниваются компилятор, go vet и golangci-lint
cases/testvet/printf, cases/testvet/copylock пакеты с тестами: какую ошибку vet ловит go test, а какую нет
cases/tags файлы с суффиксами _linux, _windows и строками //go:build
cases/stamp переменные для -ldflags "-X …": неинициализированная, со строкой, с вызовом функции
cases/gen //go:generate go run gen.go; генератор пишет codes_gen.go (в копии во временном каталоге)
go run bench/gotool/internals.go
/usr/local/go1.25.1/bin/go run bench/gotool/internals.go
go run bench/gotool/practice.go

internals.go работает со своим кешем сборки во временном каталоге (GOCACHE), поэтому холодная сборка в разделе 1 действительно холодная, а кеш пользователя не трогается. Раздел 6 нужен golangci-lint в /usr/local/bin; без него раздел пропускается. Прогон на 2 ядрах занял около двух минут: раздел 1 трижды собирает стандартную библиотеку с нуля, а раздел 8 — ещё для трёх новых пар GOOS/GOARCH.

Записи прогонов — в runs/: internals.txt и practice.txt — go1.24.7 linux/amd64, internals-go1.25.txt — go1.25.1. Машина: 2 ядра. Временные пути заменены плейсхолдерами <tmp>, <GOCACHE>, <home>, <cases>.

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

Повторная сборка не компилирует ничего. Холодная сборка cases/hello вызвала компилятор 55 раз (54 пакета стандартной библиотеки и main; на go1.25.1 — 60) и заняла 6,6 с; с тёплым кешем — ни одного вызова компилятора, 0,16 с. Готовых скомпилированных пакетов в $GOROOT/pkg нет. Если бинарник уже собран из тех же входов, go build не вызывает и компоновщик. touch файла кеш не сбрасывает, правка комментария — перекомпилирует один пакет.

go run — это сборка во временный каталог без отладочной информации. Первый go run положил бинарник в <tmp>/go-build<N>/b001/exe/hello, и после выхода файла нет. Второй запустил бинарник прямо из кеша сборки. Размер бинарника go run совпал с go build -ldflags "-s -w" до байта (сравнивался только размер). Код выхода программы 3 превратился в код выхода go run 1.

Компилятор пропускает то, что ловит vet. Printf("%d", string), копия sync.Mutex в получателе-значении, недостижимый код, выброшенный результат fmt.Sprintf — всё собралось. go vet поймал все четыре. Неиспользуемую переменную ловит сам компилятор. Проигнорированную ошибку os.Remove и лишнее присваивание vet не видит — их нашёл golangci-lint 2.5.0 (errcheck, ineffassign).

go test запускает vet, но не весь. Тест пакета с неверным глаголом Printf не запустился: [build failed]. Тест пакета с копией мьютекса прошёл и напечатал after Inc: 0: copylocks не входит в подмножество, которое go test запускает по умолчанию. С -vet=all — [build failed].

Кросс-компиляция — две переменные окружения. GOOS=windows дал файл PE, GOOS=darwin GOARCH=arm64 — Mach-O; стандартная библиотека для новой пары собрана из исходников (54–57 пакетов), CGO_ENABLED при этом 0.

-X молчит, когда не срабатывает. Переменная с вызовом функции в инициализаторе осталась unknown; опечатка в имени переменной — код выхода 0 и никакого сообщения. -s -w уменьшил бинарник в 1,54 раза (-s один дал тот же размер: по документации он включает -w), а паника в нём по-прежнему печатает main.main() и строку исходника.

gofmt — не про вкус. Открывающая скобка на своей строке — синтаксическая ошибка, а не стиль: unexpected semicolon or newline before {.

Скрипт

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

// Инструменты go: что на самом деле делают go run, go build, gofmt, go vet
// и флаги сборки.
//
// ЗАЧЕМ ЭТА ПРОГРАММА. Вопросы об инструментах на собеседовании звучат как
// бытовые — «чем go run отличается от go build», «зачем go vet, если есть
// компилятор». Ответы, которые отличают знающего, — о поведении: где лежит
// бинарник go run, что именно кешируется, какие ошибки компилятор пропускает,
// какие проверки vet запускает go test, что делает -ldflags. Здесь каждое
// такое поведение получено запуском команды go, а не пересказано.
//
// ОДИННАДЦАТЬ СЮЖЕТОВ:
//
//  1. Кеш сборки: холодная сборка компилирует десятки пакетов, повторная — ни
//     одного.
//  2. go run: бинарник собирается во временный каталог, без отладочной
//     информации; код выхода программы go run не передаёт.
//  3. gofmt -l и -d: что именно он меняет; скобка на новой строке — не стиль,
//     а синтаксическая ошибка.
//  4. Компилятор против go vet на семи подопытных программах.
//  5. go vet внутри go test: только подмножество проверок.
//  6. golangci-lint: что он ловит сверх vet.
//  7. Ограничения сборки: суффикс имени файла и строка //go:build.
//  8. Кросс-компиляция: GOOS и GOARCH, без внешних инструментов.
//  9. -ldflags: подстановка версии через -X и размер бинарника с -s -w.
//  10. go generate: команды из комментариев запускаются только по явному
//     вызову.
//  11. Ключ кеша — содержимое файла, а не время его изменения.
//
// Подопытные программы лежат в cases/ — отдельный модуль со своим go.mod.
// Часть из них намеренно не собирается или не проходит vet.
//
// Программа работает со СВОИМ кешем сборки во временном каталоге (GOCACHE),
// чтобы холодная сборка была действительно холодной. Кеш пользователя она
// не трогает. Временные пути в выводе заменены плейсхолдерами.
//
// ЗАПУСК:
//
//	go run bench/gotool/internals.go
//	/usr/local/go1.25.1/bin/go run bench/gotool/internals.go
package main

import (
	"debug/elf"
	"debug/macho"
	"debug/pe"
	"fmt"
	"os"
	"os/exec"
	"path/filepath"
	"regexp"
	"runtime"
	"sort"
	"strings"
	"time"
)

var (
	here    string // bench/gotool
	cases   string // bench/gotool/cases
	work    string // временный каталог программы
	gocache string // свой кеш сборки
	goBin   string // go той же версии, что запустила программу
	baseEnv []string
)

type result struct {
	out  string
	code int
	dur  time.Duration
}

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

func goRun(dir string, env []string, args ...string) result { return run(dir, env, goBin, args...) }

var (
	reGoBuild = regexp.MustCompile(`/tmp/go-build\d+`)
	reHash    = regexp.MustCompile(`/[0-9a-f]{2}/[0-9a-f]{64}-d/`)
	reTiming  = regexp.MustCompile(`\t\d+\.\d+s$|\s*\(\d+\.\d+s\)`)
	reOffset  = regexp.MustCompile(`\+0x[0-9a-f]+`)
)

// clean заменяет временные пути плейсхолдерами: вывод должен быть одинаковым
// от запуска к запуску.
func clean(s string) string {
	s = strings.ReplaceAll(s, gocache, "<GOCACHE>")
	s = strings.ReplaceAll(s, cases, "<cases>")
	s = reOffset.ReplaceAllString(s, "+0x<N>")
	s = strings.ReplaceAll(s, work, "<tmp>")
	s = reGoBuild.ReplaceAllString(s, "<tmp>/go-build<N>")
	s = reHash.ReplaceAllString(s, "/<xx>/<hash>-d/")
	if home, err := os.UserHomeDir(); err == nil && home != "/" {
		s = strings.ReplaceAll(s, home, "<home>")
	}
	return s
}

// lines печатает строки вывода с отступом, пропуская строки «# пакет».
func lines(out string, indent string) {
	for _, l := range strings.Split(strings.TrimRight(out, "\n"), "\n") {
		if strings.HasPrefix(l, "#") || strings.TrimSpace(l) == "" {
			continue
		}
		fmt.Println(indent + strings.TrimRight(reTiming.ReplaceAllString(clean(l), ""), " \t"))
	}
}

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

// count считает вызовы компилятора и компоновщика в выводе go build -x.
var reTool = regexp.MustCompile(`(?:^|\s)\S*/pkg/tool/[^/\s]+/(compile|link)\s`)

func count(xout string) (compile, link int) {
	for _, l := range strings.Split(xout, "\n") {
		if m := reTool.FindStringSubmatch(l); m != nil {
			if m[1] == "compile" {
				compile++
			} else {
				link++
			}
		}
	}
	return
}

// format определяет формат исполняемого файла по его заголовку.
func format(path string) string {
	if f, err := elf.Open(path); err == nil {
		defer f.Close()
		return "ELF, " + f.Machine.String()
	}
	if f, err := pe.Open(path); err == nil {
		defer f.Close()
		m := map[uint16]string{pe.IMAGE_FILE_MACHINE_AMD64: "IMAGE_FILE_MACHINE_AMD64", pe.IMAGE_FILE_MACHINE_ARM64: "IMAGE_FILE_MACHINE_ARM64"}
		return "PE, " + m[f.Machine]
	}
	if f, err := macho.Open(path); err == nil {
		defer f.Close()
		return "Mach-O, " + f.Cpu.String()
	}
	return "неизвестный формат"
}

// debugInfo: есть ли в ELF таблица символов и отладочная информация DWARF.
func debugInfo(path string) string {
	f, err := elf.Open(path)
	if err != nil {
		return "?"
	}
	defer f.Close()
	yes := func(name string) string {
		if f.Section(name) != nil {
			return "есть"
		}
		return "нет"
	}
	return fmt.Sprintf(".symtab %s, .debug_info %s", yes(".symtab"), yes(".debug_info"))
}

func size(path string) int64 {
	st, err := os.Stat(path)
	if err != nil {
		panic(err)
	}
	return st.Size()
}

// spaced печатает число с пробелами между разрядами: 2 297 638.
func spaced(n int64) string {
	s := fmt.Sprint(n)
	var b strings.Builder
	for i, c := range s {
		if i > 0 && (len(s)-i)%3 == 0 {
			b.WriteByte(' ')
		}
		b.WriteRune(c)
	}
	return b.String()
}

func best(n int, f func() time.Duration) time.Duration {
	lo := time.Duration(1 << 62)
	for i := 0; i < n; i++ {
		if d := f(); d < lo {
			lo = d
		}
	}
	return lo
}

// copyDir копирует плоский каталог с исходниками.
func copyDir(src, dst string) {
	if err := os.MkdirAll(dst, 0o755); err != nil {
		panic(err)
	}
	entries, err := os.ReadDir(src)
	if err != nil {
		panic(err)
	}
	for _, e := range entries {
		if e.IsDir() {
			continue
		}
		b, err := os.ReadFile(filepath.Join(src, e.Name()))
		if err != nil {
			panic(err)
		}
		if err := os.WriteFile(filepath.Join(dst, e.Name()), b, 0o644); err != nil {
			panic(err)
		}
	}
}

func main() {
	_, file, _, _ := runtime.Caller(0)
	here = filepath.Dir(file)
	cases = filepath.Join(here, "cases")
	goBin = filepath.Join(runtime.GOROOT(), "bin", "go")
	if _, err := os.Stat(goBin); err != nil {
		goBin = "go"
	}
	var err error
	work, err = os.MkdirTemp("", "gotool")
	if err != nil {
		panic(err)
	}
	defer os.RemoveAll(work)
	gocache = filepath.Join(work, "cache")
	// Тот же go первым в PATH: на него опирается golangci-lint и go generate.
	baseEnv = append(os.Environ(),
		"GOCACHE="+gocache,
		"GOTOOLCHAIN=local",
		"GOFLAGS=",
		"PATH="+filepath.Dir(goBin)+string(os.PathListSeparator)+os.Getenv("PATH"),
		"GOLANGCI_LINT_CACHE="+filepath.Join(work, "lintcache"),
	)

	fmt.Printf("%s %s/%s | инструменты go: run, build, fmt, vet\n", runtime.Version(), runtime.GOOS, runtime.GOARCH)

	// ── 1 ──────────────────────────────────────────────────────────────
	show("1) КЕШ СБОРКИ: ХОЛОДНАЯ И ПОВТОРНАЯ СБОРКА")
	def := run(cases, []string{"GOCACHE="}, goBin, "env", "GOCACHE")
	fmt.Println("  go env GOCACHE (по умолчанию) -> " + clean(strings.TrimSpace(def.out)))
	fmt.Println("  программа: cases/hello — fmt и os, ничего больше")
	// Есть ли в поставке готовые скомпилированные пакеты стандартной
	// библиотеки: что лежит в $GOROOT/pkg.
	pkgDir, _ := os.ReadDir(filepath.Join(runtime.GOROOT(), "pkg"))
	var pkgNames []string
	for _, e := range pkgDir {
		pkgNames = append(pkgNames, e.Name())
	}
	fmt.Printf("  каталог $GOROOT/pkg поставки: %s\n", strings.Join(pkgNames, " "))
	out := filepath.Join(work, "hello")
	var coldC, coldL int
	cold := best(3, func() time.Duration {
		os.RemoveAll(gocache)
		os.Remove(out)
		r := goRun(cases, nil, "build", "-x", "-o", out, "./hello")
		coldC, coldL = count(r.out)
		return r.dur
	})
	var warmC, warmL int
	warm := best(5, func() time.Duration {
		os.Remove(out)
		r := goRun(cases, nil, "build", "-x", "-o", out, "./hello")
		warmC, warmL = count(r.out)
		return r.dur
	})
	var sameC, sameL int
	same := best(5, func() time.Duration {
		r := goRun(cases, nil, "build", "-x", "-o", out, "./hello")
		sameC, sameL = count(r.out)
		return r.dur
	})
	fmt.Println("  go build -o hello ./hello:")
	fmt.Printf("  холодный кеш                         %5.1f с    compile: %2d, link: %d\n", cold.Seconds(), coldC, coldL)
	fmt.Printf("  тёплый кеш, файла hello ещё нет      %5.2f с    compile: %2d, link: %d\n", warm.Seconds(), warmC, warmL)
	fmt.Printf("  тёплый кеш, hello уже собран         %5.2f с    compile: %2d, link: %d\n", same.Seconds(), sameC, sameL)
	fmt.Printf("  холодная / тёплая:                  %4.0f\n", cold.Seconds()/warm.Seconds())
	fmt.Println("  Лучший из 3 (холодный кеш очищается перед каждым запуском)")
	fmt.Println("  и из 5 (тёплый). Счёт вызовов — по строкам go build -x.")
	fmt.Println("  Пакеты берутся из кеша; бинарник, которого нет, компонуется")
	fmt.Println("  заново; бинарник, который уже собран из тех же входов, не")
	fmt.Println("  трогается вовсе.")

	// ── 2 ──────────────────────────────────────────────────────────────
	show("2) go run: ВРЕМЕННЫЙ КАТАЛОГ И КОД ВЫХОДА")
	for _, label := range []string{"первый go run", "второй go run"} {
		r := goRun(cases, nil, "run", "-x", "./hello")
		c, l := count(r.out)
		exe := ""
		for _, ln := range strings.Split(r.out, "\n") {
			if strings.HasPrefix(ln, "exe: ") {
				exe = strings.TrimPrefix(ln, "exe: ")
			}
		}
		_, statErr := os.Stat(exe)
		fmt.Printf("  %s   compile: %d, link: %d\n", label, c, l)
		fmt.Printf("    бинарник: %s\n", clean(exe))
		fmt.Printf("    после выхода файл на месте: %v\n", statErr == nil)
	}
	fail := goRun(cases, nil, "run", "./hello", "fail")
	fmt.Println("  go run ./hello fail:")
	lines(fail.out, "    ")
	fmt.Printf("    код выхода go run: %d\n", fail.code)
	direct := run(cases, nil, filepath.Join(work, "hello"), "fail")
	fmt.Printf("    код выхода самой программы: %d\n", direct.code)

	// ── 3 ──────────────────────────────────────────────────────────────
	show("3) gofmt -l И gofmt -d")
	gofmt := filepath.Join(filepath.Dir(goBin), "gofmt")
	fmt.Println("  gofmt -l messy/ tags/")
	lines(run(cases, nil, gofmt, "-l", "messy", "tags").out, "    ")
	fmt.Println("  go build ./messy/")
	if r := goRun(cases, nil, "build", "-o", os.DevNull, "./messy"); r.code == 0 {
		fmt.Println("    собрано, ошибок нет")
	}
	fmt.Println("  gofmt -d messy/main.go")
	lines(run(cases, nil, gofmt, "-d", "messy/main.go").out, "    ")
	fmt.Println("  gofmt -l brace/  (открывающая скобка на своей строке)")
	lines(run(cases, nil, gofmt, "-l", "brace").out, "    ")
	fmt.Println("  go build ./brace/")
	lines(goRun(cases, nil, "build", "-o", os.DevNull, "./brace").out, "    ")

	// ── 4 ──────────────────────────────────────────────────────────────
	show("4) КОМПИЛЯТОР ПРОТИВ go vet")
	vetCases := []string{"unusedvar", "printf", "copylock", "unreachable", "unusedresult", "errcheck", "ineffassign"}
	for _, c := range vetCases {
		fmt.Println("  " + c + ":")
		b := goRun(filepath.Join(cases, c), nil, "build", "-o", os.DevNull, ".")
		if b.code == 0 {
			fmt.Println("    go build: собрано, ошибок нет")
		} else {
			lines(b.out, "    go build: ")
		}
		v := goRun(filepath.Join(cases, c), nil, "vet", ".")
		if v.code == 0 && strings.TrimSpace(v.out) == "" {
			fmt.Println("    go vet:   замечаний нет")
		} else {
			lines(v.out, "    go vet:   ")
		}
	}
	cl := goRun(cases, nil, "run", "./copylock")
	fmt.Printf("  copylock, запуск: c.Inc(), затем c.n -> %s\n", strings.TrimSpace(cl.out))

	// ── 5 ──────────────────────────────────────────────────────────────
	show("5) go vet ВНУТРИ go test")
	fmt.Println("  go test ./testvet/printf/")
	lines(goRun(cases, nil, "test", "-count=1", "./testvet/printf/").out, "    ")
	fmt.Println("  go test -v ./testvet/copylock/")
	lines(goRun(cases, nil, "test", "-count=1", "-v", "./testvet/copylock/").out, "    ")
	fmt.Println("  go test -vet=all ./testvet/copylock/")
	lines(goRun(cases, nil, "test", "-count=1", "-vet=all", "./testvet/copylock/").out, "    ")

	// ── 6 ──────────────────────────────────────────────────────────────
	show("6) golangci-lint СВЕРХ go vet")
	lint := "/usr/local/bin/golangci-lint"
	if _, err := os.Stat(lint); err != nil {
		fmt.Println("  golangci-lint не найден — раздел пропущен")
	} else {
		fmt.Println("  " + strings.TrimSpace(run(cases, nil, lint, "--version").out))
		fmt.Println("  golangci-lint run --no-config (линтеры по умолчанию):")
		issue := regexp.MustCompile(`^\S+\.go:\d+(:\d+)?: .*\(\w+\)$`)
		for _, c := range vetCases {
			r := run(filepath.Join(cases, c), nil, lint, "run", "--no-config", "./...")
			fmt.Println("  " + c + ":")
			found := false
			for _, l := range strings.Split(r.out, "\n") {
				if issue.MatchString(l) {
					fmt.Println("    " + clean(l))
					found = true
				}
			}
			if !found {
				fmt.Println("    замечаний нет")
			}
		}
	}

	// ── 7 ──────────────────────────────────────────────────────────────
	show("7) ОГРАНИЧЕНИЯ СБОРКИ: ИМЯ ФАЙЛА И //go:build")
	all, _ := filepath.Glob(filepath.Join(cases, "tags", "*.go"))
	for i := range all {
		all[i] = filepath.Base(all[i])
	}
	sort.Strings(all)
	fmt.Printf("  файлы в tags/: %s\n", strings.Join(all, " "))
	for _, v := range []struct {
		label string
		env   []string
		args  []string
	}{
		{"linux (по умолчанию)", nil, nil},
		{"GOOS=windows", []string{"GOOS=windows"}, nil},
		{"GOOS=darwin", []string{"GOOS=darwin"}, nil},
		{"-tags debug", nil, []string{"-tags", "debug"}},
	} {
		args := append([]string{"list"}, v.args...)
		args = append(args, "-f", "{{.GoFiles}}", "./tags")
		r := goRun(cases, v.env, args...)
		fmt.Printf("  %-22s %s\n", v.label, strings.TrimSpace(r.out))
	}
	fmt.Println("  go run ./tags")
	lines(goRun(cases, nil, "run", "./tags").out, "    ")
	fmt.Println("  go run -tags debug ./tags")
	lines(goRun(cases, nil, "run", "-tags", "debug", "./tags").out, "    ")

	// ── 8 ──────────────────────────────────────────────────────────────
	show("8) КРОСС-КОМПИЛЯЦИЯ: GOOS И GOARCH")
	for _, t := range []struct{ goos, goarch, out string }{
		{"linux", "amd64", "hello-linux-amd64"},
		{"linux", "arm64", "hello-linux-arm64"},
		{"windows", "amd64", "hello.exe"},
		{"darwin", "arm64", "hello-darwin-arm64"},
	} {
		env := []string{"GOOS=" + t.goos, "GOARCH=" + t.goarch}
		cgo := strings.TrimSpace(goRun(cases, env, "env", "CGO_ENABLED").out)
		out := filepath.Join(work, t.out)
		r := goRun(cases, env, "build", "-x", "-o", out, "./hello")
		if r.code != 0 {
			fmt.Println("  ошибка сборки:", clean(r.out))
			continue
		}
		c, _ := count(r.out)
		fmt.Printf("  GOOS=%-7s GOARCH=%-5s CGO_ENABLED=%s  compile: %2d  %-30s %s байт\n",
			t.goos, t.goarch, cgo, c, format(out), spaced(size(out)))
	}
	fmt.Println("  compile — сколько пакетов скомпилировано: для новой пары")
	fmt.Println("  GOOS/GOARCH стандартная библиотека собирается из исходников")
	fmt.Println("  тем же компилятором; linux/amd64 уже лежал в кеше. Внешних")
	fmt.Println("  инструментов не нужно: cgo при кросс-компиляции выключен.")

	// ── 9 ──────────────────────────────────────────────────────────────
	show("9) -ldflags: ВЕРСИЯ ЧЕРЕЗ -X И РАЗМЕР С -s -w")
	fmt.Println("  go run ./stamp")
	lines(goRun(cases, nil, "run", "./stamp").out, "    ")
	fmt.Println(`  go run -ldflags "-X main.version=1.4.2 -X main.commit=9f3c2ab -X main.built=2026-10-14" ./stamp`)
	lines(goRun(cases, nil, "run", "-ldflags", "-X main.version=1.4.2 -X main.commit=9f3c2ab -X main.built=2026-10-14", "./stamp").out, "    ")
	fmt.Println(`  go run -ldflags "-X main.verison=1.4.2" ./stamp   (опечатка в имени)`)
	r := goRun(cases, nil, "run", "-ldflags", "-X main.verison=1.4.2", "./stamp")
	lines(r.out, "    ")
	fmt.Printf("    код выхода: %d\n", r.code)

	plain := filepath.Join(work, "hello-plain")
	stripped := filepath.Join(work, "hello-sw")
	onlyS := filepath.Join(work, "hello-s")
	goRun(cases, nil, "build", "-o", plain, "./hello")
	goRun(cases, nil, "build", "-ldflags", "-s -w", "-o", stripped, "./hello")
	goRun(cases, nil, "build", "-ldflags", "-s", "-o", onlyS, "./hello")
	// Бинарник второго go run лежит в кеше: его путь печатает сама программа.
	var cached string
	for _, ln := range strings.Split(goRun(cases, nil, "run", "./hello").out, "\n") {
		if strings.HasPrefix(ln, "exe: ") {
			cached = strings.TrimPrefix(ln, "exe: ")
		}
	}
	fmt.Println("  размер cases/hello:")
	fmt.Printf("    go build                     %s байт   %s\n", spaced(size(plain)), debugInfo(plain))
	fmt.Printf("    go build -ldflags \"-s -w\"    %s байт   %s\n", spaced(size(stripped)), debugInfo(stripped))
	fmt.Printf("    go build -ldflags \"-s\"       %s байт   %s\n", spaced(size(onlyS)), debugInfo(onlyS))
	if cached != "" {
		fmt.Printf("    бинарник go run из кеша      %s байт   %s\n", spaced(size(cached)), debugInfo(cached))
	}
	fmt.Printf("    -s -w меньше в %.2f раза\n", float64(size(plain))/float64(size(stripped)))
	fmt.Println("  паника в бинарнике, собранном с -s -w:")
	p := run(cases, nil, stripped, "panic")
	lines(p.out, "    ")
	fmt.Printf("    код выхода: %d\n", p.code)

	// ── 10 ─────────────────────────────────────────────────────────────
	show("10) go generate: ТОЛЬКО ПО ЯВНОМУ ВЫЗОВУ")
	gen := filepath.Join(work, "gen")
	copyDir(filepath.Join(cases, "gen"), gen)
	os.WriteFile(filepath.Join(gen, "go.mod"), []byte("module gen\n\ngo 1.24\n"), 0o644)
	fmt.Println("  go build .")
	lines(goRun(gen, nil, "build", "-o", os.DevNull, ".").out, "    ")
	fmt.Println("  go generate -x .")
	lines(goRun(gen, nil, "generate", "-x", ".").out, "    ")
	files, _ := filepath.Glob(filepath.Join(gen, "*.go"))
	for i := range files {
		files[i] = filepath.Base(files[i])
	}
	sort.Strings(files)
	fmt.Println("  файлы после: " + strings.Join(files, " "))
	fmt.Println("  go run .")
	lines(goRun(gen, nil, "run", ".").out, "    ")

	// ── 11 ─────────────────────────────────────────────────────────────
	show("11) КЛЮЧ КЕША — СОДЕРЖИМОЕ, А НЕ ВРЕМЯ ФАЙЛА")
	h := filepath.Join(work, "h")
	copyDir(filepath.Join(cases, "hello"), h)
	os.WriteFile(filepath.Join(h, "go.mod"), []byte("module h\n\ngo 1.24\n"), 0o644)
	build := func() (int, int) {
		return count(goRun(h, nil, "build", "-x", "-o", filepath.Join(work, "h.bin"), ".").out)
	}
	build()
	c, l := build()
	fmt.Printf("  повторная сборка без изменений      compile: %d, link: %d\n", c, l)
	later := time.Now().Add(time.Hour)
	os.Chtimes(filepath.Join(h, "main.go"), later, later)
	c, l = build()
	fmt.Printf("  после touch main.go (время +1 ч)    compile: %d, link: %d\n", c, l)
	f, _ := os.OpenFile(filepath.Join(h, "main.go"), os.O_APPEND|os.O_WRONLY, 0)
	f.WriteString("\n// комментарий\n")
	f.Close()
	c, l = build()
	fmt.Printf("  после правки комментария            compile: %d, link: %d\n", c, l)
	fmt.Println("  Время изменения файла в ключ не входит, содержимое — входит:")
	fmt.Println("  правка комментария перекомпилировала один пакет main и")
	fmt.Println("  заново скомпоновала бинарник.")
}