@vernikr/size-report 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,34 @@
1
+ import vm from 'vm';
2
+ import { workerData } from 'worker_threads';
3
+
4
+ /* Рабочий поток разбора: компилирует текст и отвечает причиной (или её
5
+ * отсутствием). Исполнения нет — `SourceTextModule` только разбирает текст,
6
+ * поэтому ни `import`, ни код модуля не выполняются: файл проекта остаётся
7
+ * чужим кодом, который никто не запускает.
8
+ *
9
+ * Флаги приходят от главного потока (`--experimental-vm-modules` — без него
10
+ * `vm.SourceTextModule` не существует, `--no-warnings` — иначе предупреждение об
11
+ * эксперименте ушло бы в вывод команды). Модуля может не быть: тогда ответ несёт
12
+ * `available: false`, и главный поток возвращается к запуску `node --check`.
13
+ *
14
+ * Готовность ответа отмечается в общей памяти: главный поток ждёт её синхронно
15
+ * (`Atomics.wait`), потому что измерение истории синхронное.
16
+ */
17
+ const { port, sig } = workerData;
18
+ const available = typeof vm.SourceTextModule === 'function';
19
+
20
+ port.on('message', (req) => {
21
+ port.postMessage({ id: req.id, available: available, error: parse(req.text) });
22
+ Atomics.store(sig, 0, 1);
23
+ Atomics.notify(sig, 0);
24
+ });
25
+
26
+ function parse(text) {
27
+ if (!available) return null;
28
+ try {
29
+ new vm.SourceTextModule(text, { identifier: 'module' });
30
+ return null;
31
+ } catch (e) {
32
+ return e.name + ': ' + e.message;
33
+ }
34
+ }
package/src/parse.js ADDED
@@ -0,0 +1,136 @@
1
+ import fs from 'fs';
2
+ import os from 'os';
3
+ import path from 'path';
4
+ import { execFileSync } from 'child_process';
5
+ import { Worker, MessageChannel, receiveMessageOnPort } from 'worker_threads';
6
+ import { fileURLToPath } from 'url';
7
+
8
+ /* Разбор модуля: один рабочий поток на прогон вместо запуска Node на каждую
9
+ * клетку.
10
+ *
11
+ * Зачем. Гард компиляции обязан понимать модуль (`import`/`export` в `.js` —
12
+ * обычное дело у проекта с бандлером, см. `strip.js`), а единственный разбор
13
+ * модуля без исполнения — `vm.SourceTextModule` — живёт только под флагом
14
+ * `--experimental-vm-modules`, которого у процесса нет. Раньше это решалось
15
+ * запуском `node --check` на каждую клетку: 97 мс на запуск и минуты на истории,
16
+ * где модуль меняется каждым коммитом.
17
+ *
18
+ * Как. Поток поднимается при первом модуле и живёт до конца прогона, поэтому
19
+ * скриптовые проекты за него не платят вовсе. Обмен синхронный — измерение
20
+ * истории синхронное: запрос уходит `postMessage`, готовность ответа отмечается
21
+ * в `SharedArrayBuffer`, а ответ забирается `receiveMessageOnPort` (тот же приём,
22
+ * что в примере Node для синхронного канала в поток). Поток `unref`-нут: команда
23
+ * заканчивается вместе со своей работой, а не вместе с потоком.
24
+ *
25
+ * Чем платит. Старт потока — разовая цена (~55 мс на машине замера), и разбор
26
+ * опирается на экспериментальный API: флаг `--experimental-vm-modules` передаётся
27
+ * самому потоку, поэтому команда пользователя не меняется. Текст передаётся в
28
+ * поток копией — на файлах в десятки мегабайт это десятки миллисекунд, всё ещё
29
+ * дешевле запуска процесса.
30
+ *
31
+ * Куда отступает. К запуску `node --check` — медленнее, но не мягче: когда файла
32
+ * потока нет (неполная упаковка), когда поток не ответил за отведённое время
33
+ * (умер) и когда в потоке не оказалось `vm.SourceTextModule` (Node без модулей
34
+ * vm). Отступление молчаливое: сломавшийся быстрый путь стоит секунд, а не
35
+ * правильности, и его место стережёт проверка способа разбора (`parseMode`).
36
+ */
37
+
38
+ const WORKER_FILE = fileURLToPath(new URL('./parse-worker.js', import.meta.url));
39
+ const FLAGS = ['--experimental-vm-modules', '--no-warnings'];
40
+ const WAIT_MS = 2000;
41
+
42
+ let parser = null; // живой поток { worker, port, sig } или null
43
+ let hopeless = false; // поток не поднялся: второй раз не пробуем
44
+ let seq = 0;
45
+ let mode = null; // 'thread' | 'node' — чем разобран последний модуль
46
+
47
+ /* Причина, по которой текст не разбирается как модуль, или null, если
48
+ * разбирается. «Не удалось проверить» наружу не выходит никогда: разбор без
49
+ * потока уходит в `node --check`, а он отвечает тем же — причиной или её
50
+ * отсутствием. */
51
+ export function moduleError(text) {
52
+ const fromThread = inThread(text);
53
+ if (fromThread !== undefined) {
54
+ mode = 'thread';
55
+ return fromThread;
56
+ }
57
+ mode = 'node';
58
+ return onNodeCheck(text);
59
+ }
60
+
61
+ /* Способ разбора последнего модуля. Нужен, чтобы быстрый путь не деградировал
62
+ * молча: проверка утверждает, что на проекте с модулями он действительно поток,
63
+ * а не прежний запуск. */
64
+ export function parseMode() {
65
+ return mode;
66
+ }
67
+
68
+ function inThread(text) {
69
+ const live = start();
70
+ if (!live) return undefined;
71
+ const id = ++seq;
72
+ // Сначала ноль в том же слове, которым поток отмечает готовность: ответ,
73
+ // пришедший раньше ожидания, иначе было бы видно как «ещё не начинали».
74
+ Atomics.store(live.sig, 0, 0);
75
+ live.port.postMessage({ id: id, text: text });
76
+ if (Atomics.wait(live.sig, 0, 0, WAIT_MS) === 'timed-out') return bury();
77
+ for (;;) {
78
+ const got = receiveMessageOnPort(live.port);
79
+ if (!got) return bury();
80
+ if (got.message.id !== id) continue; // прежний ответ (поток отвечал не нам)
81
+ if (got.message.available === false) return bury();
82
+ return got.message.error === null ? null : String(got.message.error);
83
+ }
84
+ }
85
+
86
+ function start() {
87
+ if (parser || hopeless) return parser;
88
+ if (!fs.existsSync(WORKER_FILE)) {
89
+ hopeless = true;
90
+ return null;
91
+ }
92
+ const sig = new Int32Array(new SharedArrayBuffer(4));
93
+ const { port1, port2 } = new MessageChannel();
94
+ try {
95
+ const worker = new Worker(WORKER_FILE, {
96
+ execArgv: FLAGS, workerData: { port: port2, sig: sig }, transferList: [port2]
97
+ });
98
+ // Без обработчика ошибка потока стала бы исключением процесса, а её место —
99
+ // в отступлении к запуску.
100
+ worker.on('error', bury);
101
+ worker.on('exit', bury);
102
+ worker.unref();
103
+ parser = { worker: worker, port: port1, sig: sig };
104
+ } catch (_e) {
105
+ hopeless = true;
106
+ }
107
+ return parser;
108
+ }
109
+
110
+ // Поток больше не годится: дальше разбираем запуском, и вернуться уже некуда.
111
+ function bury() {
112
+ const dead = parser;
113
+ parser = null;
114
+ hopeless = true;
115
+ if (dead) dead.worker.terminate();
116
+ return undefined;
117
+ }
118
+
119
+ /* Отступление: `node --check` по временному файлу. Расширение `.mjs` здесь не
120
+ * косметика — у временного файла нет манифеста, и только расширение говорит
121
+ * Node, что текст надо читать как модуль. Причина берётся из `stderr`: там
122
+ * сначала эхо строки с ошибкой, потом сам `SyntaxError` и стек. */
123
+ function onNodeCheck(text) {
124
+ const tmp = path.join(os.tmpdir(), 'size-table-guard-' + process.pid + '-mod.mjs');
125
+ try {
126
+ fs.writeFileSync(tmp, text);
127
+ execFileSync(process.execPath, ['--check', tmp], { stdio: ['ignore', 'pipe', 'pipe'] });
128
+ return null;
129
+ } catch (e) {
130
+ const lines = String((e && e.stderr) || (e && e.message) || e).split('\n')
131
+ .map((l) => l.trim()).filter((l) => l !== '');
132
+ return lines.find((l) => /^\w*Error\b/.test(l)) || lines[0] || 'модуль не разбирается';
133
+ } finally {
134
+ fs.rmSync(tmp, { force: true });
135
+ }
136
+ }
package/src/refusal.js ADDED
@@ -0,0 +1,133 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { fileURLToPath } from 'url';
4
+ import { TOOL_PKG } from './tool.js';
5
+
6
+ /* Отказ и справка: код выхода, сообщение с готовой командой починки и текст
7
+ * «--help». Стоит ниже всех в цепочке — ни о настройках, ни о git не знает,
8
+ * поэтому его может звать любой модуль. */
9
+
10
+ // Пакет — модуль, а подсказка в `loadConfig` цитирует путь самого движка: в ESM
11
+ // `__filename` нет, поэтому путь берётся от `import.meta.url`.
12
+ const __filename = fileURLToPath(import.meta.url);
13
+
14
+ /* Отказ — это код выхода и одна строка с готовой командой починки: по коду
15
+ * ветвится агент (таблица кодов в `PLAN.md` §4.1), по тексту — человек. Стек
16
+ * наружу не отдаётся вовсе: подсказки в нём нет, зато есть пути машины. */
17
+ export const EXIT = { OK: 0, VIOLATION: 1, CONFIG: 2, SHALLOW: 3, SENSOR: 4, INTERNAL: 5 };
18
+
19
+ export class Refusal extends Error {
20
+ constructor(code, message) {
21
+ super(message);
22
+ this.code = code;
23
+ }
24
+ }
25
+
26
+ export function refuse(code, message) {
27
+ throw new Refusal(code, message);
28
+ }
29
+
30
+ /* Причины отказа кодом 2 — одним списком, и он единственное место, где они
31
+ * перечислены словами: справка печатает их из него, таблица кодов в `README.md`
32
+ * сверяется с ним проверкой (`test/docs-commands.test.js`), а `refuseCause` не
33
+ * пропускает отказ, не назвавший причины. Поэтому «в документации сказано
34
+ * меньше, чем бывает» здесь не может случиться молча. Группы — по тому, откуда
35
+ * причина: разбор вызова, настройки и проект, история, хук, измерение. */
36
+ export const CONFIG_CAUSES = [
37
+ ['командная строка', [
38
+ 'незнакомый ключ', 'ключ без значения', 'повтор ключа', 'два режима сразу',
39
+ 'лишнее слово', 'команда и режим', 'неизвестная команда', 'несовместимый ключ',
40
+ 'нет ответа в JSON', 'два ответа сразу', 'нет коммита'
41
+ ]],
42
+ ['настройки и проект', [
43
+ 'нет файла настроек', 'настройки не разобраны', 'настройки неверны',
44
+ 'нет git', 'не git-репозиторий', 'конфиг уже есть'
45
+ ]],
46
+ ['история', ['нет такого коммита', 'коммит назван неточно', 'коммит вне истории']],
47
+ ['хук', ['чужой хук', 'чужой core.hooksPath', 'нечем звать инструмент']],
48
+ ['измерение', ['файл не JavaScript', 'минификатор не разобрал']]
49
+ ];
50
+
51
+ /* Причина — объявленное имя, а не украшение текста: неназванная не доедет до
52
+ * пользователя, потому что это дефект инструмента, а не тупик человека. */
53
+ export function refuseCause(cause, message) {
54
+ if (!CONFIG_CAUSES.some((g) => g[1].indexOf(cause) >= 0)) {
55
+ throw new Error('причина отказа не объявлена: ' + cause);
56
+ }
57
+ refuse(EXIT.CONFIG, message);
58
+ }
59
+
60
+ /* Строки справки про причины — из того же списка, поэтому справка не может
61
+ * разойтись с проверками. */
62
+ const CAUSE_LINES = CONFIG_CAUSES.map((g) => ' ' + g[0] + ': ' + g[1].join(' · '));
63
+
64
+ /* Как инструмент вызывается там, где его читают. Совет называет то, что лежит
65
+ * рядом, и никогда — имя пакета: `npx <имя>` запускает установленный пакет, только
66
+ * пока тот на месте, а в проекте без него имя уходит в реестр и запускает чужой
67
+ * пакет с тем же именем — текст, который должен выручать, приводит к чужому коду.
68
+ * Поэтому форма одна: путь внутри проекта (`node node_modules/<имя>/bin/size.js`) —
69
+ * в проекте с пакетом она работает, без пакета отказывает на месте и в сеть не идёт.
70
+ *
71
+ * Команда починки цитирует точку входа, а не сам движок: при импорте движок ничего
72
+ * не запускает, поэтому `--init` работает только через команду. Путь в репозитории
73
+ * пакета считается от места движка, а не от текущего каталога, — сообщение обязано
74
+ * работать из любого места проекта. */
75
+ export function invocation() {
76
+ const local = path.join('node_modules', TOOL_PKG.name, 'bin', 'size.js');
77
+ if (fs.existsSync(path.resolve(process.cwd(), local))) return 'node ' + local;
78
+ const bin = path.resolve(path.dirname(__filename), '..', 'bin', 'size.js');
79
+ const shown = path.relative(process.cwd(), bin);
80
+ return 'node ' + (shown === '' || shown.indexOf('..') === 0 ? bin : shown);
81
+ }
82
+
83
+ export function cliCommand(flag) {
84
+ return invocation() + ' ' + flag;
85
+ }
86
+
87
+ /* Путь внутри готовой команды: пробел или кавычка в нём сломали бы копирование,
88
+ * поэтому такой путь берётся в кавычки — так его и приняла бы оболочка. */
89
+ export function advicePath(p) {
90
+ return /[\s"'$`\\]/.test(p) ? JSON.stringify(p) : p;
91
+ }
92
+
93
+ export const USAGE = [
94
+ '@vernikr/size-report — таблица объёма файлов по коммитам.',
95
+ '',
96
+ 'Запуск: ' + invocation() + ' [команда] [режим] [ключи]',
97
+ '',
98
+ 'Команды:',
99
+ ' check [--json] полнота: настройки, история, пути, датчики (код 1 — путь',
100
+ ' истории не отслеживается и не объявлен исключением)',
101
+ ' explain <коммит> почему у коммита нет строки (имя ревизии, sha или его начало)',
102
+ ' doctor [--json] диагностика одним ответом: окружение, зависимости, настройки,',
103
+ ' покрытие (код 0 — делать нечего, иначе — первый по важности)',
104
+ ' install-hook поставить хуки post-commit и post-merge: отчёт пересобирается',
105
+ ' сам после каждого коммита и слияния, а если он в git — ложится',
106
+ ' отдельным коммитом',
107
+ ' uninstall-hook убрать хук и его состояние (проект возвращается к прежнему)',
108
+ ' hook-run то, что зовёт хук: пересборка и коммит отчёта (вручную не нужно)',
109
+ '',
110
+ 'Режимы:',
111
+ ' --init [файл] черновик настроек (--force — перезаписать существующий)',
112
+ ' --write собрать таблицу в файл из настроек',
113
+ ' --data данные контракта в stdout — для страницы и для агента',
114
+ ' --page [файл] страница отчёта (по умолчанию рядом с таблицей)',
115
+ ' --json прежняя форма данных в stdout',
116
+ ' (без режима) проверить, что таблица совпадает с историей',
117
+ '',
118
+ 'Ключи: --config <файл> — другие настройки; --help — эта справка.',
119
+ '',
120
+ '--json — форма ответа, а не отдельный режим, и правило у него одно: ответ бывает',
121
+ 'ровно у четырёх вызовов. Без команды это прежняя форма данных, у check, explain',
122
+ 'и doctor — их ответ; у остального ответа нет, и там --json — отказ, а не тишина.',
123
+ '',
124
+ 'Запуск один: команда и режим не совмещаются, режим тоже один, и лишнее слово',
125
+ 'вместе с незнакомым ключом — отказ с готовой командой, а не обычный прогон.',
126
+ '',
127
+ 'Коды выхода: 0 — всё хорошо, 1 — расхождение с историей или неполнота, 2 — вызов,',
128
+ 'настройки или окружение, 3 — неполная история, 4 — нет датчика, 5 — внутренняя',
129
+ 'ошибка. У doctor свой порядок: 2, 3, 1, 4 — по важности находки, а не по тому,',
130
+ 'что нашлось первым.',
131
+ '',
132
+ 'Причины отказа кодом 2 (их же называет таблица кодов в README.md):'
133
+ ].concat(CAUSE_LINES, ['']).join('\n');
package/src/render.js ADDED
@@ -0,0 +1,131 @@
1
+ import { fill, LOCALES } from './locales.js';
2
+ import { METRICS, metricView } from './metrics.js';
3
+ import { rowHref } from './journal.js';
4
+ import { cellParts, commitParts, nowModel, rowModel, valueParts } from './derived.js';
5
+ import { ARTIFACT_CSS, TABLE_CSS } from './css.js';
6
+
7
+ /* Статический отчёт: стили, разметка клетки и таблицы, примечание. Производные
8
+ * величины берёт из общего расчёта («derived.js») — того же, который исполняет
9
+ * страница. */
10
+
11
+ /* Оформление артефакта — своё плюс общая часть таблицы: ровно тот же текст
12
+ * таблицы получает и страница, поэтому оформление самой таблицы у двух отчётов
13
+ * одно. Что именно входит в каждую часть — в `src/css.js`. */
14
+ const CSS = ARTIFACT_CSS + '\n' + TABLE_CSS;
15
+
16
+ export function esc(s) {
17
+ return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
18
+ }
19
+
20
+ /* Разметка клетки строки-коммита: правила — в `cellParts`, здесь только тег и
21
+ * классы. Клетка-дельта: абсолютные числа стоят один раз в верхней строке, иначе
22
+ * крупное число повторялось бы в каждой строке и колонки расползались бы. */
23
+ export function cellHtml(cell, first) {
24
+ const parts = cellParts(cell.value, cell.delta, '-');
25
+ const cls = 'num' + (first ? ' g' : '') + (parts.miss ? ' miss' : '');
26
+ if (parts.dir === null) return '<td class="' + cls + '">' + parts.text + '</td>';
27
+ return '<td class="' + cls + '"><span class="delta ' + parts.dir + '">' + parts.text + '</span></td>';
28
+ }
29
+
30
+ // Разметка клетки верхней строки: правила — в `valueParts`.
31
+ export function valueHtml(value, first) {
32
+ const parts = valueParts(value);
33
+ return '<td class="num' + (first ? ' g' : '') + (parts.miss ? ' miss' : '') + '">' + parts.text + '</td>';
34
+ }
35
+
36
+ function commitCell(row, index, cfg) {
37
+ const loc = LOCALES[cfg.locale];
38
+ const parts = commitParts(row, cfg.rows.sha, rowHref(row.section, row.sha, cfg));
39
+ const when = '<span class="when">' + esc(parts.when) + '</span>';
40
+ const title = esc(parts.title);
41
+ const plain = row.section === null && parts.href === null;
42
+ const body = parts.href
43
+ ? '<a class="subj" title="' + title + '" href="' + esc(parts.href) + '">' + esc(parts.subject) + '</a>'
44
+ : '<span class="subj' + (plain ? ' plain' : '') + '" title="' + title + '">' + esc(parts.subject) + '</span>';
45
+ const markTitle = parts.mark.title === null
46
+ ? (cfg.journal ? fill(loc.note.noJournalMark, { journal: cfg.journal.path }) : loc.note.noJournal)
47
+ : parts.mark.title;
48
+ const id = cfg.rows.sha ? 'c-' + row.sha.slice(0, 7) : 'c-' + (index + 1);
49
+ return '<th class="c-commit" id="' + id + '">'
50
+ + '<div class="clip">' + when + body
51
+ + '<span class="sect" title="' + esc(markTitle) + '">' + esc(parts.mark.text) + '</span>'
52
+ + '</div></th>';
53
+ }
54
+
55
+ export function noteText(rows, cfg) {
56
+ const loc = LOCALES[cfg.locale];
57
+ const metrics = cfg.metrics.map((m) => {
58
+ const view = metricView(m, cfg);
59
+ return '<b>' + view.label + '</b> — ' + view.note;
60
+ }).join(loc.note.metricSep);
61
+ const journal = cfg.journal ? loc.note.journal : loc.note.noJournal;
62
+ return loc.note.intro + metrics + loc.note.metricEnd + fill(loc.note.numbers, { now: loc.now })
63
+ + (cfg.journal ? fill(journal, { journal: cfg.journal.path }) : journal)
64
+ + fill(loc.note.columns, { columns: cfg.columns.map((c) => c.label).join(', ') })
65
+ + fill(loc.note.rows, { rows: rows.length, command: cfg.fixCommand });
66
+ }
67
+
68
+ export function render(rows, cfg) {
69
+ const loc = LOCALES[cfg.locale];
70
+ const metrics = cfg.metrics;
71
+ const groupHead = (label, cls) => '<th colspan="' + metrics.length + '" class="' + cls + '">' + esc(label) + '</th>';
72
+ const subHead = () => metrics.map((m, i) => '<th' + (i === 0 ? ' class="g"' : '') + '>'
73
+ + esc(METRICS[m].label) + '</th>').join('');
74
+ const head = '<tr>'
75
+ + '<th rowspan="2" class="c-commit">' + esc(loc.commit) + '</th>'
76
+ + groupHead(loc.total, 'g')
77
+ + cfg.columns.map((c) => groupHead(c.label, 'g')).join('')
78
+ + '</tr>\n<tr>'
79
+ + subHead()
80
+ + cfg.columns.map(() => subHead()).join('')
81
+ + '</tr>';
82
+
83
+ /* Дельта считается к предыдущему коммиту (в списке ниже он идёт строкой ниже),
84
+ * а появление файла — рост на весь его объём: иначе сумма дельт по колонке не
85
+ * сходилась бы с текущим размером, и верхняя строка была бы недоказуемой. Всё
86
+ * это считает `rowModel` — тот же, что и на странице. */
87
+ const cellsHtml = (make) => (cells) => cells.map((c, mi) => make(c, mi === 0)).join('');
88
+ const rowCells = cellsHtml((c, first) => cellHtml(c, first));
89
+ const nowCells = cellsHtml((v, first) => valueHtml(v, first));
90
+ const blocksHtml = (model, one) => one(model.total) + model.files.map(one).join('');
91
+ const body = rows.map((row, i) => {
92
+ const prev = i === 0 ? null : rows[i - 1];
93
+ return '<tr>' + commitCell(row, i, cfg)
94
+ + blocksHtml(rowModel(row.cells, prev === null ? null : prev.cells, metrics), rowCells)
95
+ + '</tr>';
96
+ }).reverse().join('\n');
97
+
98
+ const nowRow = rows.length === 0 ? '' : '<tr class="now">'
99
+ + '<th class="c-commit">' + esc(loc.now) + '</th>'
100
+ + blocksHtml(nowModel(rows[rows.length - 1].cells, metrics), nowCells)
101
+ + '</tr>';
102
+
103
+ /* Подпись называет только то, что не меняется от самих служебных коммитов:
104
+ * число строк и список колонок. Иначе таблица считалась бы устаревшей сразу
105
+ * после собственного коммита — из-за пересчитанного «пропущено N» в тексте. */
106
+ return `<!doctype html>
107
+ <html lang="${loc.html}">
108
+ <head>
109
+ <meta charset="utf-8">
110
+ <meta name="viewport" content="width=device-width, initial-scale=1">
111
+ <title>${esc(cfg.title || loc.heading)}</title>
112
+ <style>
113
+ ${CSS}
114
+ </style>
115
+ </head>
116
+ <body>
117
+ <h1>${esc(cfg.heading || loc.heading)}</h1>
118
+ <p class="note">${noteText(rows, cfg)}</p>
119
+ <table>
120
+ <thead>
121
+ ${head}
122
+ </thead>
123
+ <tbody>
124
+ ${nowRow}
125
+ ${body}
126
+ </tbody>
127
+ </table>
128
+ </body>
129
+ </html>
130
+ `;
131
+ }
@@ -0,0 +1,86 @@
1
+ /* Таблица объёма файлов по коммитам — переносимый генератор.
2
+ *
3
+ * Зачем. Объём проекта обсуждается числами регулярно (здесь — WORKLOG §19–§21),
4
+ * и каждый раз это был ручной замер двух ревизий. Генератор делает замер
5
+ * непрерывным: строка — коммит, колонка — файл, в клетке — изменение к
6
+ * предыдущему коммиту по каждой метрике (`raw` — файл как он есть,
7
+ * `min` — форма без комментариев и отступов), а абсолютные размеры стоят один
8
+ * раз, в верхней строке «сейчас» (иначе крупное число повторялось бы в каждой
9
+ * строке, и колонки расползались бы на экраны вширь).
10
+ *
11
+ * Источник правды — сам git: размеры берутся из блобов коммитов, а не из
12
+ * рабочего дерева. Поэтому таблица не зависит от того, что открыто в редакторе,
13
+ * и собирается заново по всей истории, а не дописывается инкрементально
14
+ * (инкрементальный файл пришлось бы чинить после любой правки старых чисел).
15
+ *
16
+ * **Проектное — в конфиге, механика — в пакете.** Движок не знает ни имён файлов
17
+ * проекта, ни имени журнала, ни языка подписей: колонки, метрики, журнал,
18
+ * локаль, куда писать — всё в `size-table.config.json` рядом с корнем
19
+ * репозитория (`--config` — другой путь). Поэтому пакет подключается к новому
20
+ * проекту как зависимость, а `size --init` подбирает там черновик конфига
21
+ * (какие расширения в проекте, где журнал, куда писать), который дальше
22
+ * правится глазами.
23
+ *
24
+ * Строку получает коммит, сдвинувший хотя бы одно число, включая merge: у
25
+ * слияния берётся дифф к первому родителю, поэтому его правки видны и в строке,
26
+ * и в переносе состояния. Не получают строку коммиты, тронувшие лишь сам файл
27
+ * таблицы (и всё, что перечислено в `skip`) — строка про коммит не может лежать
28
+ * внутри самого коммита (sha на момент сборки ещё неизвестен), поэтому
29
+ * обновление таблицы — отдельный коммит, — и коммиты, у которых все клетки
30
+ * вышли нулевыми (слияние, разрешённое ровно в то, что уже дала ветка): строка
31
+ * без единого числа читается как поломка. Отсюда же
32
+ * требование к конфигу: колонки обязаны покрывать всё, что коммит может
33
+ * изменить. Коммит мимо колонок дал бы строку без единого числа, а пустая
34
+ * клетка в таблице означает «файла в этой ревизии ещё нет» — читается как
35
+ * поломка (это стережёт тест).
36
+ * Если в конфиге выключить sha в строках (`rows.sha: false`), тот же инвариант
37
+ * начинает работать и для стратегии «пересобрать и дописать в тот же коммит»:
38
+ * без sha артефакт становится неподвижной точкой сборки.
39
+ *
40
+ * Запуск (из любого места репозитория; `size` — когда пакет установлен, иначе
41
+ * `node bin/size.js`):
42
+ * size проверка: таблица совпадает с историей (CI)
43
+ * size --write перегенерировать таблицу
44
+ * size --json строки как JSON в stdout
45
+ * size --data данные для страницы и агента в stdout
46
+ * size --page [файл] собрать страницу отчёта
47
+ * size --init [файл] черновик конфига для нового проекта
48
+ * size --config <путь> другой файл настроек
49
+ * size --help справка и коды выхода
50
+ *
51
+ * Требуется полная история: на обрезанном клоне (shallow) скрипт отказывается
52
+ * работать, а не пишет молча короткую таблицу. В CI — `fetch-depth: 0`.
53
+ */
54
+
55
+ /* Точка входа пакета — и только она: здесь нет ни одного расчёта, только
56
+ * реэкспорт. Механика разложена по швам, которые видно по зависимостям:
57
+ *
58
+ * refusal, locales, journal, tool, css, derived — ни на чём не стоят;
59
+ * parse → parse-worker — разбор модуля вне процесса;
60
+ * strip → refusal, parse — снятие балласта и гард;
61
+ * metrics → strip — реестр метрик;
62
+ * git → refusal — всё, что читается у git;
63
+ * config → git, refusal, locales, metrics, data — настройки проекта;
64
+ * history → git, metrics, journal, refusal — сборка по истории;
65
+ * data → locales, metrics, journal, history, tool — контракт со страницей;
66
+ * render → locales, metrics, journal, derived — статический артефакт;
67
+ * page/build → locales, render — страница отчёта;
68
+ * cli → все — режимы и разбор ключей.
69
+ *
70
+ * Публичный API — то, чем пользуются `bin/size.js` и `test/`: список ниже не
71
+ * сокращается при разбиении (это проверяет `test/api.test.js`).
72
+ */
73
+ export { main, initMode, sniffColumns, check, dataMode, pageMode } from './cli.js';
74
+ export { reportData, categoryOf, CATEGORY_EXTS, CATEGORY_ORDER } from './data.js';
75
+ export { measureHistory } from './history.js';
76
+ export { render, noteText, cellHtml, valueHtml } from './render.js';
77
+ export { pageHtml, pageScript, pageSource, stripModules } from './page/build.js';
78
+ export { measureBlob, METRICS } from './metrics.js';
79
+ export { minifyForm, strategyFor, stripCss, stripHtml, stripJs, stripLines, compactJson,
80
+ STRATEGIES } from './strip.js';
81
+ export { parseSections, touchedSection, anchor, sectionLink, rowHref } from './journal.js';
82
+ export { argValue, gitRoot, loadConfig, validateConfig, CONFIG_NAME, DEFAULT_CONFIG } from './config.js';
83
+ export { assertFullHistory, blobAt, readBlobs, readHistory } from './git.js';
84
+ export { LOCALES } from './locales.js';
85
+ export { EXIT, Refusal, refuse, cliCommand, USAGE } from './refusal.js';
86
+ export { cellParts, commitParts, deltaOf, group, nowModel, rowModel, totalsOf, valueParts } from './derived.js';