English · Русский
digitdisk prints two readings of a machine — where the disk space went,
directories by size and the largest files, and how the machine is feeling
right now, the processor and each of its cores, memory, disk, network and
video cards — and it can act on the first of them: clean removes files, in
three steps, none of which is a surprise.
status and analyze read and write nothing. clean shows a plan and needs
--apply to move anything; what it moves goes to a корзина inside the tree you
named and comes back with restore; erasing is a separate command with a
separate confirmation. What may be removed is not a list of well-known paths
and not a pattern: it is exactly what the decision layer in core/ gives the
verdict «МожноУбрать», which is proved to be nothing outside Кэш, Журнал and
Сборка.
The concrete places — npm's cache, Go's build cache, Xcode's derived data —
digitdisk knows from a справочник, a separate data file edited without a
rebuild. The справочник names a разряд; the verdict is still the core's, and
thresholds, directories, symlinks and content-addressed stores are judged
exactly as before. To say "leave this alone" there is the
защитный список; what past cleanups did is
digitdisk history.
core/ the readings as a flang specification, plus the Go printed from it into core/out-go
host/ the Go host: system calls, the command line, the output
host/internal/lang/ the dictionary: what a person reads, in both languages
host/internal/places/places.conf the справочник of known places: data, not code
packaging/ the Homebrew formula
scripts/ the release build
tools/ the licence gate
docs/ notes that are not this page
The split is the whole design. Everything that can be decided without touching
the operating system is decided in core/: a specification in flang, checked by
flang's own runs and printed into Go. Everything that must touch the
operating system — walking directories, statfs, reading counters — is
hand-written Go in host/. The core never opens a file; the host never decides
what a number means.
core/out-go is printed, not written. Hand edits there are lost at the next
print — see AGENTS.md.
Released binaries: Linux (x86-64, arm64) and macOS (Apple Silicon, Intel). All four are produced by one cross-compilation on Linux, with CGO off and a repeatable fingerprint — see macOS below.
brew install digitable-lol/tap/digitdisk
digitdisk --versionThe formula installs the released binary. It does not compile anything on your
machine and does not need a Go toolchain. Its source is
packaging/homebrew/digitdisk.rb; the copy
Homebrew reads lives in
digitable-lol/homebrew-tap.
It also installs both manual pages, and into the two places man looks: the
English one as share/man/man1/digitdisk.1, the Russian one as
share/man/ru/man1/digitdisk.1. So man digitdisk answers in English and
LANG=ru_RU.UTF-8 man digitdisk answers in Russian, with nothing to configure
— man picks the translation by locale on its own.
V=0.1.0; A=amd64 # or A=arm64
base=https://github.com/digitable-lol/digitdisk/releases/download/v$V
curl -fsSLO $base/digitdisk-$V-linux-$A.tar.gz
curl -fsSLO $base/SHA256SUMS
sha256sum --check --ignore-missing SHA256SUMS
tar -xzf digitdisk-$V-linux-$A.tar.gz
sudo install -m 0755 digitdisk-$V-linux-$A/digitdisk /usr/local/bin/digitdiskCheck the sums before unpacking, not after. Every release also carries the formula, so the two ways install the same bytes.
A Go toolchain is enough from a clean checkout — not for go build in general,
but for the flangcore tag below: the print that tag links in is committed
(core/out-go, 6,304 lines; why that one and not every print, see
core/README.md). The build runs inside host/: that is
where the module lives, and there is no module at the root.
cd host && go build -tags flangcore -o ../digitdisk .-tags flangcore is what puts the flang core inside the binary. Without it the
host builds against a placeholder that counts but decides nothing —
digitdisk --version names which one is inside, so a build is never in doubt.
There are two tags, and the second is not the default yet. flangui also puts
the screen layout in flang inside the binary — the
flang-tui library, wired in as a
submodule under ui-flang/:
cd host && go build -tags flangcore,flangui -o ../digitdisk .Both builds print the same bytes — that is a run, not a promise
(tools/sverka-ui.sh). But the flang layout recomputes its postconditions on
every return and costs 13–21 ms per keystroke against 0.13–0.19 ms for the
hand-written Go. A timer redraw hides behind the ≈1.5 s of collection; a
keystroke is waited for — so the default stays hand-written Go for now. The
numbers, and what would change the default: ui-flang/README.md.
The host builds and runs on macOS, arm64 and x86-64. It takes its facts from
sysctl(3), getfsstat(2), the routing socket and the documented functions of
libSystem instead of /proc and /sys; the flang core is untouched by any of
that, because the core has never known what a system call is.
cd host && GOOS=darwin go build -tags flangcore -o ../digitdisk .Why there is no cgo. The obvious way to call a C function from Go is cgo,
and it would have ended the release: the four binaries are cross-compiled on one
Linux machine and checked byte for byte against a second build of themselves,
and cgo ends both properties at once. So the calls are made the way the Go
standard library itself makes them on macOS — the symbol is recorded as a
dynamic import, a two-instruction assembly stub jumps to it, and the call goes
out through syscall.syscall6. The Go linker writes the import into the Mach-O
file and the system loader binds it to libSystem at start-up, exactly as it
binds the imports the runtime already needs. No Mac is needed to build this; one
is needed only to check it.
How the layouts are proved. The decoders are written from Apple's headers, not from anybody else's source, and no number is printed until its provenance has been confirmed on the machine itself:
| What is read | What proves it was read correctly |
|---|---|
process record (kinfo_proc) |
our own pid, parent and user turn up where we expect them |
process memory and threads (proc_taskinfo) |
the kernel says how many bytes it wrote; our own process holds pages and has at least one thread |
command line (KERN_PROCARGS2) |
our own arguments match os.Args word for word, which the runtime got by another road |
memory breakdown (vm_statistics64) |
no page count exceeds the machine's pages; the read-ahead pages do not outnumber the free ones the kernel folds them into; and the disjoint buckets sum to hw.memsize within a third of a percent |
| CPU busy share | it is a ratio of two differences, so it depends on no tick rate at all |
per-processor shares (processor_cpu_load_info) |
the kernel says how many processors it wrote about, and that is the number hw.logicalcpu gives; their mean comes out as the machine-wide share, which is the sum of exactly those counters |
interface counters (if_data64) |
the MTU matches what the standard library reports |
If a check does not agree, the field stays empty rather than being printed on a
guess. On top of that, every push runs those same self-checks on live GitHub
macOS runners, on Apple Silicon and on Intel:
.github/workflows/check.yml does not only build — it takes a snapshot and
looks for numbers in it.
What macOS measures, and the call each number comes from:
| Reading | Source |
|---|---|
| host, kernel, release, model | sysctl kern.hostname, kern.osrelease, kern.version, kern.osproductversion, kern.osversion, hw.machine, hw.model |
| uptime | sysctl kern.boottime (struct timeval) |
| load average, cores | sysctl vm.loadavg (struct loadavg), hw.logicalcpu |
| CPU busy share | host_statistics(HOST_CPU_LOAD_INFO) |
| the share of each core | host_processor_info(PROCESSOR_CPU_LOAD_INFO) |
| memory total, page size, swap | sysctl hw.memsize, hw.pagesize, vm.swapusage (struct xsw_usage) |
| memory free, cache, available, used, wired, compressed | host_statistics64(HOST_VM_INFO64) (struct vm_statistics64) |
| processes: the list, and how many | sysctl kern.proc.all (struct kinfo_proc) |
| per-process memory, threads, threads on a processor, CPU time | proc_pidinfo(PROC_PIDTASKINFO) (struct proc_taskinfo) |
| per-process command lines | sysctl {CTL_KERN, KERN_PROCARGS2, pid} |
| disks | getfsstat(2) (struct statfs) |
| interfaces, addresses | net.Interfaces |
| interface counters | sysctl NET_RT_IFLIST2 (struct if_data64) |
"Used" and "available" are the two sides of one statement: memory that is
neither free nor file cache is in use. It is the same reading free(1) gives on
Linux, and the report prints the arithmetic next to the number.
What is still missing on a Mac, and why. Two kinds, and they are not the same kind:
- Closed by permission, not by the language. The memory, threads and command
line of a process belonging to another user are refused to anybody but the
administrator: the kernel checks the owner. Running under
sudofills those rows in; nothing else will. - The system publishes no counters. The cards themselves are read on a Mac — out of the IORegistry, through IOKit, without cgo (see below) — but the registry names only the card, its driver and, for a card on the PCI bus, its address and identifiers. Neither the busy share nor the memory in use is there, and a card that shares the machine's memory has no memory of its own to report.
- Not published by the system. Die temperature comes from the SMC through IOKit, and Apple documents no interface to it — what circulates is a reverse-engineered structure. A number read that way would be a guess wearing a unit, so there is none.
The report names them in one line and leaves it at that. The reasons live behind
digitdisk status --why and in --json.
Two more macOS facts worth knowing before reading a report: a walk of /
stops at /System/Volumes/Data unless --cross-device is given, because the
system and the data volume are two filesystems; and a directory the privacy
machinery refuses is counted as "нет доступа", the same as an unreadable
directory on Linux.
Reprinting the core additionally needs flang, and only when the specification changed:
make -C core # check, print into core/out-go and core/out-c, compareRelease archives are built by
scripts/build-release.sh, which builds every
target twice and refuses to package if the two builds differ: the same commit
and the same Go toolchain give the same archive, byte for byte.
./digitdisk # no subcommand: the same as `status`, with its defaults
./digitdisk analyze <path> # where the space went: directories by size, the largest files
./digitdisk status # how the machine feels: CPU, memory, disk, network
./digitdisk places # what the tool knows about concrete caches, and what of it is here
./digitdisk history <path> # what past cleanups under this root did
./digitdisk run make -j8 # run somebody else's command and show what it cost
./digitdisk -c 'make && make test' # the same, shorter; a line with metacharacters goes through a shell
./digitdisk --version # version, build hash, toolchain, decision layer
./digitdisk --help # subcommands and flags, one line each
./digitdisk status --lang ru # this run in Russian; every subcommand takes --lang ru|en
man digitdisk # the reference: subcommands, flags, files, examples, exit codes
LANG=ru_RU.UTF-8 man digitdisk # the same page in RussianAll four readings take --json. None of them writes anything. A word that is
not a subcommand is refused with code 2, never guessed at; a flag in place of a
subcommand belongs to status, so digitdisk --json and digitdisk status --json are one command.
run is the one subcommand that does not look at the disk. It starts what it
is given, shows the cost live, and says what it cost when it is over. The
short spelling is -c; digitdisk -c make -j8 and digitdisk run make -j8
are one command.
$ digitdisk -c go build -a -o /dev/null ./...
… the command's output goes as it went; on the last row of the terminal, the cost:
0:04 CPU 10%, avg 474% memory 22.2 MiB, peak 301.6 MiB 2 processes
… and when it is over, two lines about what it cost:
digitdisk: command «go build -a -o /dev/null ./...»: code 0, 8.3 s
digitdisk: CPU time 1 min 17 s (943% on average), peak memory about 301.6 MiB,
processes 16; accounting — a /proc walk every 320 ms — the memory peak is approximate
Four things such wrappers usually lie about, and what happens here with each:
- The tree, not one child.
npm ci,make -jand a Go build spawn dozens of processes; measuring the direct child shows almost nothing. The whole tree is counted: by a control group of our own (cgroup v2 — the kernel counts, exactly) and, where the machine gives none, by a walk over/procalong parent links. The summary names the way that answered, and never passes an approximation off as an exact figure. - The command's output is untouched. Not a byte of ours goes to standard
output:
digitdisk run make | tee logputs into the file exactly whatmake | tee logputs there. The status line lives on the last row of the terminal (through the scroll region) and does not appear at all when standard error is not a terminal. A full-screen program —vim,ssh,less— takes the terminal for itself, and the line goes away and comes back on its own. - The exit code and the signals are the command's. The code travels out
unchanged, so the wrapper can be put into a script.
Ctrl-Creaches the command: it stays in the wrapper's process group, and the terminal signals both. A command killed by a signal is repeated on the wrapper with the same signal. - About the video card, only what can be known. Memory per process the
driver publishes (behind
--gpu-tool, throughnvidia-smi); the share of the card's time per process it does not. The load of the whole card is not counted as the command's: somebody else may be computing beside it. What cannot be known is said in words.
The wrapper's keys go BEFORE the command, the command's keys after it:
digitdisk run --json ls --json gives the first --json to the wrapper and the
second to ls. The summary and --json go to standard error, because standard
output is taken by the command.
digitdisk writes in Russian and in English, and everything a person reads is in
both: the sections of the report, the labels, the units, the разряды and
приговоры on the screen, --help, the refusals, --why, the list of commands
on the live screen, and both manual pages.
Who chooses, in the order they are asked:
--lang ru|en |
this run; every subcommand takes it |
DIGITDISK_LANG=ru|en |
this session |
~/.digitable/digitdisk/settings.conf |
what was chosen before |
| the question | asked once, on a first run at a terminal |
LC_ALL, LC_MESSAGES, LANG |
the machine's locale, in that order |
| nothing said | English |
The default is English, and the reason is POSIX rather than taste. An unset
locale, C and POSIX all name the portable locale, whose messages are
English by definition. A machine that has said nothing about its language has
not said "Russian" — it has said "the portable one" — and answering it in
Russian would be a guess about the reader. Somebody who wants Russian either
has a ru locale, or is asked once and says so.
Where there is no terminal, nothing is asked and nothing is written. A
pipe, a file, a script, a CI job, --json: no question, no settings file
brought into being, and the language comes from the locale. Both ends of the
conversation have to be a terminal for the question to happen at all — stdin
and stderr — because a question written to a terminal whose answer would come
from a pipe hangs forever, and a tool that hangs in somebody's build is worse
than a tool in the wrong language.
Writing in a home directory is an action, and it is announced. digitdisk
stores two things there: the language — and only after a person answered the
question with their own hands — and a mark that the move of the settings has
already been mentioned, so that it is not mentioned at every run. Either way
it says what it wrote and where, in one line on stderr — «язык сохранён:
~/.digitable/digitdisk/settings.conf». If the directory cannot be written — a
read-only mount, a directory owned by somebody else — the run goes on in the
language that was chosen and says plainly that it was not saved: refusing to
look at a disk because a preference could not be stored would be answering a
small problem with a big one. digitdisk --version names the language of the
run and which of the six lines above decided it.
Numbers and dates are written the way each language writes them: «12,3 ГиБ»
against 12.3 GiB, a non-breaking space against a comma between the thousands,
02.09.2026 against 2026-09-02, Б/КиБ/МиБ against B/KiB/MiB,
дн against d. That is not decoration. «12,3» read as English is twelve and
three, and a report whose numbers change meaning with the reader is worse than
a report in the wrong language, because the wrong language is obvious and a
wrong number is not.
The keys and the machine values are the same in either language: a script that
parses digitdisk clean --json must not care what language the person who ran
it reads. Russian words do travel in that JSON as VALUES, and they stay exactly
where they are — 33 fields carry one. grep -rn 'json:"' host/internal host/*.go lists every field there is; these are the two kinds among them.
20 of them are identifiers of the договор, and are not text at all: разряд
(Кэш, Журнал, Сборка, Загрузка, Крупное, Неизвестное), приговор
(МожноУбрать, Спросить, НеТрогать), вид (Файл, Каталог, Ссылка),
якорь (ОтКорня, ГдеУгодно, and the anchors a справочник row is written
with), the система column of the справочник, and the kind of a protection
rule (путь, разряд) — counting the places where they are the keys of
by_class and by_verdict rather than a value. They may not change and need
not: they are the names the layer in core/ proves things about.
13 of them carry human text: the refusals отказ and не_сделано, the
notes замечание and беда, the map missing — whose KEYS are Russian too,
being the names of the readings — uptime_human («5д 03:14»), the name of the
decision layer (решающий_слой in three records, decider in a fourth), and
where the справочник came from (справочник, откуда). Those are records of
what happened, written once and read back later by restore, purge and
history; rewriting a журнал to suit whoever opens it next would make it a
worse record. lang.Phrase is what holds both properties at once — the Russian
wording into the file, the reader's language onto the screen — so translating a
refusal moves no byte of the JSON.
For that second group there is a way forward that breaks nothing, and it is
proposed here rather than done: a machine code beside the Russian value —
отказ_код next to отказ — with the old field kept for good. A reader that
has always matched on the Russian sentence goes on working, a new one matches
the code, and nothing has to be guessed about which. None of it is written yet.
The names inside the flang core are not translated and will not be.
МожноУбрать and Кэш are identifiers of the layer in core/, proved there
and named there. What is translated is the WORD THE SCREEN SHOWS for them, and
that happens in the host, in host/internal/lang: the value that arrived is
never touched, so the identifier goes on travelling in the JSON unchanged. That
is where the border runs — core/ does not know that a language exists, and
not a letter of it moves when the output changes language.
./digitdisk clean <path> # the plan: what, how much, why. Nothing is touched.
./digitdisk clean <path> --apply # move into <path>/.digitdisk-trash/<stamp>/
./digitdisk restore <trash> # put it all back
./digitdisk purge <trash> --confirm N # erase. This one cannot be undone.The default is the harmless one: clean without --apply opens no file for
writing and does not even create the корзина, so finding out what it would do
never means having it done.
--apply is a rename(2) into a корзина inside the same tree, which is why it
is instant and reversible — and why it frees no space at all: the bytes are
still there under another name. From the shell only purge frees space, it
needs --confirm N with N the exact number of files in the корзина, and the
failure message does not tell you N — you get it by running purge with no
flag and reading the plan. A confirmation you can satisfy without looking
confirms nothing. Past the корзина — at once and for good — erases only
Backspace on the live screen, and it erases what you pointed at rather than
what the core called rubbish:
«Backspace: not "find the rubbish yourself", but "erase THIS"».
Every корзина carries a journal.json: what was moved, from where, how many
bytes, when, and where it went. It is written before the first file moves, so
a crash in the middle still leaves something restore can empty back, and it
survives purge as the record of what is gone.
A file that changed between the walk and the move is not moved. digitdisk remembers each file's dev/ino, size, mtime and mode, checks them again before touching it, and refuses by name — "размер изменился (был 25 Б, стал 30 Б)" — rather than removing something it no longer recognises.
The plan is meant to be read. Every list stops at --top (15 by default,
the same as analyze; --top 0 prints all of it) and ends with a line saying
how many files and bytes were left out. The counts do not move: the total, the
bytes and the breakdown by разряд are computed over the whole plan and are
independent of --top — a summary that shrank with the screen would be a
summary of the screen. --json is never cut: --json is how scripts call this
tool, and a shortened work list would make clean --json | jq quietly wrong.
The core's rules know what a cache IS in general: a path component called
.cache, Caches, cache. That is enough to recognise a cache and not enough
to recognise npm's, whose store is ~/.npm/_cacache — no component with the
word cache anywhere in it. The missing knowledge is a LIST OF PLACES, and a
list is data.
./digitdisk places # the whole справочник and what of it is here
./digitdisk places --json # the same for a machine
./digitdisk clean <path> --places FILE # your own справочник instead of the built-in one
./digitdisk clean <path> --no-places # judge by приметы alone, as before 0.4.0It lives in
host/internal/places/places.conf, travels
inside the binary as the default, and is replaced whole — by --places or by
~/.digitable/digitdisk/places.conf. A row looks like this:
разряд | якорь | система | путь | переменная | имя | источник | имя_en
кэш|дом|все|.npm//_cacache|npm_config_cache|npm: кэш загрузок|https://docs.npmjs.com/...|npm: download cache
The SOURCE — the seventh field — is mandatory, and not for decoration: every
row comes from the tool's own documentation, and a place is listed only when
that documentation calls it a cache, a log, or derived data the tool will
rebuild by itself. The eighth field is the English name and is optional: a row
without it is read exactly as before and shows its Russian name in either
language. All 102 rows of the built-in directory have one — cd host && go test ./internal/places/ says how many.
The double slash splits the path into a base and a tail: the base is what the
environment variable relocates (npm_config_cache, GOCACHE, CARGO_HOME,
GRADLE_USER_HOME and others), so a developer who moved a cache gets their real
place instead of one that is no longer there.
How it reaches the verdict. A row becomes a "цепь" — the place's path with a
slash at both ends, /home/u/.npm/_cacache/. The host assembles the chain; the
core matches it, and the slashes are what makes the match a match of whole
COMPONENTS: /home/u/x.npm/_cacache/ does not contain /home/u/.npm/_cacache/,
because there is no slash before .npm. A справочник whose chains are not
bounded is refused whole («Справочник ограничен») — matching one as a bare
substring would bring back the bug fixed on 1 September.
What it may not do. It names a разряд and nothing else, and only four of them: Кэш, Журнал, Сборка, Загрузка. «Крупное» is decided by size and «Неизвестное» means "no place matched", and the core refuses to let a file assert either (постусловие «Место обосновано»). It softens no threshold, removes no directory and no symlink, and a content-addressed store stays untouched: постусловие И3 outranks any line of the file. Invariant И1 — «МожноУбрать» never leaves Кэш, Журнал and Сборка — holds exactly as before, however many places the file knows.
How to say "do not touch this", by path and by разряд:
./digitdisk clean <path> --protect ~/projects # the path and everything under it
./digitdisk clean <path> --protect разряд:Загрузка # a whole разряд
./digitdisk clean <path> --protect-file FILE # a list from a fileWithout a flag, ~/.digitable/digitdisk/protect.conf is read; a row there is
путь|~/projects|why or разряд|Журнал|why. A path written without a leading
slash protects that chain of components at any depth.
The защитный список lives in the host and not in the rules, and that is not
an implementation detail. The core answers one question — what this path IS —
and every answer it gives is proved; "do not touch my ~/projects" is not an
answer to that question: the path may very well be a cache, and writing the
opposite into the справочник would be putting a falsehood into the layer to get
an effect. An instruction from the person who owns the machine belongs where the
host already keeps its veto — next to the checks in internal/clean. That is
why it weakens nothing: the list can only subtract from a plan, and no
постусловие of the core moves because of it. What it protected is printed in its
own ЗАЩИЩЕНО section, with the rule and the file line, rather than quietly
missing from the plan — and it is kept apart from ОТКАЗАНО, because a refusal
means the two layers disagree and somebody should look at the rules, while a
protection means the rules worked and a person overruled the answer.
~/.digitable/digitdisk/settings.conf the language, and nothing else
~/.digitable/digitdisk/places.conf a справочник of one's own
~/.digitable/digitdisk/protect.conf the защитный список
There was one home already — ~/.config/digitdisk/ — and the language would
have made a second. Two homes are two places to look for one answer, and every
document would then have to say which of them holds what. So there is one, and
it is not digitdisk's alone: ~/.digitable/ is the family's, and the tools
beside this one keep their settings beside it.
The old home is not broken and not deleted. ~/.config/digitdisk/places.conf
and ~/.config/digitdisk/protect.conf are still READ where they are; a run
that takes a file from there says so once and not at every start; and nothing
is copied on anybody's behalf, because a tool that writes into a person's home
unasked is the thing this tool exists to clean up after. A file lying in both
homes is read from the new one: it was moved, and the copy left behind is not
the one that was meant.
./digitdisk history <path> # a cleaned root, the корзина store, or one корзина
./digitdisk history <path> --jsonWhat was removed, when, how many bytes are sitting in корзины, how much went
back, how much was erased — and what puts the last one back. digitdisk
remembers nothing between runs: every number is read out of the same
journal.json files that restore and purge obey. A separate history
database would be a second account of the same events, and the two would
disagree the first time somebody moved a корзина with mv.
"Freed" in that summary counts only what was erased: moving into a корзина frees no bytes at all, and a number claiming otherwise would be a lie about the disk.
What is cleaned goes into digitdisk's own корзина inside the tree, not into the desktop Trash, for three reasons, one of which is a number.
The number. A корзина inside the tree means rename(2): on this machine
moving a gibibyte does not register on the timer at all (0.00 s, three runs),
because no bytes move. A корзина across a filesystem boundary turns the move
into a copy: the same gibibyte written to disk with fsync takes 0.91 s per
GiB (best of three: 0.91 / 1.01 / 1.12). The cost of reversibility would
become the size of the cleanup, the file would exist twice while the copy runs
(so the space has to be free beforehand), and a crash halfway would leave half a
file. Both ~/.local/share/Trash on Linux and ~/.Trash on macOS live in the
home directory, and cleaning usually happens on other volumes.
The write boundary. "Does not leave the tree you named" is a property of the
system calls digitdisk uses: everything goes through an os.Root opened on the
root, which cannot be walked out of even through a symlink. A cleanup of
/var/tmp that writes into ~/.local/share/Trash cancels that property.
The two systems have no common behaviour. On Linux the Trash is defined by
the freedesktop.org specification:
files/ and info/ with .trashinfo records — and the same specification says
the home Trash only accepts files from its own filesystem, while another volume
needs a .Trash-$uid at its top level, which digitdisk would have to create. On
macOS the layout is different and Finder's "Put Back" lives in an unpublished
store: the documented way in is NSFileManager trashItemAtURL:, which is Cocoa,
which is cgo, which is the end of cross-building four targets from one machine
with a reproducible digest. There is no common behaviour to implement here —
there are two different Trashes and one way to lie about freed space.
What there is instead: digitdisk's корзина is an ordinary directory. Whoever wants to hand it to the system Trash hands it over themselves, in one gesture, and knows they did.
СИСТЕМА — the marks, and what a machine is recognised by. On the left a
column of two marks: the system's own — Ubuntu, Debian, macOS — and the
Digitable mark under it. On the right what a person wants to know about their
own machine: node (user@host), distribution, the machine's model, kernel and
word size, shell, desktop, terminal, uptime, the processor on one line, the
memory on one line, the video cards on one line. The model is what the firmware
calls the machine — /sys/class/dmi/id/sys_vendor and product_name on Linux,
hw.model on macOS; the processor is the model name line of /proc/cpuinfo,
or machdep.cpu.brand_string on macOS.
Both marks are drawn on ONE grid: 7×8 dots laid out in half cells (▀ the
upper dot, ▄ the lower, █ both), which is four rows of seven columns —
exactly the grid the Digitable mark was drawn on. Two things follow: the marks
are one handwriting rather than two, and they are all one width, so the column
of fields no longer moves from machine to machine. The marks were drawn in this
tree and nowhere else (somebody else's collection is somebody else's work under
somebody else's licence) and none of them is a portrait of a logo: a mark is a
hint at which system this is, and a hint is all that survives seven dots
across. Where the hint is the honest one it is the hint that is drawn: an arch
for Arch, a hat for Red Hat.
Each mark names ONE trait it is recognised by, and the trait is written down as a check over the grid: a ring and three islands for Ubuntu, a stroke with two ends — one at the edge, one deep inside — for Debian, a crown with a brim wider than itself for Red Hat, two pillars and an opening downwards for Arch, a width that only grows downwards for Alpine, six rays for NixOS, a spine with four legs and a tail off to one side for SUSE, a bowl with a stem and a hook for Gentoo, a stalk and a notch underneath for macOS, three prongs on a crossbar for BSD, a window with a neck and a foot for the general mark. All eleven traits are run against all eleven marks, and "yes" stands exactly on the diagonal: a trait two marks pass is not a trait but a coincidence. The distance between the marks is named too: the closest pair differs by 10 dots out of 56.
Two traits did not fit into seven dots, and both were replaced deliberately. SUSE's chameleon head turned into a blob with two holes; another trait of the same creature was taken — the whole lizard seen from above: head, spine, four legs and a tail. The BSD daemon's horns read as a beetle; its pitchfork was taken instead — three prongs on a crossbar.
A mark wears the colour the system paints ITSELF with. The rule is one rule, and it is not "make them all different": Ubuntu orange, Red Hat red, SUSE green, Gentoo purple, Arch, Alpine and NixOS blue. Cyan is the brand's own and no system wears it: the Digitable mark stands right under the system's, and a system of the same colour would merge with it. macOS has no colour of its own — the Apple mark is monochrome — and takes the silver of the palette; the BSDs have no single colour at all (FreeBSD red, NetBSD orange, OpenBSD gold), so OpenBSD's gold is taken, because red belongs to the system that is named after it. Systems sharing a colour with a neighbour were eight out of ten; they are five now, and all five share it honestly.
They are drawn from the same block characters as the gauges (█), the history
of samples (▁▂▃▄▅▆▇█) and the section rules (─). A terminal that cannot
draw those shows no gauge, no rule and — in Russian — no label at all; the old
rule "marks in printable ASCII only" was protecting the marks alone in the
middle of output that terminal cannot read anyway. The rule is gone and the
price is named: where UTF-8 is not decoded the marks fall apart exactly as the
gauges already do.
There are ten system marks and one general one, and that is a decision rather than a count of what got finished. A mark belongs to a system that draws its own picture; a rebuild takes the mark of what it is a rebuild of — Mint under Ubuntu's, Rocky, Alma, CentOS and Oracle under Red Hat's, and Fedora and RHEL under one hat: one line from one vendor, and on a grid of seven dots there is no difference between them anybody could see. A distribution nobody drew gets the general mark — a screen on a stand — rather than an empty space.
ЗАГРУЗКА — every core of it. "Занято ЦП" is one number for the whole
machine: on a machine with 256 cores it says "8%" both when the load is spread
and when one core is on fire and the rest are asleep. So the cores are drawn
underneath it, and the screen picks how: while the gauges fit the height, every
core gets its own gauge in columns; when they stop fitting, a map where one
cell is one core, plus the list of the busiest. On 256 cores the map is 4 rows
of 64 cells at 80 columns, and at 200 columns all 256 gauges fit instead, in 26
rows. The printed snapshot gets two lines of it — "разброс" (minimum, median,
maximum and the number of the busiest core) and "под нагрузкой" (how many cores
are busy more than half the time) — in the same words the screen uses. The
source is /proc/stat line by line on Linux and
host_processor_info(PROCESSOR_CPU_LOAD_INFO) on macOS — and on both the list
is published only if the mean of the cores comes out as the machine-wide share,
which is the sum of those same counters.
ВИДЕОКАРТЫ — a section of its own, and there may be several cards. Name,
busy share, memory used out of total, temperature, power and clock, for each
card. The cards come from files: /sys/class/drm, the display-class devices of
the PCI bus (a card with no driver is still a card), and
/proc/driver/nvidia. The name comes from the driver, and where the driver is
silent, from the pci.ids database the distribution ships; nothing of it is
copied into this tree. What is shown is what the driver published:
| driver | what it gives in files |
|---|---|
amdgpu |
load, memory, temperature, clock, power |
i915, xe |
temperature and power on the newer chips; no busy share |
nvidia |
the name, the bus and the firmware versions — and not one counter |
mgag200 and its kind |
the name and nothing else |
--gpu-tool allows asking nvidia-smi — somebody else's program, not a
file. Without the key it is never run; with it, every card says underneath
where its numbers came from: "числа из /sys/class/drm/card1/device" or "числа
от чужой программы nvidia-smi". A row about a card the files never saw is
thrown away: a program cannot add hardware to a machine. Power is read as the
hwmon documentation defines it, in microwatts, and printed only if the result
is at least half a watt: some drivers count in something else, and a number
without a unit is not a number.
On macOS the cards come out of the IORegistry, and without cgo too. What a
Mac knows about its graphics lives in the device registry, and the documented
way in is IOKit: Core Foundation objects rather than numbers. The door is the
one the Mach functions are taken through — //go:cgo_import_dynamic, an
assembly stub and syscall.syscall6 (host/internal/iokit, twenty-three
symbols out of IOKit, CoreFoundation and libSystem); cgo stays off and the
release still cross-compiles from Linux. The registry is asked by the classes
IOAccelerator and IOPCIDevice, and devices that are not of the display
class are dropped by their PCI class code.
What a Mac gives and what it does not:
| field | from | empty when |
|---|---|---|
| name | model of the PCI node, else the driver's name |
on Apple Silicon there is no model in the registry — the driver's name is printed |
| driver | IOClass of the entry, else its class |
never |
| bus, vendor, identifiers | pcidebug, vendor-id, device-id |
for a card that is not on the PCI bus (Apple Silicon) |
| total memory | VRAM,totalsize or VRAM,totalMB |
for a card that shares the machine's memory |
| load, memory in use, temperature, power | — | always: the registry publishes no such counters |
The check does not take its own word for it: go test ./host/internal/iokit
compares the machine's model from the registry against hw.model from
sysctl and refuses to call the answer read if the two disagree. The decoding
of entries is checked separately and on any machine, against what was captured
from both macOS runners.
A snapshot printed as text (digitdisk status --plain, into a pipe or a file)
reads as a card of the machine rather than a column of labels. On the left is
the same column of two marks the live screen draws: the system's mark, and
under it the Digitable one — a lowercase "d", the cursor bar and the
continuation stroke, three of the four elements of the brand mark; both were
drawn in this tree and taken from nowhere else. The drawing is one for both
views (host/internal/report/mark.go), so a mark cannot appear on the screen
and go missing from the print. To the right of the marks stands the name of the
machine, and under it what the machine is: distribution, model, kernel with its
word size, shell, desktop, terminal,
uptime, processor and the moment the snapshot was taken. The shell, the desktop
and the terminal were collected before and never printed; a field with no value
is not printed at all — "desktop —" on a server is noise, not a fact.
Then come the sections — ЗАГРУЗКА, ПАМЯТЬ, ПРОЦЕССЫ, ДИСКИ, СЕТЬ, ТЕМПЕРАТУРА, ВИДЕОКАРТЫ — each under its name and a rule that runs to the right edge. The order is the order of the live screen's pages and the labels are the same words: whoever has learned one reads the other without learning it again.
The colour is the Digitable Focus palette, the screen's own, and the roles
in it are the same: section names in cyan, labels muted, values bright, and a
share of something full in green, yellow and red by the very thresholds that
paint the gauges of the live screen (go test ./host/internal/ui checks that).
Colour appears on a terminal and nowhere else: a pipe, a file, /dev/null,
NO_COLOR, an empty TERM and TERM=dumb all get the same text with not one
escape byte in it, and the layout does not change with it — widths are counted
in visible characters, not in bytes.
The width is the terminal's and is held between 72 and 120 columns: any narrower and the six-column table of disks does not lay out at all, any wider and a line runs across the whole screen so the eye loses the start of the next one. Printed where there is no width to ask for, it takes 100 columns, the same on every run, so two snapshots can be compared line by line.
Long tables are cut by --top — the very key that already cuts the ten
longest processes. By default that is ten rows in ДИСКИ, СЕТЬ and ТЕМПЕРАТУРА,
and what is left over is named by its count and by the way to see it: "and 15
more; all of them — digitdisk status --top 0". Nothing is thrown away — --top 0 prints everything.
In a terminal, digitdisk and digitdisk status open a live screen in the
Digitable Focus palette: the sections of the printed report as pages that keep
measuring themselves. ← → and Tab move between them, 1…9 go straight to
one, ↑ ↓ scroll a long one, p holds, r measures now, l switches the
language, q leaves. There are eleven sections; the digits reach the first
nine, and the last two — ВИДЕОКАРТЫ and НЕ ПРОЧИТАНО — sit to the left of the
first, one and two ← away from it.
The first section is КОМАНДЫ (COMMANDS), and it does more than name. It is
the same list of subcommands --help and the man page are built from, except
that here the chosen line runs: ↑ ↓ and 1…8 choose, Enter starts.
It is named in the section strip and in the footer at every width from forty
columns up — no key has to be known in advance; ? still leads there too.
analyze and clean ask for a path on the same input line digitdisk analyze
without a path already opens: it offers the current directory, completes on
Tab and lists the matching subdirectories. Agreeing is one keystroke, so the
line says what that costs: walking a home directory is millions of entries and
minutes. The numbers move from the first second, and q stops the walk.
Cleaning from the screen goes the same road as digitdisk clean: the
core's verdict, the plan with its breakdown by разряд and its trash, and the
exact number of files typed out by hand. Backspace asks the core a
different question — not "may this be removed" but "what is this and what do
you risk": it erases for good everything you pointed at, shows the core's word
for it (Исходники, Кэш, Хранилище) and its warning before anything goes,
and asks the harder the more dangerous what is going is —
«Backspace: not "find the rubbish yourself", but "erase THIS"».
purge is started from no screen under any condition: it empties a whole
корзина and the screen has none open; restore and history live in the
ЖУРНАЛ section of the analyze screen, and the list says so on the line under
it.
A subcommand run from the screen prints as it always prints, and Enter brings
the status screen back. The program does not restart in between: the terminal
passes from screen to screen, while the process, the chosen language and
everything else stay as they were.
l is the one key here that touches anything outside the screen: it turns the
whole report into the other language where the reader is looking at it, and
puts the new choice into settings.conf, so that the next run — and digitdisk clean tomorrow — speaks the same language. It says which file it wrote, on the
screen, for the six seconds after; a program that silently rewrites a file in a
home directory is the thing this tool is for cleaning up after.
Everywhere else it prints, exactly as it always has. A pipe, a file,
/dev/null, --json, TERM=dumb and an empty TERM all receive the text
report: the screen is never drawn into something that is not a terminal, so
scripts see what they have always seen.
--plain |
print the snapshot once, even in a terminal |
--live |
demand the screen; fail rather than print if there is none |
--interval MS |
how often the screen measures again (default 2000) |
DIGITDISK_PALETTE |
carbon (default), paper, signal — the palettes of the stack |
NO_COLOR is honoured: the screen still runs, it is simply drawn without
colour.
In a terminal digitdisk analyze shows not only the result but the walk
itself, and once the walk is over it becomes the place the work is done from.
While the walk runs. A walk over millions of entries takes minutes, and for
those minutes the walk is what is happening: the entry count and the byte
total climb, the directory being read right now is named, so is the rate, and
the top-level directories fill up in front of you — shares, bars, and an order
that rearranges itself as they grow. Until the walk ends, the largest
directory is a guess, not a result: the next directory read can overturn it.
The list is therefore marked with one word — ПРЕДВАРИТЕЛЬНО, preliminary —
and the mark comes off exactly when the walk finishes. q stops the walk and
yields no report: half a walk presented as a whole one is exactly the lie this
screen must not tell.
When the walk is over — eight sections (total, tree, largest, removable, classes, skipped, places, journal) and a keyboard:
Tab, 1…8 |
sections |
↑ ↓ k j |
rows; g G to the top and the bottom |
→ Enter |
into a directory |
← |
back out |
Space |
tick a directory; . ticks the one you stand in |
c |
the cleaning plan for what is ticked, and its confirmation |
Backspace |
erase for good what is ticked, or the row under the cursor when nothing is; it erases what you pointed at, rubbish or not — the core warns, it does not forbid |
o |
walk another directory (Tab completes the path, Ctrl-U clears it) |
Enter in JOURNAL |
put a корзина back where it came from |
l |
the language of the screen — the key status gives it |
? |
keys and commands |
q |
leave; the report is printed afterwards as always |
The vim h/l pair is deliberately not here: l is the language, the same key
on both screens, and one letter cannot mean two things. The vim movement keys
j k g G stay; into and out of a directory is the arrows and Enter.
Backspace no longer walks back out — it erases, and one key cannot mean two
things. The whole screen speaks both languages — headings, numbers (1,4 МиБ
against 1.4 MiB), the cleaning plan and its confirmation.
Ticking directories, seeing what the decision layer says about them and asking for them to go — all without leaving. No second road to removal is built for it, and that is the whole point:
- What goes is exactly what
clean.Makeput in a plan: what the decision layer gave the verdict «МожноУбрать» and the host's own guard let past. A tick narrows the ground the plan is made on and can add no path to it: «Спросить» and «НеТрогать» items inside a ticked directory are not in the plan, however hard they are ticked. - The plan comes first: how many files, how many bytes, broken down by разряд, into which корзина. Building it walks the tree again, so the plan is always about what is on the disk now.
- While a long thing runs, you see it running. Working something out — the cleaning plan, «считается стирание», «считается возврат» — shows what has been counted so far: entries, bytes, and the directory being read right now. No share and no "N seconds left": there is nothing to compute them from, and an invented number is worse than silence. Carrying out a plan already read — moving, erasing, putting back — knows the list in advance and therefore names both «N of M» and what is going right now; a long path is cut in the middle so that its head and its tail both stay.
- After the deed the tree is counted again, so what stands on the screen is what is on the disk and not what was there before it. The ground that was touched is measured, not the whole корень: after a забой that ground is empty and reading it costs a fraction of a walk. A hard link moves the recount to the whole корень — its content has a second name, and subtracting what was erased would claim space nobody freed.
- Then the exact number of files is asked for, the way
purge --confirm Nasks. A different number moves nothing, and the screen says so. - The move is the same
rename(2)into a корзина inside the корень. It frees no space, and the line saying so stands next to the number of bytes rather than in a footnote. - Putting it back is here too: the ЖУРНАЛ section,
Enteron a корзина, the same confirmation by count.
A корзина is not always what a person wants: it frees no space, and "clean"
through it means "move". So the screen has a second key — Backspace — and
it erases for good, with no корзина and no way back.
Two verbs, two questions, and they must not be confused. Until 3 September
2026 Backspace built its plan out of the same приговор clean uses, and to a
marked directory of a person's own source code it answered "СТИРАТЬ НЕЧЕГО: the
decision layer marked nothing of what you ticked". That was wrong, and wrong in
the most expensive place: «НеТрогать» is the core saying I will not take this
on my own, and never a person may not.
| what the core is asked | who decides | |
|---|---|---|
c — clean |
"may this be removed" — the приговор | the core: it goes looking for rubbish itself |
Backspace |
"what is this and what do you risk" — the природа | the person: they have already pointed |
Backspace erases everything under what is ticked — the directories too —
whether the core calls it rubbish or not. The cleaning rule is untouched to the
letter: c and digitdisk clean take exactly what they took.
The core is still asked — for a WORD, not for leave. About every path it
answers with a природа: Мусор, Свежее, Исходники, Личное, Хранилище,
ПодПрисмотром. The word stands in the question before anything goes, and
the warning stands beside it:
ERASE FOR GOOD
vanishing for good 4 paths: 3 files, 1 directories, 71 B
71 B will be freed — really freed, this time; there will be no trash and no way back.
the core calls this: Source 3
THIS IS NOT RUBBISH: source — written by hand, and nobody will write it again.
That is a warning, not a refusal: type the count and the directory goes.
The hard bans are only where the machine or the tool itself breaks. The list is short, and this is all of it:
| where nothing is erased from | why | what to do if you are right after all |
|---|---|---|
the root / |
the whole machine lies under it | name a directory, not the root |
a whole system directory — /usr, /etc, /var, /bin, /sbin, /lib*, /boot, /dev, /proc, /sys, /run, /opt, /srv, /mnt, /media, /home, /root, /System, /Library, /Applications, /private, /Users, /Volumes |
a package manager puts its contents there, and the machine will not boot once it is gone | remove the package with whatever installed it, or point inside — what is banned is the whole directory, not what lies in it |
| the whole home directory | it is everything you have on this machine | point at a directory inside the home; Backspace takes it with everything in it |
digitdisk's own directory and ~/.digitable/digitdisk/ |
a tool that takes itself out halfway has nothing left to finish its journal with | remove the tool the way you installed it; edit the settings in an editor |
a .digitdisk-trash корзина |
it holds the journal of what went into it | digitdisk purge <trash> --confirm N |
A banned place is banned itself and from above: pointing at its parent is not
a way round. Every refusal names both the reason and the way past it — a refusal
without "what to do" sends a person looking for a road around the tool, which is
rm -rf typed blind. Separately from the bans, the protect list holds here
too: --protect and protect.conf still subtract, and the rule that stopped
something is named.
The confirmation is proportional to the danger, not to the volume alone. There are three steps, and the scale is stated by the decision layer (the «Строгость» function), not by the screen:
| step | when | how it is confirmed | why that much |
|---|---|---|---|
| 1 | everything in the plan is Мусор to the core, the list is visible whole (up to seven paths), the volume is below «Порог крупного» |
one key, y |
the loss is recoverable: caches and build products the machine makes again. Asking hard here trains people to answer without looking |
| 2 | the plan holds Свежее, Исходники or Личное; or the list is cut; or the volume is above the threshold |
the exact number of paths | exactly what is in the list goes. The number cannot be typed without looking at it, and that is enough |
| 3 | the plan holds Хранилище or ПодПрисмотром |
the number of paths, then the word ERASE |
what goes is not a file but the integrity of a store that thinks itself whole, and the trouble surfaces at the next build, when nothing connects it to this keypress any more. The number proves the list was read, the word proves the warning was |
The strictness is taken from the worst path in the plan: one git object among
four hundred caches sets the price for the whole question, because the question
is asked once and takes them all. A layer that answers no second question at all
(a build without flangcore) buys step three: "I do not know what this is" is
the strongest reason to ask harder, never a reason to ask less.
Seven is measured, not chosen: that is how many rows are left for the list in a
24×80 window (TestOneKeyListIsWhatFitsInTheSmallestWindow). Above it the list
would have to be cut, and a cut list is one the reader cannot check, because the
question does not scroll. The size threshold is not the screen's either — it is
Порог крупного from core/disk-inventory.flang,
carried in the plan's порог_крупного field:
$ digitdisk clean ~ --json | grep порог_крупного
"порог_крупного": 1073741824The directories go too. "Delete the folder" means the folder: after the files Backspace removes the emptied directories, deepest first, with the call that refuses a non-empty one. A directory that still holds something — a refused file, a symlink — stays where it is and says why. There is no recursive remove anywhere in this tree.
What Backspace does not take: symbolic links (removing a link is not removing what it points at), the unreadable, the root of the walk, and anything inside digitdisk's own корзина.
What was erased goes into the same journal the cleaning uses, and is
distinguishable from it: the record carries "способ": "стирание", the корзина
beside the journal is empty, and restore and purge refuse such a journal out
loud. There is nothing to put back — but what vanished is on record, and that is
the only honest answer to "what did I lose".
purge is still started from no screen. It empties a whole корзина, and the
screen has no корзина open; the ЖУРНАЛ section names the command and the number
it will demand.
Memory. Walking the tree is possible because the tree of directories is
held in memory: over /srv (5,446,842 entries, 574,005 directories) that is
291 MiB of peak RSS against 22 MiB for the same walk without the screen — about
320 bytes per directory. The path map lives only in screen mode and is dropped
the moment the walk ends; analyze --plain and analyze --json build no tree
at all, so scripts and other people's pipelines stay at the 22 MiB they always
had. Past a million directories everything is still counted, not all of the
tree can be walked, and the screen says so.
The drawing does not slow the walk down. The screen redraws four times a second, but the walk hands over a snapshot of its counters no more often than once every thousand entries, and only when the screen has asked for one; nothing is summed up the tree per entry and nothing takes a lock. The plan and the move run off the drawing loop: the screen says work is in progress and listens to no key until it is back — otherwise a confirmation could be given blind.
The comparison is the "время" line analyze prints itself: digitdisk analyze /srv --plain against digitdisk analyze /srv in a terminal, over a tree of
5,446,842 entries and 434.8 GiB. The calm of the live list is measured too, not
promised: cd host && DIGITDISK_TREE=/srv go test ./internal/ui/ -run Calm -v
prints in how many frames the first ten rows changed order.
The output rule is the one status follows: a pipe, a file, /dev/null,
--json, TERM=dumb and an empty TERM all receive what they have always
received, byte for byte. --plain prints the report even in a terminal;
--live demands the screen.
- It does not delete by pattern, and it does not delete from a list of
paths. digitdisk does have a list of known places, and that list removes
nothing: the справочник names a РАЗРЯД, and what
cleanremoves is still exactly what the decision layer gave the verdict «МожноУбрать» — with the same thresholds, the same refusal to touch directories and symlinks, and the same refusal to touch content-addressed stores. A place is an argument, not an order. The host keeps a veto on top of that and refuses a directory, a symlink or anything unreadable even if the layer were to ask — and when the two disagree it prints the disagreement instead of acting on it. - It never deletes in one step, and never without being asked. There is no
flag that erases without a plan first and a separate confirmation after, and
cleanon its own touches nothing at all. - It does not leave the tree you named. Every path operation goes through
os.Rootopened on that directory: it resolves each component itself and cannot be walked out of, symbolic links included. The корзина must live inside the same tree — a корзина elsewhere would make every move a cross-filesystem copy, and the cost of reversibility would become the size of the cleanup. - It does not delete recursively.
os.RemoveAllappears nowhere in this tree andtools/licensing.flangfails the build if it ever does. Files go one at a time, from a list in a journal; empty directories go through the call that refuses a directory with anything in it. - It does not explain instead of measuring. Where there is a number, it is
printed; where there is none, a dash, and the name of the reading on one line
at the end. Why it is missing lives behind
digitdisk status --why, a flag of its own, and not in the middle of the report: a reader wants a number, not an essay about kernel calls. - It is not a fork of mole, and carries none of its GPL-3.0 code — the idea
came from there, the code did not. See
NOTICE.
| Document | What is in it |
|---|---|
LICENSE |
the binding text: BSD-2-Clause, verbatim |
LICENSE-RU.md |
what that licence means, in plain Russian |
NOTICE |
where the idea came from, what was deliberately not taken, and why |
AGENTS.md |
the rules of this tree: write boundary, no GPL, where removal may live, the order of the checks |
digitdisk.en.1 |
the manual page: subcommands, every flag, files, examples, exit codes |
digitdisk.1 |
the same page in Russian; the formula puts it where man looks for a translation |
flang io tools/licensing.flang # no copyleft; SPDX headers; removal only in host/internal/clean
flang check core/disk-inventory.flang && flang test core/disk-inventory.flang
make -C core # check, emit to Go and C, cross-check the two emissions
cd host && go vet ./... && go test -count=1 ./...
cd host && go test ./internal/lang/ # every line a person reads has a pair
cd host && GOOS=darwin GOARCH=arm64 go build ./... && GOOS=darwin GOARCH=amd64 go build ./...
cd host && GOOS=darwin go vet ./... # the macOS host, checked from a machine that is not one
scripts/build-release.sh # release archives, sums, formula; verifies the build repeatsThe translation is checked by a run and not promised in a document.
go test ./internal/lang/ reads the host's own source, finds every line that
reaches a person, and fails on one with no pair in the other language; it fails
separately on Cyrillic printed past the dictionary out of main, report,
ui or cli, on a dictionary entry nobody ever asks for, and on
%-placeholders that disagree between the two halves of one entry. What it
covered is printed by the run itself —
cd host && go test ./internal/lang/ -v -run 'Пары|Заполнители|Договор'— which at the moment reports 419 lines in the source, 488 entries in the dictionary, and 29 names of the договор translated as words.
The licensing guard and the emission cross-check are written in flang, not in
Python or JavaScript: neither is present in this tree. The flang compiler is a
single binary that needs only a C compiler (brew install digitable-lol/tap/flang, asdf, or make -C bootstrap in a clone of the
language — 1 min 54 s at -j4); it does not require Node.
The tree is complete and installable: the licences and the gate, the flang core
printed into core/out-go, the Go host with status, analyze, places,
history and the three steps of clean / restore / purge, in Russian and
in English, and the release path — scripts/build-release.sh,
the Homebrew formula with both manual pages, and the tag-driven workflow in
.github/workflows/release.yml. The version
lives in one place, VERSION; the build stamps it into the binary,
and the workflow refuses a tag that disagrees with it.