Как устроен 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 года на рабочей копии; в другом коммите они будут другими, и
именно поэтому рядом с каждым стоит команда, а не только результат.