// Package archrules — тесты-сканеры исходников для правил, которых не выражает
// ни линтер, ни компилятор: направление зависимостей между пакетами и
// согласованность перечня колонок очереди.
//
// Каждое правило здесь — бывшая строка прозы: у него есть детерминированный
// оракул, поэтому ему место в наборе проверок, а не в промпте ревью. Перечень
// механизированного — docs/conventions/go-linters.md.
//
// Пакет тестовый целиком: рабочего кода в нём нет и быть не должно.
package archrules

import (
	"bytes"
	"go/parser"
	"go/printer"
	"go/token"
	"os"
	"path/filepath"
	"regexp"
	"strings"
	"testing"
)

const (
	modulePath = "git.vakhrushev.me/av/transcriber"
	// repoRoot — корень репозитория относительно каталога пакета.
	repoRoot = "../.."
)

// Ядро — `internal/service`: оно знает только интерфейсы `internal/contract`, а
// ffmpeg, Yandex и хранилище подставляются в точке входа `cmd/transcriber`
// (docs/architecture.md, «Принципы»).
const core = "internal/service"

// Транспорты — входы в ядро. Общее у двух транспортов живёт в ядре, а не в
// одном из них: иначе второй начинает зависеть от первого и тащит его целиком.
var transports = map[string]bool{
	"internal/controller/http":   true,
	"internal/controller/worker": true,
}

const adapterPrefix = "internal/adapter/"

// Пакеты, названные константами выше, обязаны существовать. Иначе правила ниже
// стали бы вечно зелёными от одного `git mv`: обход по отсутствующему ключу
// карты идёт ноль раз и молчит.
func TestПакетыПравилСуществуют(t *testing.T) {
	dirs := packageDirs(t)
	for pkg := range transports {
		if !dirs[pkg] {
			t.Errorf(
				"транспорт %s не найден в дереве: правило о транспортах потеряло "+
					"предмет — переименуй его в этом файле",
				pkg,
			)
		}
	}
	if !dirs[core] {
		t.Errorf(
			"ядро %s не найдено в дереве: правила о ядре потеряли предмет — "+
				"переименуй его в этом файле",
			core,
		)
	}
	var adapters int
	for dir := range dirs {
		if strings.HasPrefix(dir, adapterPrefix) {
			adapters++
		}
	}
	if adapters == 0 {
		t.Errorf("под %s не найдено ни одного пакета: правило об адаптерах потеряло предмет", adapterPrefix)
	}
}

func TestЯдроНеЗнаетОбАдаптерах(t *testing.T) {
	for _, imp := range internalImports(t)[core] {
		if strings.HasPrefix(imp, adapterPrefix) {
			t.Errorf(
				"%s импортирует адаптер %s: ядро зависит от интерфейсов "+
					"internal/contract, а реализацию подставляет cmd/transcriber",
				core, imp,
			)
		}
	}
}

func TestЯдроНеЗнаетОТранспортах(t *testing.T) {
	for _, imp := range internalImports(t)[core] {
		if transports[imp] {
			t.Errorf(
				"%s импортирует транспорт %s: зависимость направлена не туда, "+
					"ядро не знает, кто его позвал",
				core, imp,
			)
		}
	}
}

func TestТранспортыНеЗнаютДругОДруге(t *testing.T) {
	for pkg, imports := range internalImports(t) {
		if !transports[pkg] {
			continue
		}
		for _, imp := range imports {
			if transports[imp] && imp != pkg {
				t.Errorf(
					"%s импортирует транспорт %s: общее у двух входов живёт в ядре",
					pkg, imp,
				)
			}
		}
	}
}

// Транспорт не знает адаптеров. Изъятие, разрешавшее ему знать адаптер
// хранилища, снято вместе с предметом: HTTP-поверхность была роутером
// встроенного хранилища, а стала своей, и правило на это направление заводится
// впервые.
//
// Что оно ловит: возврат `EnsureUser`, `NewFileRepository` и прочих имён
// адаптера в обработчики. Знание о внешнем мире приходит транспорту интерфейсом
// `internal/contract`, а реализацию подставляет точка входа.
func TestТранспортыНеЗнаютАдаптеров(t *testing.T) {
	for pkg, imports := range internalImports(t) {
		if !transports[pkg] {
			continue
		}
		for _, imp := range imports {
			if strings.HasPrefix(imp, adapterPrefix) {
				t.Errorf(
					"транспорт %s импортирует адаптер %s: реализацию подставляет "+
						"cmd/transcriber, а транспорт знает только internal/contract",
					pkg, imp,
				)
			}
		}
	}
}

func TestАдаптерыНеЗнаютНиЯдра_НиТранспортов(t *testing.T) {
	for pkg, imports := range internalImports(t) {
		if !strings.HasPrefix(pkg, adapterPrefix) {
			continue
		}
		for _, imp := range imports {
			if imp == core || transports[imp] {
				t.Errorf(
					"адаптер %s импортирует %s: адаптер реализует интерфейс "+
						"internal/contract и о вызывающем не знает",
					pkg, imp,
				)
			}
		}
	}
}

// Ошибку узнают `errors.Is` и `errors.As`. Сравнение текста сообщения ловит
// заодно и чужую ошибку с тем же текстом, а при смене текста в библиотеке
// перестаёт ловить вообще — молча. `errorlint` видит `err == ErrX` и приведение
// типа, но матчинг по тексту не видит: его ловит это правило.
//
// Прецедент: клиент SpeechKit узнавал конец потока сравнением `err.Error() ==
// "EOF"` — правилом это закрыто 2026-08-13.
//
// Форм записи одного и того же условия много, и правило перечисляет их все:
// равенство и **неравенство**, обратный порядок операндов, `switch` по тексту и
// поиск подстроки любым способом. Отрицание — самая частая форма, и текстовый
// запрет, ловящий только `==`, обходился бы ею молча.
//
// Ищутся все вхождения, а не первое: два места в одном файле иначе починили бы
// по одному за прогон.
func TestОшибкаНеУзнаётсяПоТексту(t *testing.T) {
	patterns := []*regexp.Regexp{
		regexp.MustCompile(`\.Error\(\)\s*(==|!=)`),
		regexp.MustCompile(`(==|!=)\s*[\w.]+\.Error\(\)`),
		regexp.MustCompile(`switch\s+[\w.]+\.Error\(\)`),
		regexp.MustCompile(`strings\.\w+\([^)]*\.Error\(\)`),
		regexp.MustCompile(`regexp\.\w+\([^)]*\.Error\(\)`),
		regexp.MustCompile(`\.MatchString\([^)]*\.Error\(\)`),
	}
	for _, path := range goFiles(t) {
		// Комментарии сняты разбором: объяснение, приводящее запрещённую форму
		// в пример, — не код, и краснеть на нём правило не должно.
		body := []byte(sourceWithoutComments(t, path))
		rel, err := filepath.Rel(repoRoot, path)
		if err != nil {
			t.Fatalf("отношу путь %s: %v", path, err)
		}
		for _, re := range patterns {
			for _, loc := range re.FindAllIndex(body, -1) {
				t.Errorf(
					"%s:%d — ошибку узнают errors.Is и errors.As, а не по тексту сообщения: %q",
					rel, lineOf(body, loc[0]), strings.TrimSpace(string(body[loc[0]:loc[1]])),
				)
			}
		}
	}
}

// Перечень колонок аудиозаписи компилятор не видит: их пишет `writeOwnedByPipeline`
// вместе с `writeRecord`, читает `readRecordColumns`, доводит до сущности
// `rowToAudioRecord`, и заводит шаг схемы. Колонка, забытая в любом звене этой
// цепочки, теряется молча: запись, прочитанная не тем путём, приезжает с нулевым
// полем, и первое же сохранение пишет этот ноль поверх значения.
//
// Отображение работает **по имени колонки** — именованные параметры запроса и
// место назначения, найденное по имени, — поэтому правила ниже сверяют имена, а
// не порядок полей. Ту поломку, где колонка не забыта, а перепутана местом, эта
// форма снимает сама: позиционного списка, который сдвинулся бы на одно поле, у
// отображения нет вовсе.
const (
	repoPkg        = "internal/adapter/repo/sqlite"
	mappingFile    = repoPkg + "/record_mapping.go"
	migrationsPath = repoPkg + "/migrations"
	stageFile      = "internal/entity/stage.go"
	stateFile      = "internal/entity/audio_record.go"
	serviceFile    = "internal/service/transcribe.go"
)

func TestКолонкиЗаписиПишутсяИЧитаются(t *testing.T) {
	written := writtenColumns(t)
	read := readColumns(t)

	for col := range written {
		if !read[col] {
			t.Errorf(
				"колонку %q пишет отображение записи, но readRecordColumns её не "+
					"читает: запись приедет из базы без этого поля",
				col,
			)
		}
	}
	for col := range read {
		if !written[col] {
			t.Errorf(
				"колонку %q читает readRecordColumns, но её не пишет ни "+
					"writeOwnedByPipeline, ни writeRecord: поле не сохранится",
				col,
			)
		}
	}
}

// Колонка, прочитанная в поле сырой строки, обязана доехать до сущности:
// `readRecordColumns` называет, куда ляжет значение, а `rowToAudioRecord`
// решает, возьмут ли его оттуда. Поле, забытое во втором, теряется молча —
// компилятор его не видит, спрошенная колонка приезжает и остаётся лежать в
// сырой строке, сущность получает нулевое значение, а ближайшее сохранение
// пишет этот ноль поверх сохранённого.
func TestПрочитанныеКолонкиДоезжаютДоСущности(t *testing.T) {
	targets := readTargets(t)
	used := rowFieldsTakenByEntity(t)

	for field, column := range targets {
		if !used[field] {
			t.Errorf(
				"колонка %q читается в поле row.%s, но rowToAudioRecord его не берёт: "+
					"значение не доедет до сущности, а ближайшее сохранение запишет "+
					"нулевое поверх сохранённого",
				column, field,
			)
		}
	}
	for field := range used {
		if _, ok := targets[field]; !ok {
			t.Errorf(
				"rowToAudioRecord берёт поле row.%s, но readRecordColumns ни одной "+
					"колонки в него не кладёт: сущность получит нулевое значение всегда",
				field,
			)
		}
	}
}

func TestКолонкиЗаписиЗаведеныШагомСхемы(t *testing.T) {
	declared := schemaFieldNames(t)
	for col := range writtenColumns(t) {
		if !declared[col] {
			t.Errorf(
				"колонка %q пишется отображением записи, но ни один шаг схемы её не "+
					"заводит: сохранение отвалится на живой базе",
				col,
			)
		}
	}
}

// Рубеж объявлен одним дескриптором, но шаг под него пишется в другом месте, и
// связь между ними компилятор не видит. Рубеж, оставшийся без шага, из работы не
// выходит: воркер его захватит, шага не найдёт и остановит запись — а рубеж,
// забытый в дескрипторе, не выдаётся захвату вовсе, и пустой прогон по
// инварианту проекта не пишется в журнал и не считается в метрику.
func TestУКаждогоРабочегоРубежаЕстьШаг(t *testing.T) {
	body := funcBody(t, serviceFile, "func (s *TranscribeService) stepFor(")
	for _, stage := range workingStageIdents(t) {
		if !strings.Contains(body, "entity."+stage) {
			t.Errorf(
				"рубеж entity.%s объявлен рабочим в дескрипторе, но шага под него нет "+
					"в таблице stepFor: запись с этим рубежом остановится, не начав работы",
				stage,
			)
		}
	}
}

// Обратное направление того же правила: шаг, написанный под рубеж, которого в
// дескрипторе нет, недостижим — захват такую запись не выдаст никогда.
// Отбор списка — очередной потребитель словаря рубежей, и перечислять их у него
// строкой запроса нельзя: рубеж, добавленный конвейером, молча поменял бы состав
// всех трёх состояний отбора, а заметить это было бы нечем.
//
// Правило смотрит, что выборка списка берёт рубежи у дескриптора, а не пишет их
// литералом. Инвариант проекта «Рубеж объявляется одним дескриптором» компилятор
// не проверяет — проверяет оно.
func TestОтборСпискаБерётРубежиУДескриптора(t *testing.T) {
	const listFile = repoPkg + "/record_list.go"

	body := readFile(t, listFile)

	for _, value := range declaredStateValues(t) {
		if strings.Contains(body, `"`+value+`"`) {
			t.Errorf(
				"отбор списка называет рубеж %q строкой: рубеж, добавленный "+
					"дескриптором, молча не попадёт ни в одно состояние отбора",
				value,
			)
		}
	}

	for _, fn := range []string{"entity.WorkingStages()", "entity.TerminalStages()"} {
		if !strings.Contains(body, fn) {
			t.Errorf(
				"отбор списка не зовёт %s: перечень рубежей обязан приходить из "+
					"дескриптора, а не собираться по месту",
				fn,
			)
		}
	}
}

func TestШагиОбъявленыРубежамиДескриптора(t *testing.T) {
	body := funcBody(t, serviceFile, "func (s *TranscribeService) stepFor(")
	declared := map[string]bool{}
	for _, stage := range stageIdents(t) {
		declared[stage] = true
	}

	re := regexp.MustCompile(`case entity\.(\w+):`)
	for _, m := range re.FindAllStringSubmatch(body, -1) {
		if !declared[m[1]] {
			t.Errorf(
				"в таблице stepFor есть ветка для entity.%s, но такого рубежа нет в "+
					"дескрипторе: запись с этим рубежом захвату не выдаётся",
				m[1],
			)
		}
	}
}

// columnKey — имя колонки в карте отображения: строковый ключ в начале строки.
var columnKey = regexp.MustCompile(`(?m)^\s*"([a-z_0-9]+)":`)

// writtenColumns — колонки, которые пишет отображение записи в базу.
func writtenColumns(t *testing.T) map[string]bool {
	t.Helper()
	body := funcBody(t, mappingFile, "func writeOwnedByPipeline(") +
		funcBody(t, mappingFile, "func writeRecord(")
	out := map[string]bool{}
	for _, m := range columnKey.FindAllStringSubmatch(body, -1) {
		out[m[1]] = true
	}
	if len(out) == 0 {
		t.Fatalf("отображение записи не пишет ни одной колонки: правило потеряло предмет")
	}
	return out
}

// readColumns — колонки, которые читает обратное отображение. Перечень выборки
// собирается из той же карты, поэтому сверяется именно она.
func readColumns(t *testing.T) map[string]bool {
	t.Helper()
	body := funcBody(t, mappingFile, "func readRecordColumns(")
	out := map[string]bool{}
	for _, m := range columnKey.FindAllStringSubmatch(body, -1) {
		out[m[1]] = true
	}
	if len(out) == 0 {
		t.Fatalf("readRecordColumns не читает ни одной колонки: правило потеряло предмет")
	}
	return out
}

// readTarget — колонка чтения и поле сырой строки, куда она ложится.
var readTarget = regexp.MustCompile(`(?m)^\s*"([a-z_0-9]+)":\s*&row\.(\w+),`)

// rowFieldUse — обращение к полю сырой строки при сборке сущности.
var rowFieldUse = regexp.MustCompile(`\brow\.(\w+)\b`)

// readTargets — поле сырой строки в имя колонки, которая в него читается.
func readTargets(t *testing.T) map[string]string {
	t.Helper()
	body := funcBody(t, mappingFile, "func readRecordColumns(")
	out := map[string]string{}
	for _, m := range readTarget.FindAllStringSubmatch(body, -1) {
		out[m[2]] = m[1]
	}
	if len(out) == 0 {
		t.Fatalf("readRecordColumns не кладёт ни одной колонки в поле строки: правило потеряло предмет")
	}
	return out
}

// rowFieldsTakenByEntity — поля сырой строки, которые берёт сборка сущности.
func rowFieldsTakenByEntity(t *testing.T) map[string]bool {
	t.Helper()
	body := funcBody(t, mappingFile, "func rowToAudioRecord(")
	out := map[string]bool{}
	for _, m := range rowFieldUse.FindAllStringSubmatch(body, -1) {
		out[m[1]] = true
	}
	if len(out) == 0 {
		t.Fatalf("rowToAudioRecord не берёт ни одного поля строки: правило потеряло предмет")
	}
	return out
}

// stageIdents — имена констант рубежей, перечисленных дескриптором.
func stageIdents(t *testing.T) []string {
	t.Helper()
	out, _ := stageDescriptor(t)
	return out
}

// workingStageIdents — то же, но без конечного рубежа: из него запись в работу
// не берут.
func workingStageIdents(t *testing.T) []string {
	t.Helper()
	_, working := stageDescriptor(t)
	return working
}

func stageDescriptor(t *testing.T) (all []string, working []string) {
	t.Helper()
	body := readFile(t, stageFile)
	const marker = "var stages = []Stage{"
	start := strings.Index(body, marker)
	if start < 0 {
		t.Fatalf("в %s нет дескриптора рубежей: правило потеряло предмет", stageFile)
	}
	end := strings.Index(body[start:], "\n}")
	if end < 0 {
		t.Fatalf("не нашёл конец дескриптора рубежей в %s", stageFile)
	}

	declared := declaredStates(t)
	re := regexp.MustCompile(`\{Name: (\w+)[^}]*\}`)
	for _, m := range re.FindAllStringSubmatch(body[start:start+end], -1) {
		if !declared[m[1]] {
			t.Errorf(
				"дескриптор называет рубеж %s, которого нет среди объявленных состояний "+
					"в %s: перечень схемы разошёлся бы с ним молча",
				m[1], stateFile,
			)
			continue
		}
		all = append(all, m[1])
		if !strings.Contains(m[0], "Terminal: true") {
			working = append(working, m[1])
		}
	}

	if len(all) == 0 {
		t.Fatalf("дескриптор рубежей прочитан пустым: правило потеряло предмет")
	}
	if len(working) == 0 {
		t.Fatalf("в дескрипторе нет ни одного рабочего рубежа: правило потеряло предмет")
	}
	return all, working
}

// declaredStates — константы рубежей, объявленные доменом.
// declaredStateValues — **значения** рубежей, а не имена их констант: правило
// отбора ищет строковый литерал в чужом файле, и сравнивать его надо со
// значением.
//
// declaredStates рядом отдаёт имена констант — им пользуются правила, читающие
// код, а не строки.
func declaredStateValues(t *testing.T) []string {
	t.Helper()
	out := []string{}
	re := regexp.MustCompile(`(?m)^\tState\w+\s*=\s*"([^"]+)"`)
	for _, m := range re.FindAllStringSubmatch(readFile(t, stateFile), -1) {
		out = append(out, m[1])
	}
	if len(out) == 0 {
		t.Fatalf("в %s не объявлено ни одного рубежа: правило потеряло предмет", stateFile)
	}
	return out
}

func declaredStates(t *testing.T) map[string]bool {
	t.Helper()
	out := map[string]bool{}
	re := regexp.MustCompile(`(?m)^\t(State\w+)\s*=\s*"`)
	for _, m := range re.FindAllStringSubmatch(readFile(t, stateFile), -1) {
		out[m[1]] = true
	}
	if len(out) == 0 {
		t.Fatalf("в %s не объявлено ни одного рубежа: правило потеряло предмет", stateFile)
	}
	return out
}

// --- Чтение исходников ------------------------------------------------------

// funcBody — текст тела функции от её заголовка до закрывающей скобки в первой
// позиции строки. Пропавший заголовок — отказ, а не пустое тело: правило,
// потерявшее предмет, обязано краснеть, а не зеленеть.
func funcBody(t *testing.T, file, header string) string {
	t.Helper()
	body := readFile(t, file)
	start := strings.Index(body, header)
	if start < 0 {
		t.Fatalf("в %s нет %s: правило потеряло предмет", file, header)
	}
	end := strings.Index(body[start:], "\n}")
	if end < 0 {
		t.Fatalf("не нашёл конец %s в %s", header, file)
	}
	return body[start : start+end]
}

// schemaFieldNames собирает имена колонок аудиозаписи, заведённых шагами схемы.
//
// Читается объявление таблицы в любом файле каталога шагов: колонку заводит тот
// шаг, который её добавил, а переписывать применённый шаг нельзя. Перечень
// поэтому объединённый — по всем шагам сразу.
func schemaFieldNames(t *testing.T) map[string]bool {
	t.Helper()
	dir := filepath.Join(repoRoot, migrationsPath)
	entries, err := os.ReadDir(dir)
	if err != nil {
		t.Fatalf("читаю каталог шагов схемы: %v", err)
	}

	column := regexp.MustCompile(`(?m)^\s*([a-z_0-9]+)\s+(TEXT|INTEGER)`)
	out := map[string]bool{}
	for _, e := range entries {
		if e.IsDir() || !strings.HasSuffix(e.Name(), ".go") {
			continue
		}
		body, err := os.ReadFile(filepath.Join(dir, e.Name()))
		if err != nil {
			t.Fatalf("читаю %s: %v", e.Name(), err)
		}
		for _, block := range tableBlocks(string(body), recordsTable) {
			for _, m := range column.FindAllStringSubmatch(block, -1) {
				out[m[1]] = true
			}
		}
	}
	if len(out) == 0 {
		t.Fatalf("шаги схемы не объявили ни одной колонки аудиозаписи: правило потеряло предмет")
	}
	return out
}

// recordsTable — имя таблицы аудиозаписей в шагах схемы.
const recordsTable = "audio_records"

// tableBlocks вырезает объявления названной таблицы: от `CREATE TABLE имя (` до
// закрывающей скобки в начале строки.
func tableBlocks(body, table string) []string {
	var out []string
	marker := "CREATE TABLE " + table + " ("
	for {
		start := strings.Index(body, marker)
		if start < 0 {
			return out
		}
		body = body[start+len(marker):]
		end := strings.Index(body, "\n\t\t)")
		if end < 0 {
			return out
		}
		out = append(out, body[:end])
		body = body[end:]
	}
}

func readFile(t *testing.T, rel string) string {
	t.Helper()
	body, err := os.ReadFile(filepath.Join(repoRoot, rel))
	if err != nil {
		t.Fatalf("читаю %s: %v", rel, err)
	}
	return string(body)
}

// internalImports возвращает карту «пакет репозитория → его внутренние импорты».
// Файлы проверок не читаются: подставной адаптер в тесте ядра законен, а вот в
// рабочем коде — нет.
func internalImports(t *testing.T) map[string][]string {
	t.Helper()
	out := map[string][]string{}
	fset := token.NewFileSet()
	for _, path := range goFiles(t) {
		f, err := parser.ParseFile(fset, path, nil, parser.ImportsOnly)
		if err != nil {
			t.Fatalf("разбираю %s: %v", path, err)
		}
		rel, err := filepath.Rel(repoRoot, filepath.Dir(path))
		if err != nil {
			t.Fatalf("отношу путь %s: %v", path, err)
		}
		for _, imp := range f.Imports {
			if after, ok := strings.CutPrefix(strings.Trim(imp.Path.Value, `"`), modulePath+"/"); ok {
				out[rel] = append(out[rel], after)
			}
		}
	}
	if len(out) == 0 {
		t.Fatal("не найдено ни одного файла с внутренними импортами: правило потеряло предмет")
	}
	return out
}

// sourceWithoutComments — исходник без комментариев: файл разбирается без них и
// печатается заново. Снимать комментарии текстом нельзя — строковый литерал со
// знаками `//` внутри перестал бы читаться.
func sourceWithoutComments(t *testing.T, path string) string {
	t.Helper()
	fset := token.NewFileSet()
	f, err := parser.ParseFile(fset, path, nil, 0)
	if err != nil {
		t.Fatalf("разбираю %s: %v", path, err)
	}
	var buf bytes.Buffer
	if err := printer.Fprint(&buf, fset, f); err != nil {
		t.Fatalf("печатаю %s: %v", path, err)
	}
	return buf.String()
}

// packageDirs — каталоги репозитория с рабочим кодом на Go, путями от корня
// модуля. Каталог без импортов внутрь модуля тоже считается: правила о предмете
// говорят, а не о его зависимостях.
func packageDirs(t *testing.T) map[string]bool {
	t.Helper()
	out := map[string]bool{}
	for _, path := range goFiles(t) {
		rel, err := filepath.Rel(repoRoot, filepath.Dir(path))
		if err != nil {
			t.Fatalf("отношу путь %s: %v", path, err)
		}
		out[rel] = true
	}
	return out
}

// goFiles — все нерабочие каталоги отброшены, файлы проверок тоже: правила
// говорят о рабочем коде.
func goFiles(t *testing.T) []string {
	t.Helper()
	var files []string
	err := filepath.WalkDir(repoRoot, func(path string, d os.DirEntry, err error) error {
		if err != nil {
			return err
		}
		if d.IsDir() {
			switch d.Name() {
			case ".git", "data", "docker", "node_modules":
				return filepath.SkipDir
			}
			return nil
		}
		if strings.HasSuffix(path, ".go") && !strings.HasSuffix(path, "_test.go") {
			files = append(files, path)
		}
		return nil
	})
	if err != nil {
		t.Fatalf("обхожу репозиторий: %v", err)
	}
	return files
}

func lineOf(body []byte, offset int) int {
	return 1 + strings.Count(string(body[:offset]), "\n")
}
