docs: sync ARCHITECTURE line counts and format list with code, gitignore speedtest binary

Line-count exceptions had drifted 18-80% behind actual file sizes, storage
section undercounted --format all as three formats instead of five, test
count was stale at 880 vs actual 930.
This commit is contained in:
Andrew Pimenov 2026-08-23 19:16:08 +03:00
parent 0f369ded04
commit 5dd078566c
3 changed files with 20 additions and 16 deletions

2
.gitignore vendored
View file

@ -6,6 +6,8 @@ bugs.md
llm_context* llm_context*
TASKS.md TASKS.md
docs/superpowers/ docs/superpowers/
speedtest.exe
speedtest.md
plans/ plans/
.venv/ .venv/

View file

@ -49,15 +49,16 @@ src/netsleuth/
**File-size exceptions.** The ~200-line guideline (Global Constraints) held for most modules, but these grew past it during implementation and were kept as single files rather than split, because each one is a single cohesive responsibility that splitting would only scatter across more files with a thinner reason to draw the line anywhere in particular: **File-size exceptions.** The ~200-line guideline (Global Constraints) held for most modules, but these grew past it during implementation and were kept as single files rather than split, because each one is a single cohesive responsibility that splitting would only scatter across more files with a thinner reason to draw the line anywhere in particular:
- `exporter.py` (~910 lines) — JSON and both Markdown languages are renderers of the same report shape; keeping them together is what guarantees the English and Russian reports can never drift on which sections or rows exist. `render_markdown(report, lang="en"|"ru")` and a handful of inline `_t(lang, en, ru)` calls at each string site render both languages from one code path, deliberately, instead of a second renderer file that could silently fall out of sync. This grew by five sections (TLS, DNS advanced, path diversity, prefix bench, DPI check) without changing that argument. - `exporter.py` (~1100 lines) — JSON and both Markdown languages are renderers of the same report shape; keeping them together is what guarantees the English and Russian reports can never drift on which sections or rows exist. `render_markdown(report, lang="en"|"ru")` and a handful of inline `_t(lang, en, ru)` calls at each string site render both languages from one code path, deliberately, instead of a second renderer file that could silently fall out of sync. This grew by five sections (TLS, DNS advanced, path diversity, prefix bench, DPI check) without changing that argument.
- `cli.py` (~805 lines) — the Typer app, every flag, and the full `diagnose()` phase pipeline; splitting flag parsing from what the flags toggle would just add an import hop. - `cli.py` (~1078 lines) — the Typer app, every flag, and the full `diagnose()` phase pipeline; splitting flag parsing from what the flags toggle would just add an import hop.
- `models.py` (~490 lines) — every shared dataclass plus `to_jsonable()`; this is deliberately the one place that defines the report's vocabulary. - `models.py` (~576 lines) — every shared dataclass plus `to_jsonable()`; this is deliberately the one place that defines the report's vocabulary.
- `interpret.py` (~600 lines) — threshold bands, the latency/path/speed/TLS/DNS/DPI/anycast/prefix finding generators, VPN scoring and bufferbloat grading all reason over the same `Finding[]` shape. - `interpret.py` (~1079 lines) — threshold bands, the latency/path/speed/TLS/DNS/DPI/anycast/prefix finding generators, VPN scoring and bufferbloat grading all reason over the same `Finding[]` shape.
- `bgp.py` (~305 lines) — four independent providers (RIPEstat, CAIDA ASRank, Team Cymru, PeeringDB) plus the disk cache they share. - `bgp.py` (~305 lines) — four independent providers (RIPEstat, CAIDA ASRank, Team Cymru, PeeringDB) plus the disk cache they share.
- `ip_geo.py` (~280 lines) — six independent provider normalizers plus the field-wise merge that reconciles them. - `ip_geo.py` (~280 lines) — six independent provider normalizers plus the field-wise merge that reconciles them.
- `netinfo.py`, `speed.py` (~260 lines each) — OS-specific capability probing and the four-tier speedtest cascade both fan out into several backends that don't split cleanly by single responsibility. - `netinfo.py` (~310 lines) — OS-specific capability probing fans out into several backends that don't split cleanly by single responsibility.
- `speed.py` (~260 lines) — the four-tier speedtest cascade fans out into several backends that don't split cleanly by single responsibility.
- `reputation.py` (~250 lines) — FireHOL netsets, Shodan InternetDB and DNSBL decoding are three unrelated reputation sources sharing one result shape. - `reputation.py` (~250 lines) — FireHOL netsets, Shodan InternetDB and DNSBL decoding are three unrelated reputation sources sharing one result shape.
- `probes/traceroute.py` (~225 lines) — the four-tier cascade plus the opt-in `tcp_trace` tier. - `probes/traceroute.py` (~284 lines) — the four-tier cascade plus the opt-in `tcp_trace` tier.
## Data flow ## Data flow
@ -190,7 +191,7 @@ The AS prefix benchmark (`probes/prefix_benchmark.py`) gets the same treatment f
## Storage ## Storage
- `./logs/<YYYY>/<MM>/<DD>/report_<ASN>_<HH-MM-SSZ>.{md,ru.md,json,prom,csv}` — the artifacts selected by `--format`/`output.formats` (default: English Markdown only; `all` writes all three: English Markdown, a full Russian translation with identical structure, and the JSON dump — English only, no `.ru.json`). When both Markdown languages are written they cross-link each other at the top, the same way `README.md`/`README.ru.md` do; writing only one skips the cross-link rather than pointing at a file that doesn't exist. `<ASN>` is the target's subject in target mode (an ASN, IP or domain) and the local egress ASN in auto mode. The run's start date becomes the directory (`exporter.report_date_dir()`), so browsing `logs/` means walking year, then month, then day; the filename itself only needs a readable time-of-day (`exporter.readable_timestamp()`, e.g. `19-12-00Z`) since the date is already the path — no `:` because Windows forbids it in filenames, dashes instead. Falls back to a flat `report_unknown_unknown.…` at the `logs/` root when `started_at` doesn't parse. Written temp-file-plus-`os.replace()`, always atomic — `atomic_write()` creates the whole `YYYY/MM/DD` chain via `mkdir(parents=True)`. Gitignored: reports contain the external IP, city, coordinates and ISP name. - `./logs/<YYYY>/<MM>/<DD>/report_<ASN>_<HH-MM-SSZ>.{md,ru.md,json,prom,csv}` — the artifacts selected by `--format`/`output.formats` (default: English Markdown only; `all` writes all five: English Markdown, a full Russian translation with identical structure, the JSON dump, the Prometheus text-format dump, and the CSV dump — English only, no `.ru.json`). When both Markdown languages are written they cross-link each other at the top, the same way `README.md`/`README.ru.md` do; writing only one skips the cross-link rather than pointing at a file that doesn't exist. `<ASN>` is the target's subject in target mode (an ASN, IP or domain) and the local egress ASN in auto mode. The run's start date becomes the directory (`exporter.report_date_dir()`), so browsing `logs/` means walking year, then month, then day; the filename itself only needs a readable time-of-day (`exporter.readable_timestamp()`, e.g. `19-12-00Z`) since the date is already the path — no `:` because Windows forbids it in filenames, dashes instead. Falls back to a flat `report_unknown_unknown.…` at the `logs/` root when `started_at` doesn't parse. Written temp-file-plus-`os.replace()`, always atomic — `atomic_write()` creates the whole `YYYY/MM/DD` chain via `mkdir(parents=True)`. Gitignored: reports contain the external IP, city, coordinates and ISP name.
- `./logs/<YYYY>/<MM>/<DD>/watch_<ASN>_<HH-MM-SSZ>.json` — one time-series artifact per `--watch` session, not one report per tick, dated by the session's `started_at` the same way one-shot reports are. - `./logs/<YYYY>/<MM>/<DD>/watch_<ASN>_<HH-MM-SSZ>.json` — one time-series artifact per `--watch` session, not one report per tick, dated by the session's `started_at` the same way one-shot reports are.
- `./.cache/firehol/*.netset` — downloaded blocklists, refreshed per `providers.firehol_refresh_hours`. - `./.cache/firehol/*.netset` — downloaded blocklists, refreshed per `providers.firehol_refresh_hours`.
- `./.cache/pdb-net-<asn>.json`, `./.cache/pdb-netixlan-<net_id>.json` — PeeringDB responses, valid for `providers.peeringdb_cache_hours`. - `./.cache/pdb-net-<asn>.json`, `./.cache/pdb-netixlan-<net_id>.json` — PeeringDB responses, valid for `providers.peeringdb_cache_hours`.
@ -204,4 +205,4 @@ Several sections back the L4-L7 probes, all opt-in at the CLI level; the ones wh
## Tests ## Tests
`uv run pytest -q`. Roughly 880 tests covering the parsers, normalizers, verdict engine, DNSBL decoding, throughput math, ping statistics, jitter percentiles, serialization and the report diff. Glue is deliberately untested: live HTTP, real ICMP, Typer wiring, `psutil`/`ctypes` calls and the `--watch` timing loop. Traceroute fixtures live in `tests/fixtures/traceroute/{windows,linux,darwin}/` and include a cp866-encoded Russian Windows sample, because that encoding is exactly what breaks naive parsers. DoH response fixtures (RFC 8484 JSON form) live in `tests/fixtures/api/doh_*.json` alongside the other provider fixtures. `uv run pytest -q`. Roughly 930 tests covering the parsers, normalizers, verdict engine, DNSBL decoding, throughput math, ping statistics, jitter percentiles, serialization and the report diff. Glue is deliberately untested: live HTTP, real ICMP, Typer wiring, `psutil`/`ctypes` calls and the `--watch` timing loop. Traceroute fixtures live in `tests/fixtures/traceroute/{windows,linux,darwin}/` and include a cp866-encoded Russian Windows sample, because that encoding is exactly what breaks naive parsers. DoH response fixtures (RFC 8484 JSON form) live in `tests/fixtures/api/doh_*.json` alongside the other provider fixtures.

View file

@ -49,15 +49,16 @@ src/netsleuth/
**Исключения по размеру файла.** Лимит ~200 строк (Общие ограничения) выдержан для большинства модулей, но эти выросли за него в процессе реализации и остались одним файлом, а не были разбиты, потому что каждый — единая цельная ответственность, и разбиение только раскидало бы её по файлам без чёткой причины проводить границу где-то конкретно: **Исключения по размеру файла.** Лимит ~200 строк (Общие ограничения) выдержан для большинства модулей, но эти выросли за него в процессе реализации и остались одним файлом, а не были разбиты, потому что каждый — единая цельная ответственность, и разбиение только раскидало бы её по файлам без чёткой причины проводить границу где-то конкретно:
- `exporter.py` (~910 строк) — JSON и оба языка Markdown — рендеры одной и той же формы отчёта; то, что они вместе, гарантирует, что английский и русский отчёты никогда не разойдутся в том, какие разделы или строки существуют. `render_markdown(report, lang="en"|"ru")` и точечные вызовы `_t(lang, en, ru)` в местах со строками рендерят оба языка из одного пути кода — намеренно, вместо отдельного файла-рендера, который мог бы незаметно разойтись. Файл вырос на пять разделов (TLS, DNS advanced, path diversity, prefix bench, DPI check), не меняя этот принцип. - `exporter.py` (~1100 строк) — JSON и оба языка Markdown — рендеры одной и той же формы отчёта; то, что они вместе, гарантирует, что английский и русский отчёты никогда не разойдутся в том, какие разделы или строки существуют. `render_markdown(report, lang="en"|"ru")` и точечные вызовы `_t(lang, en, ru)` в местах со строками рендерят оба языка из одного пути кода — намеренно, вместо отдельного файла-рендера, который мог бы незаметно разойтись. Файл вырос на пять разделов (TLS, DNS advanced, path diversity, prefix bench, DPI check), не меняя этот принцип.
- `cli.py` (~805 строк) — приложение Typer, все флаги и весь конвейер фаз `diagnose()`; разделение разбора флагов и того, что они переключают, добавило бы только лишний импорт. - `cli.py` (~1078 строк) — приложение Typer, все флаги и весь конвейер фаз `diagnose()`; разделение разбора флагов и того, что они переключают, добавило бы только лишний импорт.
- `models.py` (~490 строк) — все общие датаклассы плюс `to_jsonable()`; это намеренно единственное место, определяющее словарь отчёта. - `models.py` (~576 строк) — все общие датаклассы плюс `to_jsonable()`; это намеренно единственное место, определяющее словарь отчёта.
- `interpret.py` (~600 строк) — пороговые полосы, генераторы находок по задержке/маршруту/скорости/TLS/DNS/DPI/anycast/префиксам, скоринг VPN и оценка bufferbloat — всё рассуждает над одной формой `Finding[]`. - `interpret.py` (~1079 строк) — пороговые полосы, генераторы находок по задержке/маршруту/скорости/TLS/DNS/DPI/anycast/префиксам, скоринг VPN и оценка bufferbloat — всё рассуждает над одной формой `Finding[]`.
- `bgp.py` (~305 строк) — четыре независимых источника (RIPEstat, CAIDA ASRank, Team Cymru, PeeringDB) плюс общий для них дисковый кэш. - `bgp.py` (~305 строк) — четыре независимых источника (RIPEstat, CAIDA ASRank, Team Cymru, PeeringDB) плюс общий для них дисковый кэш.
- `ip_geo.py` (~280 строк) — шесть независимых нормализаторов провайдеров плюс пополевое слияние, которое их примиряет. - `ip_geo.py` (~280 строк) — шесть независимых нормализаторов провайдеров плюс пополевое слияние, которое их примиряет.
- `netinfo.py`, `speed.py` (~260 строк каждый) — определение возможностей по ОС и каскад замера скорости из четырёх уровней оба веерятся по нескольким бэкендам, которые не разбиваются чисто по единой ответственности. - `netinfo.py` (~310 строк) — определение возможностей по ОС веерится по нескольким бэкендам, которые не разбиваются чисто по единой ответственности.
- `speed.py` (~260 строк) — каскад замера скорости из четырёх уровней веерится по нескольким бэкендам, которые не разбиваются чисто по единой ответственности.
- `reputation.py` (~250 строк) — netset'ы FireHOL, Shodan InternetDB и декодирование DNSBL — три несвязанных источника репутации с одной общей формой результата. - `reputation.py` (~250 строк) — netset'ы FireHOL, Shodan InternetDB и декодирование DNSBL — три несвязанных источника репутации с одной общей формой результата.
- `probes/traceroute.py` (~225 строк) — каскад из четырёх уровней плюс опциональный уровень `tcp_trace`. - `probes/traceroute.py` (~284 строки) — каскад из четырёх уровней плюс опциональный уровень `tcp_trace`.
## Поток данных ## Поток данных
@ -190,7 +191,7 @@ src/netsleuth/
## Хранение ## Хранение
- `./logs/<YYYY>/<MM>/<DD>/report_<ASN>_<HH-MM-SSZ>.{md,ru.md,json,prom,csv}` — артефакты, выбранные `--format`/`output.formats` (по умолчанию: только английский Markdown; `all` пишет все три: английский Markdown, его полный перевод на русский с идентичной структурой и JSON-дамп — только английский, `.ru.json` нет). Когда пишутся оба языка Markdown, они ссылаются друг на друга вверху, так же как `README.md`/`README.ru.md`; если пишется только один — перекрёстная ссылка не ставится, чтобы не указывать на несуществующий файл. `<ASN>` — это цель в режиме target (ASN, IP или домен) и локальный ASN исходящего адреса в авто-режиме. Дата старта прогона становится директорией (`exporter.report_date_dir()`), так что просмотр `logs/` — это спуск по году, затем месяцу, затем дню; самому имени файла достаточно читаемого времени суток (`exporter.readable_timestamp()`, например `19-12-00Z`), поскольку дата уже есть в пути — без `:`, потому что Windows запрещает его в именах файлов, вместо него дефисы. При непарсящемся `started_at` — плоский `report_unknown_unknown.…` прямо в корне `logs/`. Запись через временный файл и `os.replace()`, всегда атомарно — `atomic_write()` создаёт всю цепочку `YYYY/MM/DD` через `mkdir(parents=True)`. В gitignore: отчёты содержат внешний IP, город, координаты и название провайдера. - `./logs/<YYYY>/<MM>/<DD>/report_<ASN>_<HH-MM-SSZ>.{md,ru.md,json,prom,csv}` — артефакты, выбранные `--format`/`output.formats` (по умолчанию: только английский Markdown; `all` пишет все пять: английский Markdown, его полный перевод на русский с идентичной структурой, JSON-дамп, дамп в формате Prometheus text и CSV-дамп — только английский, `.ru.json` нет). Когда пишутся оба языка Markdown, они ссылаются друг на друга вверху, так же как `README.md`/`README.ru.md`; если пишется только один — перекрёстная ссылка не ставится, чтобы не указывать на несуществующий файл. `<ASN>` — это цель в режиме target (ASN, IP или домен) и локальный ASN исходящего адреса в авто-режиме. Дата старта прогона становится директорией (`exporter.report_date_dir()`), так что просмотр `logs/` — это спуск по году, затем месяцу, затем дню; самому имени файла достаточно читаемого времени суток (`exporter.readable_timestamp()`, например `19-12-00Z`), поскольку дата уже есть в пути — без `:`, потому что Windows запрещает его в именах файлов, вместо него дефисы. При непарсящемся `started_at` — плоский `report_unknown_unknown.…` прямо в корне `logs/`. Запись через временный файл и `os.replace()`, всегда атомарно — `atomic_write()` создаёт всю цепочку `YYYY/MM/DD` через `mkdir(parents=True)`. В gitignore: отчёты содержат внешний IP, город, координаты и название провайдера.
- `./logs/<YYYY>/<MM>/<DD>/watch_<ASN>_<HH-MM-SSZ>.json` — один тайм-ряд на сессию `--watch`, а не отдельный отчёт на каждый тик, датирован по `started_at` сессии так же, как и одноразовые отчёты. - `./logs/<YYYY>/<MM>/<DD>/watch_<ASN>_<HH-MM-SSZ>.json` — один тайм-ряд на сессию `--watch`, а не отдельный отчёт на каждый тик, датирован по `started_at` сессии так же, как и одноразовые отчёты.
- `./.cache/firehol/*.netset` — скачанные блоклисты, обновляются по `providers.firehol_refresh_hours`. - `./.cache/firehol/*.netset` — скачанные блоклисты, обновляются по `providers.firehol_refresh_hours`.
- `./.cache/pdb-net-<asn>.json`, `./.cache/pdb-netixlan-<net_id>.json` — ответы PeeringDB, действительны `providers.peeringdb_cache_hours`. - `./.cache/pdb-net-<asn>.json`, `./.cache/pdb-netixlan-<net_id>.json` — ответы PeeringDB, действительны `providers.peeringdb_cache_hours`.
@ -204,4 +205,4 @@ src/netsleuth/
## Тесты ## Тесты
`uv run pytest -q`. Около 880 тестов: парсеры, нормализаторы, движок вердиктов, декодирование DNSBL, математика пропускной способности, статистика пингов, перцентили джиттера, сериализация и сравнение отчётов. Клей намеренно не тестируется: живой HTTP, настоящий ICMP, обвязка Typer, вызовы `psutil`/`ctypes` и цикл `--watch`. Фикстуры трассировок лежат в `tests/fixtures/traceroute/{windows,linux,darwin}/` и включают образец русской Windows в cp866 — именно эта кодировка ломает наивные парсеры. Фикстуры DoH-ответов (формат RFC 8484 JSON) лежат в `tests/fixtures/api/doh_*.json` рядом с фикстурами других провайдеров. `uv run pytest -q`. Около 930 тестов: парсеры, нормализаторы, движок вердиктов, декодирование DNSBL, математика пропускной способности, статистика пингов, перцентили джиттера, сериализация и сравнение отчётов. Клей намеренно не тестируется: живой HTTP, настоящий ICMP, обвязка Typer, вызовы `psutil`/`ctypes` и цикл `--watch`. Фикстуры трассировок лежат в `tests/fixtures/traceroute/{windows,linux,darwin}/` и включают образец русской Windows в cp866 — именно эта кодировка ломает наивные парсеры. Фикстуры DoH-ответов (формат RFC 8484 JSON) лежат в `tests/fixtures/api/doh_*.json` рядом с фикстурами других провайдеров.