@vernikr/size-report 1.2.0 → 1.3.0
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/CHANGELOG.md +56 -0
- package/README.md +168 -67
- package/package.json +21 -2
- package/src/args.js +191 -0
- package/src/artifact.js +9 -2
- package/src/check.js +8 -10
- package/src/cli.js +47 -457
- package/src/config.js +103 -24
- package/src/data.js +16 -9
- package/src/derived.js +3 -2
- package/src/doctor.js +121 -65
- package/src/explain.js +43 -26
- package/src/history.js +94 -77
- package/src/hook.js +17 -11
- package/src/init.js +75 -0
- package/src/modes.js +190 -0
- package/src/page/app.js +9 -498
- package/src/page/build.js +16 -7
- package/src/page/dom.js +22 -0
- package/src/page/panel.js +124 -0
- package/src/page/state.js +226 -0
- package/src/page/table.js +145 -0
- package/src/project.js +262 -0
- package/src/refusal.js +6 -1
- package/src/render.js +19 -11
- package/src/size-table.js +10 -4
- package/src/strip/forms.js +26 -0
- package/src/strip/guard.js +59 -0
- package/src/strip/js.js +136 -0
- package/src/strip.js +17 -190
- package/templates/README.md +16 -10
package/src/history.js
CHANGED
|
@@ -36,112 +36,129 @@ function changesVolume(state, before, columns, metrics) {
|
|
|
36
36
|
});
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
/*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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): отчёт пересобран после ' +
|
|
396
|
-
const commit = commitReport(root, rel,
|
|
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:
|
|
405
|
+
return record(root, { result: 'refused', head: head, report: cfg.output, why: commit.why },
|
|
399
406
|
'✗ size-report: коммит отчёта не прошёл: ' + commit.why);
|
|
400
407
|
}
|
|
401
|
-
|
|
402
|
-
|
|
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
|
+
}
|