@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/CHANGELOG.md +91 -0
- package/README.md +183 -71
- 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.css +30 -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/cli.js
CHANGED
|
@@ -1,453 +1,53 @@
|
|
|
1
|
-
import fs from 'fs';
|
|
2
1
|
import path from 'path';
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
import { hookRun, installHook, uninstallHook } from './hook.js';
|
|
14
|
-
import { rebuild } from './artifact.js';
|
|
15
|
-
import { sensorGaps } from './metrics.js';
|
|
16
|
-
import { render } from './render.js';
|
|
17
|
-
import { totalsOf } from './derived.js';
|
|
18
|
-
import { pageHtml } from './page/build.js';
|
|
19
|
-
|
|
20
|
-
/* Режимы командной строки: проверка полноты, объяснение пропущенной строки,
|
|
21
|
-
* проверка таблицы, сборка, данные, страница, черновик настроек и разбор
|
|
22
|
-
* аргументов. Единственный модуль, который знает про все остальные сразу, — потому
|
|
23
|
-
* ему и позволено их связывать. */
|
|
24
|
-
|
|
25
|
-
function kmb(bytes) {
|
|
26
|
-
return Math.round(bytes / 1024) + ' КБ';
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
/* Деградация — не ошибка, а факт отчёта: числа получены другим счётом (упрощение
|
|
30
|
-
* вместо сжатия, оценка вместо точного счёта), потому что необязательной
|
|
31
|
-
* зависимости нет. Факт печатается один раз на датчик и становится кодом 4 — иначе
|
|
32
|
-
* приближение уезжало бы в CI как успех. */
|
|
33
|
-
function note(gaps) {
|
|
34
|
-
gaps.forEach((gap) => console.error('! ' + gap.why + '\n починка: ' + gap.fix));
|
|
35
|
-
return gaps.length === 0 ? EXIT.OK : EXIT.SENSOR;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
function sensorNote(cfg) {
|
|
39
|
-
return note(sensorGaps(cfg));
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/* Вердикт режима вместе с заметками о датчиках: заметка печатается всегда — молчание
|
|
43
|
-
* о другом счёте читается как точное число, и расхождение остаётся без причины, — а
|
|
44
|
-
* код остаётся первым по важности. Нарушение старше приближения (тот же порядок, что
|
|
45
|
-
* у `size check` и у `doctor`): код 4 говорит «числа честные, но другим счётом», а
|
|
46
|
-
* когда таблица расходится, этого никто не проверял — расхождение может быть и
|
|
47
|
-
* настоящей правкой мимо отчёта. */
|
|
48
|
-
function verdict(code, gaps) {
|
|
49
|
-
const sensors = note(gaps);
|
|
50
|
-
return code === EXIT.OK ? sensors : code;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
export function check(cfg, want, root) {
|
|
54
|
-
const out = path.join(root, cfg.output);
|
|
55
|
-
if (!fs.existsSync(out)) {
|
|
56
|
-
console.error('✗ таблица размеров: нет файла ' + cfg.output + ' — соберите её: ' + cfg.fixCommand);
|
|
57
|
-
return 1;
|
|
58
|
-
}
|
|
59
|
-
const have = fs.readFileSync(out, 'utf8');
|
|
60
|
-
if (have === want) return 0;
|
|
61
|
-
|
|
62
|
-
const a = have.split('\n');
|
|
63
|
-
const b = want.split('\n');
|
|
64
|
-
let i = 0;
|
|
65
|
-
while (i < a.length && i < b.length && a[i] === b[i]) i++;
|
|
66
|
-
console.error('✗ таблица размеров: ' + cfg.output + ' расходится с историей git (строка ' + (i + 1) + '):');
|
|
67
|
-
console.error(' в файле: ' + (a[i] === undefined ? '<строк нет>' : a[i].trim().slice(0, 160)));
|
|
68
|
-
console.error(' по истории: ' + (b[i] === undefined ? '<строк нет>' : b[i].trim().slice(0, 160)));
|
|
69
|
-
const missing = [...want.matchAll(/id="c-([^"]+)"/g)].map((m) => m[1])
|
|
70
|
-
.filter((id) => have.indexOf('id="c-' + id + '"') === -1);
|
|
71
|
-
if (missing.length > 0) {
|
|
72
|
-
console.error(' строк нет в файле: ' + missing.length + ' (' + missing.slice(0, 5).join(', ')
|
|
73
|
-
+ (missing.length > 5 ? ', …' : '') + ')');
|
|
74
|
-
}
|
|
75
|
-
console.error(' починка: ' + cfg.fixCommand + ' — и закоммитить ' + cfg.output + ' отдельным коммитом.');
|
|
76
|
-
return 1;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
/* Запись вывода, у которого может не быть каталога: `--page .size-report/report.html`
|
|
80
|
-
* в свежем проекте — обычный запуск, а не ошибка пользователя. */
|
|
81
|
-
function writeFileEnsured(file, text) {
|
|
82
|
-
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
83
|
-
fs.writeFileSync(file, text);
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
function writeMode(cfg, root) {
|
|
87
|
-
const { rows, dropped, state, html } = rebuild(cfg, root);
|
|
88
|
-
console.log('✓ ' + cfg.output + ': ' + rows.length + ' строк × ' + cfg.columns.length + ' файлов, '
|
|
89
|
-
+ kmb(byteLen(html)) + ' (пропущено без строки: ' + dropped.length + ' — '
|
|
90
|
-
+ dropped.map(skipLine).join(', ') + ')');
|
|
91
|
-
console.log(' состояние на HEAD: ' + cfg.columns.map((c, i) => c.label + ' '
|
|
92
|
-
+ (state[i] === null ? '—' : cfg.metrics.map((m) => state[i].cells[m]).join('/'))).join(', '));
|
|
93
|
-
return sensorNote(cfg);
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
function checkMode(cfg, root) {
|
|
97
|
-
const { rows } = build(cfg, root);
|
|
98
|
-
const html = render(rows, cfg);
|
|
99
|
-
const code = check(cfg, html, root);
|
|
100
|
-
if (code === 0) {
|
|
101
|
-
console.log('✓ таблица размеров: ' + rows.length + ' коммитов × ' + cfg.columns.length + ' файлов '
|
|
102
|
-
+ 'совпадает с историей (' + cfg.output + ', ' + kmb(byteLen(html)) + ')');
|
|
103
|
-
}
|
|
104
|
-
return verdict(code, sensorGaps(cfg));
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
/* Полнота покрытия (`size check`): настройки, история, пути, датчики. Не путать с
|
|
108
|
-
* `checkMode` выше — тот про таблицу и историю («файл совпадает с тем, что
|
|
109
|
-
* сосчитано»), а этот про то, что сосчитано **всё**: ни один путь истории не
|
|
110
|
-
* прошёл мимо колонок. Разные вопросы, поэтому и разные команды: держать отчёт в
|
|
111
|
-
* git не обязательно, а вот полноту терять нельзя — она той же проверкой и
|
|
112
|
-
* заменяется. */
|
|
113
|
-
function coverageMode(cfg, root, configFile, asJson) {
|
|
114
|
-
const rep = coverage(cfg, root, configFile);
|
|
115
|
-
if (asJson) process.stdout.write(JSON.stringify(rep, null, 2) + '\n');
|
|
116
|
-
else console.log(coverageText(rep));
|
|
117
|
-
return verdict(rep.ok ? EXIT.OK : EXIT.VIOLATION, rep.sensors);
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
/* Диагностика одним ответом (`size doctor`): окружение, зависимости, настройки и
|
|
121
|
-
* покрытие — сборкой из тех же кусков, что и остальные режимы. Код выхода — не
|
|
122
|
-
* «что-то не так», а первый по важности (настройки → история → покрытие →
|
|
123
|
-
* приближение): по нему агент ветвится, а текст читает человек. */
|
|
124
|
-
function doctorMode(root, configFile, asJson) {
|
|
125
|
-
const rep = doctor(root, configFile);
|
|
126
|
-
if (asJson) process.stdout.write(JSON.stringify(rep, null, 2) + '\n');
|
|
127
|
-
else console.log(doctorText(rep));
|
|
128
|
-
return rep.exit;
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
/* Хук: установка, снятие и то, что он зовёт сам. Ставится и снимается только
|
|
132
|
-
* явной командой; `hook-run` зовётся хуком и всегда отвечает кодом 0 — коммит уже
|
|
133
|
-
* сделан, и валить его нечем (устройство и причины — `src/hook.js`). Строка о
|
|
134
|
-
* сделанном идёт в stderr: она часть вывода git, а не данных инструмента. */
|
|
135
|
-
function hookMode(verb, root, configFile) {
|
|
136
|
-
if (verb === 'hook-run') {
|
|
137
|
-
const rep = hookRun(root, configFile);
|
|
138
|
-
if (rep.note !== '') console.error(rep.note);
|
|
139
|
-
return rep.code;
|
|
140
|
-
}
|
|
141
|
-
const rep = verb === 'install-hook' ? installHook(root, loadConfig(configFile, root)) : uninstallHook(root);
|
|
142
|
-
rep.lines.forEach((line) => console.log(line));
|
|
143
|
-
return rep.code;
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
/* Объяснение пропущенной строки (`size explain <коммит>`): ответ есть у любого
|
|
147
|
-
* коммита, поэтому код выхода 0 и у «строка есть», и у «строки нет»; 2 — только
|
|
148
|
-
* когда названного коммита в истории нет или префикс подходит нескольким. */
|
|
149
|
-
function explainMode(cfg, root, target, asJson) {
|
|
150
|
-
const rep = explainCommit(cfg, root, target);
|
|
151
|
-
if (asJson) process.stdout.write(JSON.stringify(rep, null, 2) + '\n');
|
|
152
|
-
else console.log(explainText(rep));
|
|
153
|
-
return EXIT.OK;
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
/* Грамматика командной строки: один разбор на входе, до чтения проекта. Он решает
|
|
157
|
-
* всё сразу — какой режим запрошен, совместим ли он с командой и с остальными
|
|
158
|
-
* ключами, все ли ключи получили значение, нет ли лишних слов, — и отвечает либо
|
|
159
|
-
* планом, либо отказом. Порядок ветвлений в `main` поэтому ничего не решает:
|
|
160
|
-
* правило «так нельзя» это значение, а не место в коде, и наружу оно выходит одним
|
|
161
|
-
* способом — отказом с названным виновником и готовой командой.
|
|
2
|
+
import { EXIT, Refusal, USAGE } from './refusal.js';
|
|
3
|
+
import { CONFIG_NAME, gitRoot, loadConfig } from './config.js';
|
|
4
|
+
import { HOOK_COMMANDS, parseArgs } from './args.js';
|
|
5
|
+
import { initMode } from './init.js';
|
|
6
|
+
import { derivedLines } from './project.js';
|
|
7
|
+
import {
|
|
8
|
+
checkMode, coverageMode, dataMode, doctorMode, explainMode, hookMode, jsonMode, pageMode, writeMode
|
|
9
|
+
} from './modes.js';
|
|
10
|
+
|
|
11
|
+
/* Вход инструмента: разбор строки, чтение проекта и доставка запроса режиму.
|
|
162
12
|
*
|
|
163
|
-
*
|
|
164
|
-
*
|
|
165
|
-
*
|
|
166
|
-
*
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
if (none && a === '--config') {
|
|
200
|
-
refuseCause('ключ без значения', 'у ключа «' + a + '» нет значения: нужен файл настроек'
|
|
201
|
-
+ '\n починка: ' + cliCommand(a + ' <файл>'));
|
|
202
|
-
}
|
|
203
|
-
if (!none) i++;
|
|
204
|
-
values[a] = none ? null : next;
|
|
205
|
-
}
|
|
206
|
-
if (MODES.indexOf(a) >= 0) modes.push(a);
|
|
207
|
-
}
|
|
208
|
-
return Object.assign({ help: false, values: values }, checkArgs(words, seen, values, modes));
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
/* Проверки, которые нельзя сделать по одному аргументу: они про сочетание
|
|
212
|
-
* названного. Отсюда и все правила вида «это с этим не работает» — одним списком,
|
|
213
|
-
* а не разбросанными по ветвлениям. */
|
|
214
|
-
function checkArgs(words, seen, values, modes) {
|
|
215
|
-
const verb = words.length > 0 ? words[0] : null;
|
|
216
|
-
const arg = words.slice(1);
|
|
217
|
-
const mode = modes.length > 0 ? modes[0] : null;
|
|
218
|
-
/* Совет повторяет настройки, которые человек назвал: без `--config` команда ищет
|
|
219
|
-
* файл под умолчательным именем, в проекте с другим именем не находит его и
|
|
220
|
-
* уводит человека во второй отказ — про файл настроек, которого у него нет. */
|
|
221
|
-
const given = typeof values['--config'] === 'string'
|
|
222
|
-
? '--config ' + advicePath(values['--config']) + ' ' : '';
|
|
223
|
-
const advice = (rest) => cliCommand(given + rest);
|
|
224
|
-
if (modes.length > 1) {
|
|
225
|
-
refuseCause('два режима сразу', 'два режима сразу: «' + modes[0] + '» и «' + modes[1] + '» — режим один'
|
|
226
|
-
+ '\n починка: ' + advice(modes[0]));
|
|
227
|
-
}
|
|
228
|
-
if (seen.has('--force') && mode !== '--init') {
|
|
229
|
-
refuseCause('несовместимый ключ', 'ключ «--force» работает только с «--init»'
|
|
230
|
-
+ '\n починка: ' + cliCommand('--init --force'));
|
|
231
|
-
}
|
|
232
|
-
if (seen.has('--config') && mode === '--init') {
|
|
233
|
-
refuseCause('несовместимый ключ', 'у «--init» свой файл, а «--config» называет настройки проекта'
|
|
234
|
-
+ '\n починка: ' + cliCommand('--init <файл>'));
|
|
235
|
-
}
|
|
236
|
-
if (verb !== null && COMMANDS.indexOf(verb) < 0) {
|
|
237
|
-
// Слово после режима со значением (`--init`, `--page`) — не «неизвестная
|
|
238
|
-
// команда»: команды здесь никто не звал, а виновато лишнее значение, и отказ
|
|
239
|
-
// обязан назвать виновника своим именем. У `--config` остаток — именно
|
|
240
|
-
// команда, и зов её разбирается ниже.
|
|
241
|
-
const valued = MODES.find((f) => VALUE_FLAGS.indexOf(f) >= 0 && typeof values[f] === 'string');
|
|
242
|
-
if (valued !== undefined) {
|
|
243
|
-
refuseCause('лишнее слово', 'лишнее слово «' + verb + '»: «' + valued + '» принимает одно значение'
|
|
244
|
-
+ '\n починка: ' + advice(valued + ' ' + advicePath(values[valued])));
|
|
245
|
-
}
|
|
246
|
-
refuseCause('неизвестная команда', 'неизвестная команда «' + verb + '»\n починка: ' + cliCommand('--help'));
|
|
247
|
-
}
|
|
248
|
-
if (verb !== null && mode !== null) {
|
|
249
|
-
refuseCause('команда и режим', 'команда «' + verb + '» и режим «' + mode + '» — разное, вместе они не работают'
|
|
250
|
-
+ '\n починка: ' + advice(verb));
|
|
251
|
-
}
|
|
252
|
-
// Коммит либо не назван, либо назван не один раз — тупика два, а починка одна.
|
|
253
|
-
if (verb === 'explain' && arg.length === 0) {
|
|
254
|
-
refuseCause('нет коммита', 'команде «explain» нужен коммит: имя ревизии (HEAD, ветка, тег),'
|
|
255
|
-
+ ' sha или его начало'
|
|
256
|
-
+ '\n починка: ' + advice('explain <коммит>'));
|
|
257
|
-
}
|
|
258
|
-
if (verb === 'explain' && arg.length > 1) {
|
|
259
|
-
refuseCause('лишнее слово', 'команда «explain» принимает один коммит, а не ' + arg.length
|
|
260
|
-
+ ': «' + arg.slice(1).join('», «') + '» лишние\n починка: ' + advice('explain <коммит>'));
|
|
261
|
-
}
|
|
262
|
-
if (verb !== null && verb !== 'explain' && arg.length > 0) {
|
|
263
|
-
refuseCause('лишнее слово', 'команда «' + verb + '» аргументов не принимает: «' + arg[0] + '» лишний'
|
|
264
|
-
+ '\n починка: ' + advice(verb));
|
|
265
|
-
}
|
|
266
|
-
// `--json` — не режим, а форма ответа, и правило у него одно: ответ бывает
|
|
267
|
-
// ровно у четырёх вызовов. Без команды и режима это прежняя форма данных
|
|
268
|
-
// (заморожена эталоном паритета — убрать её нельзя), три команды отвечают
|
|
269
|
-
// своим. У команды без ответа просить нечего, а у режима ответ уже один —
|
|
270
|
-
// запись; оба случая — отказ, и каждый называет своего виновника.
|
|
271
|
-
if (seen.has('--json') && verb !== null && ANSWER_COMMANDS.indexOf(verb) < 0) {
|
|
272
|
-
refuseCause('нет ответа в JSON', 'у команды «' + verb + '» нет ответа в JSON'
|
|
273
|
-
+ '\n починка: ' + advice(verb));
|
|
274
|
-
}
|
|
275
|
-
if (seen.has('--json') && verb === null && mode !== null) {
|
|
276
|
-
refuseCause('два ответа сразу', '«--json» и режим «' + mode + '» — разное: данные или запись, но не оба'
|
|
277
|
-
+ '\n починка: ' + advice(mode));
|
|
278
|
-
}
|
|
279
|
-
return {
|
|
280
|
-
verb: verb,
|
|
281
|
-
mode: mode,
|
|
282
|
-
arg: arg,
|
|
283
|
-
json: seen.has('--json'),
|
|
284
|
-
force: seen.has('--force')
|
|
285
|
-
};
|
|
13
|
+
* Здесь не осталось ни грамматики (`src/args.js`), ни самих режимов
|
|
14
|
+
* (`src/modes.js`), ни закрепления настроек (`src/init.js`) — только то, без чего
|
|
15
|
+
* вход не вход: откуда берётся корень проекта, каким ключом назван файл настроек и
|
|
16
|
+
* как отказ превращается в код выхода. Здесь же сказано вслух, когда настроек нет
|
|
17
|
+
* и работа идёт на выведенных из проекта: это общее для всех режимов, а не их дело. Разделение не косметическое: цепочка
|
|
18
|
+
* ветвлений «что запрошено» росла с каждым режимом и держала сложность входа, а
|
|
19
|
+
* правила грамматики и тексты отказов проверяются своим каталогом
|
|
20
|
+
* (`tools/refusals.js`), который считает их места в исходниках.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/* Исполнитель запроса: ключ — то, чем запрос назван (команда или режим), а «просто
|
|
24
|
+
* запуск» — пустая строка. Разбор уже проверил сочетания, поэтому здесь остаётся
|
|
25
|
+
* выбор из готового списка, а не решение: у каждого названного есть свой
|
|
26
|
+
* исполнитель, и вызывается он без пробежек по `if`. */
|
|
27
|
+
const RUNNERS = {
|
|
28
|
+
check: (c, x) => coverageMode(x.cfg, x.root, x.configFile, c.json),
|
|
29
|
+
explain: (c, x) => explainMode(x.cfg, x.root, c.arg[0], c.json),
|
|
30
|
+
'--data': (c, x) => dataMode(x.cfg, x.root),
|
|
31
|
+
'--page': (c, x) => pageMode(x.cfg, x.root, c.values['--page']),
|
|
32
|
+
'--write': (c, x) => writeMode(x.cfg, x.root),
|
|
33
|
+
'': (c, x) => (c.json ? jsonMode(x.cfg, x.root) : checkMode(x.cfg, x.root))
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const asked = (cmd) => (cmd.verb === null ? (cmd.mode === null ? '' : cmd.mode) : cmd.verb);
|
|
37
|
+
|
|
38
|
+
/* Доставка. Диагностика и хук отвечают до чтения настроек: им нужен не весь
|
|
39
|
+
* проект, а окружение, и отказывать им из-за настроек было бы неверно — про
|
|
40
|
+
* настройки они как раз и докладывают. */
|
|
41
|
+
function deliver(cmd, base) {
|
|
42
|
+
if (cmd.verb === 'doctor') return doctorMode(base.root, base.configFile, cmd.json);
|
|
43
|
+
if (HOOK_COMMANDS.indexOf(cmd.verb) >= 0) return hookMode(cmd.verb, base.root, base.configFile);
|
|
44
|
+
const ctx = { root: base.root, configFile: base.configFile, cfg: loadConfig(base.configFile, base.root) };
|
|
45
|
+
// Примечание идёт в stderr: у `--json` и `--data` в stdout лежат данные, и
|
|
46
|
+
// подмешивать в них рассказ о настройках значило бы ломать разбор.
|
|
47
|
+
if (ctx.cfg.derived) derivedLines(ctx.cfg).forEach((line) => console.error(line));
|
|
48
|
+
return RUNNERS[asked(cmd)](cmd, ctx);
|
|
286
49
|
}
|
|
287
50
|
|
|
288
|
-
/* Данные контракта в stdout — для страницы и для агента: та же правда, что в
|
|
289
|
-
* артефакте, но без вёрстки и без производных величин. Прежняя форма `--json`
|
|
290
|
-
* остаётся нетронутой: она заморожена эталоном паритета (fixtures/parity). */
|
|
291
|
-
export function dataMode(cfg, root) {
|
|
292
|
-
process.stdout.write(JSON.stringify(reportData(cfg, root), null, 2) + '\n');
|
|
293
|
-
return sensorNote(cfg);
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
/* Страница отчёта: собирается тем же проходом по истории, что и артефакт — иначе
|
|
297
|
-
* два отчёта могли бы показывать разные числа. Файл кладётся рядом с таблицей,
|
|
298
|
-
* потому что он из неё и растёт. */
|
|
299
|
-
const PAGE_NAME = 'size-report.html';
|
|
300
|
-
|
|
301
|
-
export function pageMode(cfg, root, file) {
|
|
302
|
-
const data = reportData(cfg, root);
|
|
303
|
-
const target = file ? path.resolve(file) : path.join(root, path.dirname(cfg.output), PAGE_NAME);
|
|
304
|
-
const html = pageHtml(data, cfg);
|
|
305
|
-
writeFileEnsured(target, html);
|
|
306
|
-
console.log('✓ ' + path.relative(root, target) + ': ' + data.rows.length + ' строк × '
|
|
307
|
-
+ data.files.length + ' файлов, ' + kmb(byteLen(html)));
|
|
308
|
-
return sensorNote(cfg);
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
function jsonMode(cfg, root) {
|
|
312
|
-
const { rows, dropped } = build(cfg, root);
|
|
313
|
-
process.stdout.write(JSON.stringify({
|
|
314
|
-
columns: cfg.columns.map((c) => ({ label: c.label, paths: c.paths })),
|
|
315
|
-
metrics: cfg.metrics,
|
|
316
|
-
rows: rows.map((r) => ({
|
|
317
|
-
sha: r.sha, when: r.when, subject: r.subject,
|
|
318
|
-
section: r.section === null ? null : { id: r.section.id, head: r.section.head, added: r.section.added },
|
|
319
|
-
cells: r.cells,
|
|
320
|
-
totals: totalsOf(r.cells, cfg.metrics)
|
|
321
|
-
})),
|
|
322
|
-
skipped: dropped.map(skipLine)
|
|
323
|
-
}, null, 2) + '\n');
|
|
324
|
-
return sensorNote(cfg);
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
/* Черновик конфига для нового проекта: колонки — по расширениям, которые в
|
|
328
|
-
* проекте реально есть (крупнейшие по объёму), журнал — по знакомым именам,
|
|
329
|
-
* output — рядом с доками. Дальше конфиг правят глазами: угадать, какие файлы
|
|
330
|
-
* важны, инструмент не может. */
|
|
331
|
-
const INIT_EXTS = ['.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx', '.html', '.css', '.scss', '.json', '.py', '.rb', '.go', '.rs', '.sh'];
|
|
332
|
-
const INIT_JOURNALS = ['WORKLOG.md', 'CHANGELOG.md', 'CHANGES.md', 'HISTORY.md'];
|
|
333
|
-
const INIT_SKIP_NAMES = [/package-lock\.json$/, /\.min\./, /\.map$/, /yarn\.lock$/, /composer\.lock$/];
|
|
334
|
-
|
|
335
|
-
/* `skip` — пути, которые колонками быть не могут (файл самой таблицы: его размер
|
|
336
|
-
* зависит от числа строк, то есть от самого себя). */
|
|
337
|
-
export function sniffColumns(root, limit, skip) {
|
|
338
|
-
const listed = git(root, ['ls-files', '-s']).split('\n').filter((l) => l !== '');
|
|
339
|
-
const sizes = new Map();
|
|
340
|
-
const shas = listed.map((l) => l.split(/\s+/)[1]);
|
|
341
|
-
if (shas.length > 0) {
|
|
342
|
-
const checked = execFileSync('git', gitArgv(['cat-file', '--batch-check=%(objectname)\t%(objectsize)']), {
|
|
343
|
-
cwd: root, encoding: 'utf8', input: shas.join('\n') + '\n', maxBuffer: MAX_BUF, env: gitEnv()
|
|
344
|
-
});
|
|
345
|
-
checked.split('\n').forEach((l) => {
|
|
346
|
-
const [sha, size] = l.split('\t');
|
|
347
|
-
sizes.set(sha, Number(size));
|
|
348
|
-
});
|
|
349
|
-
}
|
|
350
|
-
const byExt = new Map();
|
|
351
|
-
const skipped = skip || [];
|
|
352
|
-
listed.forEach((line) => {
|
|
353
|
-
const f = line.split('\t')[1];
|
|
354
|
-
if (skipped.indexOf(f) >= 0) return;
|
|
355
|
-
if (INIT_SKIP_NAMES.some((re) => re.test(f))) return;
|
|
356
|
-
const ext = path.extname(f).toLowerCase();
|
|
357
|
-
if (INIT_EXTS.indexOf(ext) < 0) return;
|
|
358
|
-
const size = sizes.get(line.split(/\s+/)[1]) || 0;
|
|
359
|
-
if (size > 512 * 1024) return; // собранный или сгенерированный файл, а не исходник
|
|
360
|
-
const group = byExt.get(ext) || { ext: ext, bytes: 0, files: [] };
|
|
361
|
-
group.bytes += size;
|
|
362
|
-
group.files.push({ p: f, size: size });
|
|
363
|
-
byExt.set(ext, group);
|
|
364
|
-
});
|
|
365
|
-
const groups = [...byExt.values()].sort((a, b) => b.bytes - a.bytes);
|
|
366
|
-
const columns = [];
|
|
367
|
-
groups.forEach((g) => {
|
|
368
|
-
g.files.sort((a, b) => b.size - a.size).slice(0, limit).forEach((f) => {
|
|
369
|
-
columns.push({ label: path.basename(f.p), paths: [f.p] });
|
|
370
|
-
});
|
|
371
|
-
});
|
|
372
|
-
return { columns: columns.slice(0, limit), exts: groups.map((g) => g.ext), total: byExt.size };
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
export function initMode(root, file, force) {
|
|
376
|
-
const target = file ? path.resolve(root, file) : path.join(root, CONFIG_NAME);
|
|
377
|
-
if (fs.existsSync(target) && !force) {
|
|
378
|
-
// Совет называет тот же файл, о котором шла речь: `--init --force` без файла
|
|
379
|
-
// перезаписал бы черновиком умолчательное имя, а не тот файл, что человек звал.
|
|
380
|
-
const name = file === undefined || file === null ? CONFIG_NAME : advicePath(file);
|
|
381
|
-
refuseCause('конфиг уже есть', 'конфиг уже есть: ' + target
|
|
382
|
-
+ '\n починка: правьте его или перезапишите черновиком: ' + cliCommand('--init ' + name + ' --force'));
|
|
383
|
-
}
|
|
384
|
-
const journalPath = INIT_JOURNALS.find((p) => fs.existsSync(path.join(root, p))) || '';
|
|
385
|
-
const outDir = fs.existsSync(path.join(root, 'docs')) ? 'docs/' : '';
|
|
386
|
-
const sniffed = sniffColumns(root, 12, [outDir + 'size-table.html']);
|
|
387
|
-
if (sniffed.columns.length === 0) {
|
|
388
|
-
// Черновик всё равно записывается, и это не отказ: работа сделана, а колонки
|
|
389
|
-
// за человека не выберет никто. Поэтому «!», а не «✗»: знак и код выхода не
|
|
390
|
-
// имеют права говорить разное.
|
|
391
|
-
console.error('! не нашлось файлов с известными расширениями (' + INIT_EXTS.join(' ')
|
|
392
|
-
+ '): черновик записан без колонок\n впишите их руками в ' + path.relative(root, target)
|
|
393
|
-
+ ' — без колонок проверка настроек скажет «не задано ни одной колонки»');
|
|
394
|
-
}
|
|
395
|
-
// Подпись артефакта цитирует команду починки, поэтому в проекте без
|
|
396
|
-
// package.json она должна указывать на саму команду: иначе таблица ссылалась
|
|
397
|
-
// бы на npm-скрипт, которого там нет.
|
|
398
|
-
const hasPkg = fs.existsSync(path.join(root, 'package.json'));
|
|
399
|
-
// Менеджер пакетов — по lock-файлу, а не предположением: подпись артефакта
|
|
400
|
-
// цитирует команду починки, и она обязана существовать в чужом проекте.
|
|
401
|
-
const manager = fs.existsSync(path.join(root, 'pnpm-lock.yaml')) ? 'pnpm'
|
|
402
|
-
: fs.existsSync(path.join(root, 'yarn.lock')) ? 'yarn' : 'npm';
|
|
403
|
-
const cfg = {
|
|
404
|
-
output: outDir + 'size-table.html',
|
|
405
|
-
locale: 'ru',
|
|
406
|
-
title: 'Объём файлов по коммитам',
|
|
407
|
-
heading: 'Объём файлов по коммитам',
|
|
408
|
-
// Ни в одной ветви нет имени пакета как команды: без манифеста скрипта нет,
|
|
409
|
-
// а имя в реестре — чужой пакет (§refusal.js, invocation).
|
|
410
|
-
fixCommand: hasPkg ? manager + ' run sizes' : invocation() + ' --write',
|
|
411
|
-
metrics: ['raw', 'min', 'tok'],
|
|
412
|
-
// Настоящее сжатие и настоящий словарь, а не приближения: новый проект не
|
|
413
|
-
// должен начинать с приближённых чисел. Плата названа в подсказке ниже: без
|
|
414
|
-
// необязательной зависимости метрика честно отступает к другому счёту и
|
|
415
|
-
// прогон возвращает код 4.
|
|
416
|
-
minify: { engine: 'esbuild' },
|
|
417
|
-
tokens: { family: 'openai', encoding: 'o200k_base' },
|
|
418
|
-
columns: sniffed.columns,
|
|
419
|
-
journal: journalPath
|
|
420
|
-
? { path: journalPath, url: '../' + journalPath, pattern: '^## (?<id>\\S+)\\s+(?<title>.+?)\\s*$', anchor: 'heading' }
|
|
421
|
-
: null,
|
|
422
|
-
links: { commitUrl: '' },
|
|
423
|
-
rows: { merges: true, sha: true },
|
|
424
|
-
hooks: { enabled: true },
|
|
425
|
-
skip: []
|
|
426
|
-
};
|
|
427
|
-
// Черновик обязан проходить ту же проверку, которой его встретит первый запуск:
|
|
428
|
-
// иначе отказ из подсказки приводит в новый тупик. Проект без знакомых
|
|
429
|
-
// расширений — исключение: колонок нет вовсе, и черновик правят руками.
|
|
430
|
-
if (cfg.columns.length > 0) validateConfig(Object.assign({}, cfg, { path: target }));
|
|
431
|
-
writeFileEnsured(target, JSON.stringify(cfg, null, 2) + '\n');
|
|
432
|
-
console.log('✓ черновик конфига: ' + path.relative(root, target));
|
|
433
|
-
console.log(' расширения в проекте: ' + (sniffed.exts.join(' ') || '—'));
|
|
434
|
-
console.log(' колонок: ' + sniffed.columns.length + ' (крупнейшие файлы по расширениям)');
|
|
435
|
-
console.log(' метрика min: настоящее сжатие (esbuild); без него — честное упрощение и код 4');
|
|
436
|
-
console.log(' метрика tok: словарь o200k_base (gpt-tokenizer); без него — оценка по длине и код 4');
|
|
437
|
-
console.log(' журнал: ' + (journalPath || 'не найден — ссылки строк будут без разделов'));
|
|
438
|
-
console.log(' дальше: 1) поправьте колонки и метрики — какие файлы важны, знает только проект');
|
|
439
|
-
console.log(' 2) ' + (hasPkg
|
|
440
|
-
? 'добавьте в package.json: "sizes": "size --write", "test:sizes": "size"'
|
|
441
|
-
: 'запуск: ' + cfg.fixCommand + ' (проверка — без --write)'));
|
|
442
|
-
console.log(' 3) ' + (hasPkg ? 'добавьте ' + manager + ' run test:sizes в CI' : 'добавьте проверку в CI')
|
|
443
|
-
+ '; проверка — команда пакета, своих файлов в проект она не приносит');
|
|
444
|
-
return 0;
|
|
445
|
-
}
|
|
446
|
-
|
|
447
|
-
/* Команды — словами, режимы — ключами: словами называются те два ответа, которых
|
|
448
|
-
* у ключей не было («всё ли посчитано» и «почему нет строки»), а прежние ключи
|
|
449
|
-
* остаются собой. Отказ до этой функции не доходит: разбор либо вернул готовый
|
|
450
|
-
* план, либо уже бросил, и бросил до того, как проект был прочитан. */
|
|
451
51
|
export function main() {
|
|
452
52
|
try {
|
|
453
53
|
const cmd = parseArgs(process.argv.slice(2));
|
|
@@ -459,16 +59,7 @@ export function main() {
|
|
|
459
59
|
if (cmd.mode === '--init') return initMode(root, cmd.values['--init'], cmd.force);
|
|
460
60
|
const named = cmd.values['--config'];
|
|
461
61
|
const configFile = named ? path.resolve(named) : path.join(root, CONFIG_NAME);
|
|
462
|
-
|
|
463
|
-
if (HOOK_COMMANDS.indexOf(cmd.verb) >= 0) return hookMode(cmd.verb, root, configFile);
|
|
464
|
-
const cfg = loadConfig(configFile, root);
|
|
465
|
-
if (cmd.verb === 'check') return coverageMode(cfg, root, configFile, cmd.json);
|
|
466
|
-
if (cmd.verb === 'explain') return explainMode(cfg, root, cmd.arg[0], cmd.json);
|
|
467
|
-
if (cmd.mode === '--data') return dataMode(cfg, root);
|
|
468
|
-
if (cmd.mode === '--page') return pageMode(cfg, root, cmd.values['--page']);
|
|
469
|
-
if (cmd.mode === '--write') return writeMode(cfg, root);
|
|
470
|
-
if (cmd.json) return jsonMode(cfg, root);
|
|
471
|
-
return checkMode(cfg, root);
|
|
62
|
+
return deliver(cmd, { root: root, configFile: configFile });
|
|
472
63
|
} catch (e) {
|
|
473
64
|
if (e instanceof Refusal) {
|
|
474
65
|
console.error('✗ ' + e.message);
|
|
@@ -482,4 +73,3 @@ export function main() {
|
|
|
482
73
|
return EXIT.INTERNAL;
|
|
483
74
|
}
|
|
484
75
|
}
|
|
485
|
-
|