Материал Как устроен digitdisk: 2 206 строк на flang решают, 20 678 строк на Go делают
0%

Как устроен digitdisk: 2 206 строк на flang решают, 20 678 строк на Go делают

Как устроен digitdisk: 2 206 строк на flang решают, 20 678 строк на Go делают

digitdisk — инструмент, который смотрит, куда ушло место на диске, и по команде убирает лишнее. Написан он на двух языках: flang — это язык, где у функции есть постусловия, а компилятор их проверяет, — и Go, на котором говорят с операционной системой. Про то, что инструмент делает, есть отдельная страница; этот материал — про то, как он сделан внутри: где проходит граница между двумя языками, кто имеет право сказать «этот файл можно убрать» и почему экран это право не получает даже тогда, когда человек жмёт Backspace.

Читать интересно по одной причине: граница здесь проведена не по слоям приложения и не по пакетам, а по виду утверждения. Всё, что можно доказать, живёт в одном языке; всё, что можно только сделать, — в другом. Ниже видно, чего это стоило и что осталось за границей. Каждое число называет команду, которая его печатает, — их можно повторить в любом клоне.

Пять ответов сразу

вопрос ответ чем проверено
Кто решает судьбу файла? Ядро на flang — 2 206 строк, 81 функция, 14 постусловий. Хозяин на Go приговоров не выносит cat core/*.flang | grep -cE '^ *обеспечивает' → 14; то же с тотальная функция → 81
Кто делает работу? Хозяин на Go — 20 678 строк рукописного кода в 18 пакетах, плюс 9 149 строк тестов find host -name '*.go' ! -name '*_test.go' -exec cat {} + | wc -l → 20678
Может ли экран удалить в обход ядра? Нет. Backspace зовёт ту же clean.Make, что и подкоманда clean, и получает тот же приговор grep -n 'clean.Make' host/main.go → строки 450 и 567
Сколько внешних зависимостей у хозяина? Ноль. Файла host/go.sum нет вовсе; все семь require указывают внутрь дерева через replace ls host/go.sum → нет такого файла
Что с раскладкой экрана? Она существует в двух реализациях, и прогон требует, чтобы обе дали кадр байт в байт tools/sverka-ui.sh; сами не гоняли — см. «Чего мы не мерили»

Одной строкой, если дальше читать некогда: новое правило — это правка ядра на flang и его доказательств, а не правка команды на Go. Всё устройство инструмента подчинено тому, чтобы обойти это было неудобно.

Граница слоёв: факты вверх, приговор вниз

Ядро не ходит в файловую систему. Не потому, что в языке нет ввода-вывода — поручений там 22, и обход каталога среди них есть, — а потому, что отклик «Перечислено» несёт только имена, без размеров, а анализатор диска состоит из размеров. Добыть их можно лишь запуском процесса, и это замерено: 8,82 мс на запуск, то есть 16,5 минуты на дерево из 111 986 записей против 3,39 с нативного обхода. Довод записан в шапке core/disk-inventory.flang и повторён в core/README.md.

Поэтому факты собирает хозяин и подаёт их значением, а ядро возвращает решение тоже значением:

Подписи на схемах — имена из кода. В ядре они не переводятся ни на какой язык: МожноУбрать и Кэш — идентификаторы, доказанные в core/, и хозяин переводит только слово, которым их называет экран.

Граница проведена по поручению, которого нет, а не по функции, которой не хватает. Отсюда следствие, которое видно в дереве: пакет host/internal/clean никогда не спрашивает ядро сам — он получает уже вынесенный приговор вместе с находкой и дальше только исполняет.

Путь от нажатия Backspace до стёртого файла

На живом экране обхода Backspace стирает насовсем: корзины за этим нет, и restore потом работать не с чем. Это ровно то место, где инструмент мог бы завести вторую дорогу к удалению — «пользователь же указал пальцем». Не завёл:

Читать это стоит так: клавиша решает только, на какой земле спрашивать. Что на этой земле можно убрать — не её дело и не становится её делом. Путь, который слой не пометил, нельзя стереть, показав на него пальцем сильнее.

Три места на схеме держат остальное:

  • дерево обходится заново, а не берётся из показанного на экране: стирается то, чему ядро вынесло приговор сейчас, а не то, что было верно при прошлом замере;
  • журнал ложится целиком до первого удаления. У переноса в корзину после падения на середине остаются сами файлы; у стирания остаётся только список, и человеку, чьи это были файлы, отвечать на вопрос «что ушло» больше нечем;
  • отпечаток файла сверяется повторно между обходом и стиранием. Файл, изменившийся с момента, когда его судили, получает отказ, а не удаление.

Насколько тяжело подтверждать, решает тоже не экран: порог объёма приезжает в плане полем порог_крупного от core.Sizer. Экран со своим гигабайтом внутри продолжал бы называть 900 МиБ мелочью после того, как правило сказало иначе.

Удаление живёт в одном каталоге, и это сторожит flang

Правило дерева: вызовы, стирающие и переставляющие файлы — Remove(, Rename(, Truncate( — разрешены только в host/internal/clean/. Там вокруг них стоят приговор ядра, сверка отпечатка, корзина и журнал. RemoveAll запрещён всюду и без исключений, включая комментарии: сторож ловит само имя, потому что вызова без имени не бывает.

Проверяется это не обещанием, а прогоном — и сторож написан на том же flang, что и ядро (tools/licensing.flang, 494 строки):

flang io tools/licensing.flang    # код 0 — чисто

Сегодня в дереве:

что искали нашли команда
файлов с Remove(/Rename(/Truncate( вне clean/ 0 grep -rlE '(os\.)?(Remove|Rename|Truncate)\(' host --include='*.go' | grep -v '^host/internal/clean/'
вхождений RemoveAll где угодно в host/ 0 grep -rc RemoveAll host --include='*.go' | grep -v ':0'
причин отказа в guard() 10 sed -n '494,530p' host/internal/clean/clean.go | grep -c 'lang.Say('

Особенно стоит смотреть на защитный список. «Не трогай мои ~/projects» — это не ответ на вопрос «чем этот путь является», а распоряжение владельца машины. Поэтому он лежит у хозяина, в host/internal/protect, и умеет только вычитать. Правило, которое умеет ДОБАВИТЬ файл в план, туда не заводится: иначе в доказанный слой въехала бы неправда ради нужного действия.

Раскладка экрана: чужая библиотека подмодулем, печать в дереве

Раскладку — сколько клеток закрасить в полосе, где обрезать строку, какие строки раздела показать при этой прокрутке — считает библиотека flang-tui. Она подключена подмодулем, а не скопирована:

git submodule status ui-flang/flang-tui
# 6bb627cc45966bfc24cc2680e1eec2196fb2a43d ui-flang/flang-tui

Скопировать шесть .flang-файлов было бы проще. Но тогда на вопрос «откуда взялась эта строка и как её перепечатать» отвечал бы только тот, кто копировал, а подмодуль отвечает командой.

Дальше начинается место, из-за которого материал и стоило писать: в дереве лежит напечатанный компилятором код — 22 596 строк Go, которых никто не писал руками (5 754 в core/out-go, 16 842 в ui-flang/out-go). Довод, почему он там, записан в трёх местах и одними и теми же словами: AGENTS.md, core/README.md, ui-flang/README.md. Довод один — чтобы сборка из чистого клона требовала только Go, без компилятора flang. Цена тоже названа в трёх местах: правка руками внутри out-go/дефект, а не правка, потому что make -C core печать начинается с rm -rf и сотрёт её молча.

Раскладка на flang стоит за признаком сборки flangui и умолчанием не является: без признака экран считает рукописный Go в layout_stock.go (258 строк). Пара layout_stock.go / layout_flang.go (81 строка) — одни и те же имена, те же подписи, те же места вызова. Менять раскладку значит менять обе.

Две реализации сверяются прогоном, а не обещанием

Одна сборка держит одну реализацию, поэтому сравнить их изнутри go test нельзя: нужны два прогона одного дерева с разными признаками. Этим и занят tools/sverka-ui.sh:

tools/sverka-ui.sh
# == снимки кадров: 4 глубины цвета × ширины 40/80/120/200 × 2 языка × все разделы
#    СОВПАЛО байт в байт
# ...
# сверка раскладки: расхождений 0

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

Мак без cgo: девять символов и девять прыжков

Показателей машины на macOS в Go из коробки нет, а cgo тянет за собой компилятор C и ломает статическую сборку. host/internal/libsystem берёт девять символов Mach прямо из libSystem.B.dylib — без единой строки C:

//go:cgo_import_dynamic libc_sysctl sysctl "/usr/lib/libSystem.B.dylib"
//go:cgo_import_dynamic libc_host_statistics64 host_statistics64 "/usr/lib/libSystem.B.dylib"
//go:cgo_import_dynamic libc_proc_pidinfo proc_pidinfo "/usr/lib/libSystem.B.dylib"

Всего таких строк девять, а import "C" в дереве нет ни одного:

что считали сколько команда
динамических импортов 9 grep -c go:cgo_import_dynamic host/internal/libsystem/libsystem_darwin.go
вхождений import "C" во всём хозяине 0 grep -rn 'import \"C\"' host --include='*.go' | wc -l

К каждому символу приложена заглушка на ассемблере — один прыжок и адрес этого прыжка как данные:

TEXT libc_sysctl_trampoline<>(SB),NOSPLIT,$0-0
	JMP	libc_sysctl(SB)
GLOBL	·libc_sysctl_trampoline_addr(SB), RODATA, $8
DATA	·libc_sysctl_trampoline_addr(SB)/8, $libc_sysctl_trampoline<>(SB)

Прыжок существует потому, что компоновщик Go разрешает динамический импорт по имени на этапе сборки и связывает его при старте, а вызывающему нужен обычный адрес, чтобы отдать его в syscall.syscall6. Прыжок и превращает одно в другое.

Файлов два — libsystem_darwin_amd64.s и libsystem_darwin_arm64.s, — и они совпадают построчно: diff между ними даёт ноль строк. Ассемблеру нужна архитектура в имени файла, а не в коде.

Ни одной внешней зависимости

Хозяин собран на одной стандартной библиотеке Go. В host/go.mod семь require, и все семь указывают внутрь дерева через replace: одно на ядро (flangprogram../core/out-go) и шесть на модули раскладки. Файла host/go.sum в дереве нет вовсе — его нечем заполнять.

То же и в проверках: ни Python, ни JavaScript в дереве нет, и заводить их не надо. Сторож лицензий и сверка печати написаны на flang, а компилятору нужен только cc.

Один перечень команд и один словарь

Две вещи в этом дереве заведены как данные, а не как код, и обе — по одной причине: список, названный в трёх местах, — это три возможности разойтись.

Подкоманд восемь, и перечень у них ровно одинcli.Commands (host/internal/cli/cli.go:66). Из него берёт справка --help, из него же рисуется раздел КОМАНДЫ на живом экране, и по нему сверяется страница руководства digitdisk.1. Расхождение любых двух из трёх роняет go test -count=1 ./..., причём в обе стороны: ключ, заведённый в коде и не названный на странице, — такой же дефект, как обратное.

Экран этот перечень не только показывает, но и запускает, и «что будет, если нажать Enter на этой строке» лежит там же, полем Start: запустить здесь, закрыть экран и выполнить, спросить путь и выполнить, или не отсюда — и тогда поле Instead говорит, где команда живёт. Довод в исходнике сформулирован прямо: это свойство подкоманды, а не рисунка. Держи его в экране — и через месяц список экрана разойдётся со списком справки на одну строку, которую никто не заметит. Безопасность при этом отдельно: purge не запускается ни с одного экрана вовсе.

Словарь тоже один, и ключ статьи — сама русская строка, а не выдуманное имя: l.T("СИСТЕМА"). Русский тогда не может разойтись сам с собой, а английский лежит рядом. Сегодня это 852 пары в восьми файлах (grep -hoE '^\s+"[^"]+":\s+"' host/internal/lang/dict_*.go | wc -l → 852).

Три границы у перевода проведены жёстко, и все три видны в коде:

  • каждая строка, которую видит человек, имеет пару — не обещание, а прогон: go test ./internal/lang/ читает исходник хозяина, находит все вызовы T/F/Say/Errorf и валится на строке без пары, на кириллице в пакетах вывода мимо словаря, на мёртвой статье словаря и на разошедшихся %-заполнителях;
  • --json не переводится — ключи и машинные значения байт в байт те же. Русские слова, которые уже ездят в JSON значениями (разряд Кэш, приговор МожноУбрать), — это имена решающего слоя и записи журнала, а не текст вывода. Для них есть lang.Phrase: по-русски в файл, на языке читателя на экран;
  • имена в ядре не переводятся вообще. Ни одна буква в core/ от языка вывода не меняется; хозяин переводит только слово, которым их называет экран.

Чего мы не мерили

Этот материал — разбор устройства, а не замер. Границу знания стоит назвать прямо, чтобы никто не прочитал в нём больше, чем в нём есть.

  • Скорость мы здесь не мерили ни разу. Единственное число о времени в этом тексте — 8,82 мс на запуск процесса и 3,39 с на обход дерева из 111 986 записей; оно взято из шапки core/disk-inventory.flang, где обосновывается чистота ядра, и относится к сравнению двух дорог, а не к скорости инструмента.
  • Цена раскладки на flang против рукописного Go не названа. У сверки есть ключ --замер, печатающий цену кадра и нажатия в обеих сборках. Мы его не гоняли, числа у нас нет, и ставить сюда догадку нельзя.
  • Правильность ядра доказана не вся. 14 постусловий и 185 примеров (cat core/*.flang | grep -cE '^ *пример' → 185) — это то, что проверяет flang check и flang test. Утверждения вроде «инструмент не удалит нужного» из них не следует: постусловие «убрать можно только мусор» верно ровно настолько, насколько верно определение мусора. Дерево само говорит об этом: пока приметы искались подстрокой пути, постусловие держалось, а инструмент четырежды ошибся на живой машине.
  • Сверку двух реализаций экрана мы не запускали в этой работе. Дерево открывалось только на чтение; «расхождений 0» — это то, что печатает скрипт по своему устройству, а не наш прогон. То же и с make -C core сверка (240 входов): число названо в core/README.md, не нами.
  • Ничего не сказано про Windows. В дереве есть libsystem для macOS и procfs для Linux; третьей ветки нет, и была ли она нужна — вопрос, который здесь не разбирался.
  • Мы не сравнивали digitdisk ни с одним чужим чистильщиком по качеству разбора. Заимствовать код у проектов под копилефтом дерево запрещает, наблюдать чужую работу — нет; но замера «кто точнее» у нас не существует.

Одной строкой в конце

Версия дерева, по которому всё посчитано, — 0.8.0 (cat VERSION). Числа сняты 3 сентября 2026 года на рабочей копии; в другом коммите они будут другими, и именно поэтому рядом с каждым стоит команда, а не только результат.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов
Дальше