@vernikr/size-report 1.2.0 → 1.3.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.
package/src/history.js CHANGED
@@ -36,112 +36,129 @@ function changesVolume(state, before, columns, metrics) {
36
36
  });
37
37
  }
38
38
 
39
- /* Проход по истории. Состояние колонки переносится вперёд, а перезамер делается
40
- * только для изменившихся в коммите файлов. Читается всё сразу: сначала план
41
- * «какие пары ревизия:путь понадобятся», затем один поход в git (`readBlobs`),
42
- * затем собственно измерение иначе на каждый коммит приходилось бы по
43
- * git-вызову на колонку. Значения метрик кэшируются по sha блоба: ревизия с тем
44
- * же содержимым (откат, повторный merge) не пересчитывается.
45
- *
46
- * `known` — уже прочитанная история: проходам, которым она нужна ещё и сама по
47
- * себе (полнота покрытия), незачем звать `git log` второй раз. */
48
- export function measureHistory(cfg, root, known) {
49
- const commits = known === undefined ? readHistory(root) : known;
50
- const metrics = cfg.metrics;
51
- // Текст журнала нужен всегда: ссылка в раздел — не метрика, но тоже чтение.
52
- const needText = !!cfg.journal || metrics.some((m) => METRICS[m].needsText);
53
- const skipPaths = [cfg.output].concat(cfg.skip || []);
54
- const state = cfg.columns.map(() => null);
55
- const rows = [];
56
- const dropped = [];
57
- const mixed = [];
58
- let journalPrev = '';
59
-
39
+ /* План чтения: какие пары «ревизия:путь» понадобятся и всё содержимое сразу — иначе
40
+ * на каждый коммит приходилось бы по git-вызову на колонку. Псевдонимов колонки,
41
+ * которых коммит коснулся, может быть и два: при выключенном распознавании
42
+ * переименований git отдаёт в одном коммите и старое имя, и новое. Собираются все —
43
+ * какой из них в коммите действительно есть, решается потом, по прочитанным блобам. */
44
+ function readPlan(cfg, root, commits, needText) {
60
45
  const plan = commits.map((c) => {
61
46
  const changed = new Set(c.files);
62
- /* Псевдонимов колонки, которых коммит коснулся, может быть и два: при
63
- * выключенном распознавании переименований git отдаёт в одном коммите и старое
64
- * имя, и новое. Собираются все — какой из них в коммите действительно есть,
65
- * решается потом, по прочитанным блобам. */
66
47
  const picks = cfg.columns.map((col) => col.paths
67
48
  .filter((cand) => changed.has(cand))
68
49
  .map((path) => ({ path: path, spec: c.sha + ':' + path })));
69
50
  const journal = cfg.journal && changed.has(cfg.journal.path) ? c.sha + ':' + cfg.journal.path : null;
70
51
  return { picks: picks, journal: journal };
71
52
  });
72
-
73
53
  const specs = [];
74
54
  plan.forEach((p) => {
75
55
  p.picks.forEach((candidates) => { candidates.forEach((cand) => specs.push(cand.spec)); });
76
56
  if (p.journal !== null) specs.push(p.journal);
77
57
  });
78
- const blobs = readBlobs(root, specs, needText);
58
+ return { plan: plan, blobs: readBlobs(root, specs, needText) };
59
+ }
79
60
 
61
+ /* Замер блоба с памятью на проход: ревизия с тем же содержимым (откат, повторный
62
+ * merge) не пересчитывается. */
63
+ function measurer(cfg) {
80
64
  const measured = new Map(); // sha блоба + метрика → число
81
- const measure = (name, blob, file, rev) => {
65
+ return (name, blob, file, rev) => {
82
66
  const key = blob.sha + '\u0000' + name;
83
67
  if (measured.has(key)) return measured.get(key);
84
68
  const value = measureBlob(name, blob, file, cfg, rev);
85
69
  measured.set(key, value);
86
70
  return value;
87
71
  };
72
+ }
88
73
 
89
- commits.forEach((c, ci) => {
90
- let section = null;
91
- if (plan[ci].journal !== null) {
92
- const journalBlob = blobs.get(plan[ci].journal);
93
- if (journalBlob !== undefined) {
94
- section = touchedSection(journalPrev, journalBlob.text, cfg.journal.pattern);
95
- journalPrev = journalBlob.text;
96
- }
74
+ /* Правки коммита в состояние: из псевдонимов берётся тот, который в коммите есть, а
75
+ * не первый по порядку настроек, — исчезнувшее имя в коммите отсутствует, и
76
+ * состояние, взятое по порядку, теряло файл (а сверка с деревом — отказывала). */
77
+ function applyPicks(pass, c, picks) {
78
+ picks.forEach((candidates, i) => {
79
+ const pick = candidates.find((cand) => pass.blobs.get(cand.spec) !== undefined);
80
+ if (pick === undefined) {
81
+ // Путь в коммите есть, а файла по нему нет — файл удалён.
82
+ if (candidates.length > 0) pass.state[i] = null;
83
+ return;
97
84
  }
98
-
99
- const before = state.slice();
100
- plan[ci].picks.forEach((candidates, i) => {
101
- /* Из псевдонимов берётся тот, который в коммите есть, а не первый по
102
- * порядку настроек: исчезнувшее имя в коммите отсутствует, и состояние,
103
- * взятое по порядку, теряло файл (а сверка с деревом — отказывала). */
104
- const pick = candidates.find((cand) => blobs.get(cand.spec) !== undefined);
105
- if (pick === undefined) {
106
- // Путь в коммите есть, а файла по нему нет — файл удалён.
107
- if (candidates.length > 0) state[i] = null;
108
- return;
109
- }
110
- const blob = blobs.get(pick.spec);
111
- const cells = {};
112
- /* Приближённость числа — свойство пути, а не блоба: от расширения зависит,
113
- * возьмёт ли формат минификатор. Поэтому она считается здесь, вместо с
114
- * замером, и в кэш содержимого не попадает. */
115
- const approx = {};
116
- metrics.forEach((m) => {
117
- cells[m] = measure(m, blob, pick.path, c.sha);
118
- approx[m] = !pointExact(m, pick.path, cfg);
119
- });
120
- state[i] = { path: pick.path, sha: blob.sha, cells: cells, approx: approx };
85
+ const blob = pass.blobs.get(pick.spec);
86
+ const cells = {};
87
+ /* Приближённость числа — свойство пути, а не блоба: от расширения зависит,
88
+ * возьмёт ли формат минификатор. Поэтому она считается здесь, вместо с
89
+ * замером, и в кэш содержимого не попадает. */
90
+ const approx = {};
91
+ pass.metrics.forEach((m) => {
92
+ cells[m] = pass.measure(m, blob, pick.path, c.sha);
93
+ approx[m] = !pointExact(m, pick.path, pass.cfg);
121
94
  });
95
+ pass.state[i] = { path: pick.path, sha: blob.sha, cells: cells, approx: approx };
96
+ });
97
+ }
122
98
 
123
- if (c.parents.length > 1 && !cfg.rows.merges) { dropped.push({ sha: c.sha, reason: 'merge' }); return; }
124
- if (c.files.length > 0 && c.files.every((f) => skipPaths.indexOf(f) >= 0)) {
125
- dropped.push({ sha: c.sha, reason: 'report' });
126
- return;
99
+ /* Один коммит прохода: сдвиг состояния, затем нужна ли коммиту строка. `pass`
100
+ * общий на весь проход (состояние, списки, память замеров), поэтому функция только
101
+ * двигает его вперёд. */
102
+ function stepCommit(pass, c, ci) {
103
+ const plan = pass.plan[ci];
104
+ let section = null;
105
+ if (plan.journal !== null) {
106
+ const journalBlob = pass.blobs.get(plan.journal);
107
+ if (journalBlob !== undefined) {
108
+ section = touchedSection(pass.journalPrev, journalBlob.text, pass.cfg.journal.pattern);
109
+ pass.journalPrev = journalBlob.text;
127
110
  }
128
- if (!changesVolume(state, before, cfg.columns, metrics)) {
129
- dropped.push({ sha: c.sha, reason: 'flat' });
130
- return;
131
- }
132
- if (c.files.some((f) => f === cfg.output)) mixed.push(c.sha.slice(0, 7));
111
+ }
133
112
 
134
- rows.push({
135
- sha: c.sha,
136
- when: c.when,
137
- subject: c.subject,
138
- section: section,
139
- cells: state.map((s) => (s === null ? null : s.cells)),
140
- approx: state.map((s) => (s === null ? null : s.approx))
141
- });
113
+ const before = pass.state.slice();
114
+ applyPicks(pass, c, plan.picks);
115
+
116
+ if (c.parents.length > 1 && !pass.cfg.rows.merges) { pass.dropped.push({ sha: c.sha, reason: 'merge' }); return; }
117
+ if (c.files.length > 0 && c.files.every((f) => pass.skipPaths.indexOf(f) >= 0)) {
118
+ pass.dropped.push({ sha: c.sha, reason: 'report' });
119
+ return;
120
+ }
121
+ if (!changesVolume(pass.state, before, pass.cfg.columns, pass.metrics)) {
122
+ pass.dropped.push({ sha: c.sha, reason: 'flat' });
123
+ return;
124
+ }
125
+ if (c.files.some((f) => f === pass.cfg.output)) pass.mixed.push(c.sha.slice(0, 7));
126
+
127
+ pass.rows.push({
128
+ sha: c.sha,
129
+ when: c.when,
130
+ subject: c.subject,
131
+ section: section,
132
+ cells: pass.state.map((s) => (s === null ? null : s.cells)),
133
+ approx: pass.state.map((s) => (s === null ? null : s.approx))
142
134
  });
135
+ }
143
136
 
144
- return { rows, dropped, mixed, state };
137
+ /* Проход по истории. Состояние колонки переносится вперёд, а перезамер делается
138
+ * только для изменившихся в коммите файлов.
139
+ *
140
+ * `known` — уже прочитанная история: проходам, которым она нужна ещё и сама по
141
+ * себе (полнота покрытия), незачем звать `git log` второй раз. */
142
+ export function measureHistory(cfg, root, known) {
143
+ const commits = known === undefined ? readHistory(root) : known;
144
+ // Текст журнала нужен всегда: ссылка в раздел — не метрика, но тоже чтение.
145
+ const needText = !!cfg.journal || cfg.metrics.some((m) => METRICS[m].needsText);
146
+ const reads = readPlan(cfg, root, commits, needText);
147
+ const pass = {
148
+ cfg: cfg,
149
+ metrics: cfg.metrics,
150
+ plan: reads.plan,
151
+ blobs: reads.blobs,
152
+ measure: measurer(cfg),
153
+ skipPaths: [cfg.output].concat(cfg.skip || []),
154
+ state: cfg.columns.map(() => null),
155
+ rows: [],
156
+ dropped: [],
157
+ mixed: [],
158
+ journalPrev: ''
159
+ };
160
+ commits.forEach((c, ci) => stepCommit(pass, c, ci));
161
+ return { rows: pass.rows, dropped: pass.dropped, mixed: pass.mixed, state: pass.state };
145
162
  }
146
163
 
147
164
  /* Сверка с рабочим деревом отвечает на два вопроса, и оба обязательны: состояние
package/src/hook.js CHANGED
@@ -286,7 +286,8 @@ function record(root, fields, note) {
286
286
  * Подпись коммита не спрашивается: настройка машины не должна останавливать коммит.
287
287
  * Дешёвый индекс здесь — временный: настоящий трогается один раз, и только в записи
288
288
  * об отчёте, иначе после коммита дерево было бы грязным. */
289
- function commitReport(root, rel, file, message, branch, head) {
289
+ function commitReport(root, job) {
290
+ const { rel, file, message, branch, head } = job;
290
291
  const fail = (why) => ({ ok: false, why: why });
291
292
  const blob = gitTry(root, ['hash-object', '-w', '--', file]);
292
293
  if (blob.status !== 0) return fail('git hash-object: ' + blob.stderr.trim());
@@ -374,31 +375,36 @@ function runLocked(root, configFile, sha) {
374
375
  '✗ size-report: внутренняя ошибка: ' + e.stack);
375
376
  }
376
377
 
377
- const rel = path.relative(root, out.file);
378
+ return storeReport(root, cfg, branch, sha, out.file);
379
+ }
380
+
381
+ /* Запись отчёта после пересборки: не отслеживается — сказать словами, не
382
+ * изменился — ничего не делать, изменился — закоммитить. */
383
+ function storeReport(root, cfg, branch, head, file) {
384
+ const rel = path.relative(root, file);
378
385
  if (gitTry(root, ['ls-files', '--error-unmatch', '--', rel]).status !== 0) {
379
386
  return record(root, {
380
387
  result: 'rebuilt',
381
- head: sha,
388
+ head: head,
382
389
  report: cfg.output,
383
390
  why: 'отчёт не отслеживается git: пересобран, коммита нет'
384
391
  });
385
392
  }
386
393
  const diff = gitTry(root, ['diff', '--quiet', 'HEAD', '--', rel]);
387
394
  if (diff.status === 0) {
388
- return record(root, { result: 'unchanged', head: sha, report: cfg.output });
395
+ return record(root, { result: 'unchanged', head: head, report: cfg.output });
389
396
  }
390
397
  if (diff.status !== 1) {
391
- return record(root, { result: 'failed', head: sha, report: cfg.output, why: 'git diff: ' + diff.stderr.trim() },
398
+ return record(root, { result: 'failed', head: head, report: cfg.output, why: 'git diff: ' + diff.stderr.trim() },
392
399
  '✗ size-report: git diff -- ' + rel + ': ' + diff.stderr.trim());
393
400
  }
394
401
 
395
- const message = 'chore(report): отчёт пересобран после ' + sha.slice(0, 7);
396
- const commit = commitReport(root, rel, out.file, message, branch, sha);
402
+ const message = 'chore(report): отчёт пересобран после ' + head.slice(0, 7);
403
+ const commit = commitReport(root, { rel: rel, file: file, message: message, branch: branch, head: head });
397
404
  if (!commit.ok) {
398
- return record(root, { result: 'refused', head: sha, report: cfg.output, why: commit.why },
405
+ return record(root, { result: 'refused', head: head, report: cfg.output, why: commit.why },
399
406
  '✗ size-report: коммит отчёта не прошёл: ' + commit.why);
400
407
  }
401
- const made = commit.sha;
402
- return record(root, { result: 'committed', head: sha, report: cfg.output, commit: made },
403
- '✓ size-report: ' + cfg.output + ' пересобран и закоммичен (' + made + ')');
408
+ return record(root, { result: 'committed', head: head, report: cfg.output, commit: commit.sha },
409
+ '✓ size-report: ' + cfg.output + ' пересобран и закоммичен (' + commit.sha + ')');
404
410
  }
package/src/init.js ADDED
@@ -0,0 +1,75 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { CONFIG_NAME, derivedProfile, validateConfig } from './config.js';
4
+ import { advicePath, cliCommand, refuseCause } from './refusal.js';
5
+ import { writeFileEnsured } from './artifact.js';
6
+ import { packageManager } from './project.js';
7
+
8
+ /* Закрепление настроек файлом (`--init`): то, что проект вывел о себе сам
9
+ * (`src/project.js`), записывается туда, где его встретит следующий запуск.
10
+ *
11
+ * Отдельным модулем от вывода профиля: тот смотрит на проект впервые и почти всё о
12
+ * нём угадывает, а этот делает одну вещь — кладёт результат файлом и говорит, что
13
+ * записал. Требование к себе одно и оно жёсткое: **закреплённое обязано проходить
14
+ * ту же проверку, которой его встретит первый запуск** — иначе подсказка приводит
15
+ * человека в новый тупик (BLOCKERS §N2, REFACTOR R-0.4).
16
+ */
17
+
18
+ /* Что сказать после записи: тем же порядком, что и раньше, — что записано, чем
19
+ * заменятся приближения и что делать дальше. Строки собираются списком, а не
20
+ * печатаются по ходу: тогда «что сказано» читается целиком. */
21
+ function draftLines(root, target, cfg) {
22
+ const hasPkg = fs.existsSync(path.join(root, 'package.json'));
23
+ const manager = packageManager(root);
24
+ return [
25
+ '✓ настройки выведены из проекта и закреплены: ' + path.relative(root, target),
26
+ ' колонок: ' + cfg.columns.length + ' (' + cfg.columns.map((c) => c.label).slice(0, 6).join(', ')
27
+ + (cfg.columns.length > 6 ? ', …' : '') + ')',
28
+ ' исключено путей: ' + cfg.skip.length + ' (сам отчёт, замки зависимостей, карты, собранное)',
29
+ ' метрика min: настоящее сжатие (esbuild); без него — честное упрощение и код 4',
30
+ ' метрика tok: словарь o200k_base (gpt-tokenizer); без него — оценка по длине и код 4',
31
+ ' журнал: ' + (cfg.journal === null ? 'не найден — ссылки строк будут без разделов' : cfg.journal.path),
32
+ ' дальше: правьте колонки и метрики — какие файлы важны, знает только проект',
33
+ ' ' + (hasPkg
34
+ ? 'добавьте в package.json "sizes": "size --write" — тогда отчёт будет звать '
35
+ + manager + ' run sizes (проверка — без --write)'
36
+ : 'запуск: ' + cfg.fixCommand + ' (проверка — без --write)'),
37
+ ' ' + (hasPkg ? 'добавьте ' + manager + ' run test:sizes в CI' : 'добавьте проверку в CI')
38
+ + '; проверка — команда пакета, своих файлов в проект она не приносит'
39
+ ];
40
+ }
41
+
42
+ /* Пустой профиль — не отказ, а примечание: работа сделана, а колонки за человека не
43
+ * выберет никто. Поэтому «!», а не «✗»: знак и код выхода не имеют права говорить
44
+ * разное (каталог отказов считает такие знаки отдельно). */
45
+ function noteNoColumns(root, target, cfg) {
46
+ if (cfg.columns.length > 0) return;
47
+ console.error('! в проекте не нашлось путей, которые можно взять колонками'
48
+ + ' (история пуста или в ней нет знакомых расширений): черновик записан без колонок'
49
+ + '\n впишите их руками в ' + path.relative(root, target)
50
+ + ' — без колонок проверка настроек скажет «не задано ни одной колонки»');
51
+ }
52
+
53
+ export function initMode(root, file, force) {
54
+ const target = file ? path.resolve(root, file) : path.join(root, CONFIG_NAME);
55
+ if (fs.existsSync(target) && !force) {
56
+ // Совет называет тот же файл, о котором шла речь: `--init --force` без файла
57
+ // перезаписал бы черновиком умолчательное имя, а не тот файл, что человек звал.
58
+ const name = file === undefined || file === null ? CONFIG_NAME : advicePath(file);
59
+ refuseCause('конфиг уже есть', 'конфиг уже есть: ' + target
60
+ + '\n починка: правьте его или перезапишите черновиком: ' + cliCommand('--init ' + name + ' --force'));
61
+ }
62
+ // Закреплённое — то же, чем проект работает без файла (вывод из проекта поверх
63
+ // умолчаний), и оно же обязано проходить ту же проверку, которой его встретит
64
+ // запуск: путь в тексте отказа — тот файл, куда оно легло. «Выведено» и «путь
65
+ // отказа» в файл не пишутся: это свойства не настроек, а того, откуда они взялись.
66
+ const cfg = derivedProfile(root);
67
+ if (cfg.columns.length > 0) validateConfig(Object.assign({}, cfg, { path: target }));
68
+ const written = Object.assign({}, cfg);
69
+ delete written.path;
70
+ delete written.derived;
71
+ writeFileEnsured(target, JSON.stringify(written, null, 2) + '\n');
72
+ noteNoColumns(root, target, cfg);
73
+ draftLines(root, target, cfg).forEach((line) => console.log(line));
74
+ return 0;
75
+ }
package/src/modes.js ADDED
@@ -0,0 +1,190 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { EXIT } from './refusal.js';
4
+ import { loadConfig } from './config.js';
5
+ import { byteLen } from './strip.js';
6
+ import { build, skipLine } from './history.js';
7
+ import { reportData, rowShape } from './data.js';
8
+ import { coverage, coverageText } from './check.js';
9
+ import { explainCommit, explainText } from './explain.js';
10
+ import { doctor, doctorText } from './doctor.js';
11
+ import { hookRun, installHook, uninstallHook } from './hook.js';
12
+ import { rebuild, writeFileEnsured } from './artifact.js';
13
+ import { sensorGaps } from './metrics.js';
14
+ import { render } from './render.js';
15
+ import { totalsOf } from './derived.js';
16
+ import { pageHtml } from './page/build.js';
17
+
18
+ /* Режимы: что инструмент делает по запросу. Разбор аргументов — в `src/args.js`, а
19
+ * сюда приходит готовый план: какой режим, какой ключ, что печатать. Здесь же их
20
+ * общие мелочи (знак «!» о другом счёте, вердикт, размер словами) — один владелец
21
+ * на все режимы, потому что один и тот же счёт и один и тот же знак не должны
22
+ * разойтись между `--write`, `--data`, `--page` и `size check`.
23
+ *
24
+ * Что где: сборка и сверка таблицы (`--write`, проверка), данные и страница
25
+ * (`--data`, `--page`), полнота покрытия (`size check`), диагностика (`doctor`),
26
+ * хук и объяснение пропущенной строки. Файл знает про все остальные модули сразу —
27
+ * это его работа: связать их в одну команду.
28
+ */
29
+
30
+ function kmb(bytes) {
31
+ return Math.round(bytes / 1024) + ' КБ';
32
+ }
33
+
34
+ /* Деградация — не ошибка, а факт отчёта: числа получены другим счётом (упрощение
35
+ * вместо сжатия, оценка вместо точного счёта), потому что необязательной
36
+ * зависимости нет. Факт печатается один раз на датчик и становится кодом 4 — иначе
37
+ * приближение уезжало бы в CI как успех. */
38
+ function note(gaps) {
39
+ gaps.forEach((gap) => console.error('! ' + gap.why + '\n починка: ' + gap.fix));
40
+ return gaps.length === 0 ? EXIT.OK : EXIT.SENSOR;
41
+ }
42
+
43
+ function sensorNote(cfg) {
44
+ return note(sensorGaps(cfg));
45
+ }
46
+
47
+ /* Вердикт режима вместе с заметками о датчиках: заметка печатается всегда — молчание
48
+ * о другом счёте читается как точное число, и расхождение остаётся без причины, — а
49
+ * код остаётся первым по важности. Нарушение старше приближения (тот же порядок, что
50
+ * у `size check` и у `doctor`): код 4 говорит «числа честные, но другим счётом», а
51
+ * когда таблица расходится, этого никто не проверял — расхождение может быть и
52
+ * настоящей правкой мимо отчёта. */
53
+ function verdict(code, gaps) {
54
+ const sensors = note(gaps);
55
+ return code === EXIT.OK ? sensors : code;
56
+ }
57
+
58
+ export function check(cfg, want, root) {
59
+ const out = path.join(root, cfg.output);
60
+ if (!fs.existsSync(out)) {
61
+ console.error('✗ таблица размеров: нет файла ' + cfg.output + ' — соберите её: ' + cfg.fixCommand);
62
+ return 1;
63
+ }
64
+ const have = fs.readFileSync(out, 'utf8');
65
+ if (have === want) return 0;
66
+
67
+ const a = have.split('\n');
68
+ const b = want.split('\n');
69
+ let i = 0;
70
+ while (i < a.length && i < b.length && a[i] === b[i]) i++;
71
+ console.error('✗ таблица размеров: ' + cfg.output + ' расходится с историей git (строка ' + (i + 1) + '):');
72
+ console.error(' в файле: ' + (a[i] === undefined ? '<строк нет>' : a[i].trim().slice(0, 160)));
73
+ console.error(' по истории: ' + (b[i] === undefined ? '<строк нет>' : b[i].trim().slice(0, 160)));
74
+ const missing = [...want.matchAll(/id="c-([^"]+)"/g)].map((m) => m[1])
75
+ .filter((id) => have.indexOf('id="c-' + id + '"') === -1);
76
+ if (missing.length > 0) {
77
+ console.error(' строк нет в файле: ' + missing.length + ' (' + missing.slice(0, 5).join(', ')
78
+ + (missing.length > 5 ? ', …' : '') + ')');
79
+ }
80
+ console.error(' починка: ' + cfg.fixCommand + ' — и закоммитить ' + cfg.output + ' отдельным коммитом.');
81
+ return 1;
82
+ }
83
+
84
+ export function writeMode(cfg, root) {
85
+ const { rows, dropped, state, html } = rebuild(cfg, root);
86
+ console.log('✓ ' + cfg.output + ': ' + rows.length + ' строк × ' + cfg.columns.length + ' файлов, '
87
+ + kmb(byteLen(html)) + ' (пропущено без строки: ' + dropped.length + ' — '
88
+ + dropped.map(skipLine).join(', ') + ')');
89
+ console.log(' состояние на HEAD: ' + cfg.columns.map((c, i) => c.label + ' '
90
+ + (state[i] === null ? '—' : cfg.metrics.map((m) => state[i].cells[m]).join('/'))).join(', '));
91
+ return sensorNote(cfg);
92
+ }
93
+
94
+ export function checkMode(cfg, root) {
95
+ const { rows } = build(cfg, root);
96
+ const html = render(rows, cfg);
97
+ const code = check(cfg, html, root);
98
+ if (code === 0) {
99
+ console.log('✓ таблица размеров: ' + rows.length + ' коммитов × ' + cfg.columns.length + ' файлов '
100
+ + 'совпадает с историей (' + cfg.output + ', ' + kmb(byteLen(html)) + ')');
101
+ }
102
+ return verdict(code, sensorGaps(cfg));
103
+ }
104
+
105
+ /* Ответ команды: `--json` — машинная форма того же ответа, а не второй ответ.
106
+ * Одна на три команды, чтобы «кто печатает и в каком виде» не разошёлся между
107
+ * ними: разойтись он может только здесь, а байты ответа — то, чем пользуется
108
+ * агент. Текст берётся функцией: в машинной форме он не нужен вовсе. */
109
+ function answer(rep, asJson, text) {
110
+ if (asJson) process.stdout.write(JSON.stringify(rep, null, 2) + '\n');
111
+ else console.log(text(rep));
112
+ return rep;
113
+ }
114
+
115
+ /* Полнота покрытия (`size check`): настройки, история, пути, датчики. Не путать с
116
+ * `checkMode` выше — тот про таблицу и историю («файл совпадает с тем, что
117
+ * сосчитано»), а этот про то, что сосчитано **всё**: ни один путь истории не
118
+ * прошёл мимо колонок. Разные вопросы, поэтому и разные команды: держать отчёт в
119
+ * git не обязательно, а вот полноту терять нельзя — она той же проверкой и
120
+ * заменяется. */
121
+ export function coverageMode(cfg, root, configFile, asJson) {
122
+ const rep = answer(coverage(cfg, root, configFile), asJson, coverageText);
123
+ return verdict(rep.ok ? EXIT.OK : EXIT.VIOLATION, rep.sensors);
124
+ }
125
+
126
+ /* Диагностика одним ответом (`size doctor`): окружение, зависимости, настройки и
127
+ * покрытие — сборкой из тех же кусков, что и остальные режимы. Код выхода — не
128
+ * «что-то не так», а первый по важности (настройки → история → покрытие →
129
+ * приближение): по нему агент ветвится, а текст читает человек. */
130
+ export function doctorMode(root, configFile, asJson) {
131
+ return answer(doctor(root, configFile), asJson, doctorText).exit;
132
+ }
133
+
134
+ /* Хук: установка, снятие и то, что он зовёт сам. Ставится и снимается только
135
+ * явной командой; `hook-run` зовётся хуком и всегда отвечает кодом 0 — коммит уже
136
+ * сделан, и валить его нечем (устройство и причины — `src/hook.js`). Строка о
137
+ * сделанном идёт в stderr: она часть вывода git, а не данных инструмента. */
138
+ export function hookMode(verb, root, configFile) {
139
+ if (verb === 'hook-run') {
140
+ const rep = hookRun(root, configFile);
141
+ if (rep.note !== '') console.error(rep.note);
142
+ return rep.code;
143
+ }
144
+ const rep = verb === 'install-hook' ? installHook(root, loadConfig(configFile, root)) : uninstallHook(root);
145
+ rep.lines.forEach((line) => console.log(line));
146
+ return rep.code;
147
+ }
148
+
149
+ /* Объяснение пропущенной строки (`size explain <коммит>`): ответ есть у любого
150
+ * коммита, поэтому код выхода 0 и у «строка есть», и у «строки нет»; 2 — только
151
+ * когда названного коммита в истории нет или префикс подходит нескольким. */
152
+ export function explainMode(cfg, root, target, asJson) {
153
+ answer(explainCommit(cfg, root, target), asJson, explainText);
154
+ return EXIT.OK;
155
+ }
156
+
157
+ /* Данные контракта в stdout — для страницы и для агента: та же правда, что в
158
+ * артефакте, но без вёрстки и без производных величин. Прежняя форма `--json`
159
+ * остаётся нетронутой: она заморожена эталоном паритета (fixtures/parity). */
160
+ export function dataMode(cfg, root) {
161
+ process.stdout.write(JSON.stringify(reportData(cfg, root), null, 2) + '\n');
162
+ return sensorNote(cfg);
163
+ }
164
+
165
+ /* Страница отчёта: собирается тем же проходом по истории, что и артефакт — иначе
166
+ * два отчёта могли бы показывать разные числа. Файл кладётся рядом с таблицей,
167
+ * потому что он из неё и растёт. */
168
+ const PAGE_NAME = 'size-report.html';
169
+
170
+ export function pageMode(cfg, root, file) {
171
+ const data = reportData(cfg, root);
172
+ const target = file ? path.resolve(file) : path.join(root, path.dirname(cfg.output), PAGE_NAME);
173
+ const html = pageHtml(data, cfg);
174
+ writeFileEnsured(target, html);
175
+ console.log('✓ ' + path.relative(root, target) + ': ' + data.rows.length + ' строк × '
176
+ + data.files.length + ' файлов, ' + kmb(byteLen(html)));
177
+ return sensorNote(cfg);
178
+ }
179
+
180
+ export function jsonMode(cfg, root) {
181
+ const { rows, dropped } = build(cfg, root);
182
+ process.stdout.write(JSON.stringify({
183
+ columns: cfg.columns.map((c) => ({ label: c.label, paths: c.paths })),
184
+ metrics: cfg.metrics,
185
+ rows: rows.map((r) => Object.assign(rowShape(r),
186
+ { cells: r.cells, totals: totalsOf(r.cells, cfg.metrics) })),
187
+ skipped: dropped.map(skipLine)
188
+ }, null, 2) + '\n');
189
+ return sensorNote(cfg);
190
+ }
package/src/page/app.css CHANGED
@@ -154,6 +154,36 @@ h1 { margin: 0 0 3px; font-size: 21px; font-weight: 650; letter-spacing: -.012em
154
154
  * сдвигает разметку. Ссылки журнала и все переключатели доступны с Tab. */
155
155
  :focus-visible { outline: 2px solid AccentColor; outline-offset: 2px; border-radius: 3px; }
156
156
 
157
+ /* Широкая страница: панель выбора (метрики, дерево файлов, легенда) стоит **слева**
158
+ * от таблицы. Это не украшение: на десктопе бокового места много, а вертикального
159
+ * мало — переключатели и числа видны одновременно, и прокрутка страницы не уводит
160
+ * управление за экран. Узкое окно эту же раскладку снимает (ниже): там столбцы
161
+ * снова идут друг под другом, потому что рядом им не хватает ширины.
162
+ *
163
+ * Раскладка — сетка на `body`, а не обёртка в разметке: страница собирается
164
+ * вклейкой глав (`src/page/build.js`), и добавлять ей узлы ради оформления значило
165
+ * бы менять форму страницы в двух местах вместо одного.
166
+ *
167
+ * Порог 900px — тот же, что у адаптаций ниже: одна граница на «широко» и «узко»,
168
+ * иначе между двумя порогами страница осталась бы без ни одного правила. */
169
+ @media (min-width: 900px) {
170
+ body {
171
+ display: grid;
172
+ grid-template-columns: minmax(230px, 300px) minmax(0, 1fr);
173
+ column-gap: var(--gap);
174
+ /* Оба столбца начинаются сверху и не растягиваются под соседа: у панели своя
175
+ * высота, у таблицы своя. */
176
+ align-content: start;
177
+ align-items: start;
178
+ }
179
+ header { grid-column: 1 / -1; }
180
+ #panel { grid-column: 1; }
181
+ #notice, #shell, #state, #note { grid-column: 2; }
182
+ /* В столбце дерево прокручивается выше, чем над таблицей: место занимает
183
+ * боковая колонка, а числа не отодвигает. */
184
+ .panel .files { max-height: min(62vh, 620px); }
185
+ }
186
+
157
187
  /* Адаптации: единственное место, где страница правит общую геометрию, — потому что
158
188
  * общая часть заморожена байтами артефакта, а не потому что так удобнее. */
159
189
  @media (max-width: 900px) {