@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.
package/src/strip.js ADDED
@@ -0,0 +1,231 @@
1
+ import path from 'path';
2
+ import vm from 'vm';
3
+ import { refuseCause } from './refusal.js';
4
+ import { moduleError } from './parse.js';
5
+
6
+ /* Снятие балласта: стрипперы комментариев и отступов для каждой формы текста и
7
+ * правило, какая форма к какому файлу применяется. Только преобразование
8
+ * текста — ни истории, ни настроек этот модуль не знает. */
9
+
10
+ export function byteLen(text) {
11
+ return Buffer.byteLength(text, 'utf8');
12
+ }
13
+
14
+ /* Снятие комментариев и отступов — метрика «объём без балласта», а не
15
+ * минификация: пробелы внутри строк и порядок токенов не трогаются (это позволит
16
+ * сравнивать числа между языками и не зависит от чужого инструмента, которого в
17
+ * проекте нет). Строки и шаблоны проходят насквозь, блочный комментарий
18
+ * заменяется пробелом, чтобы `a` и `b` из `a` + блочный комментарий + `b` не
19
+ * склеились в одно имя, перевод строки после `//` сохраняется — он разделяет
20
+ * токены. */
21
+ export function stripJs(src) {
22
+ let out = '';
23
+ let i = 0;
24
+ let last = ''; // последний значимый символ вывода: по нему решается, оператор «/» или регексп
25
+ let word = ''; // хвост последнего слова: после `return` идёт выражение, а не деление
26
+ while (i < src.length) {
27
+ const ch = src[i];
28
+ const next = src[i + 1];
29
+ // Комментарные пары проверяются до регекси: ни `/`, ни `*` не могут быть
30
+ // первым символом литерала регекспа, а вот `/*` в начале файла — обычное дело.
31
+ if (ch === '/' && next === '/') {
32
+ const nl = src.indexOf('\n', i);
33
+ i = nl === -1 ? src.length : nl;
34
+ continue;
35
+ }
36
+ if (ch === '/' && next === '*') {
37
+ const end = src.indexOf('*/', i + 2);
38
+ out += ' ';
39
+ i = end === -1 ? src.length : end + 2;
40
+ continue;
41
+ }
42
+ if (ch === '/' && regexAllowed(last, word)) {
43
+ const end = endOfRegex(src, i);
44
+ out += src.slice(i, end);
45
+ i = end;
46
+ last = '/';
47
+ word = '';
48
+ continue;
49
+ }
50
+ if (ch === '"' || ch === "'" || ch === '`') {
51
+ const end = endOfString(src, i, ch);
52
+ out += src.slice(i, end);
53
+ i = end;
54
+ last = ch;
55
+ word = '';
56
+ continue;
57
+ }
58
+ out += ch;
59
+ if (ch.trim() !== '') {
60
+ last = ch;
61
+ word = /[\w$]/.test(ch) ? word + ch : '';
62
+ }
63
+ i++;
64
+ }
65
+ return out;
66
+ }
67
+
68
+ /* Регексп начинается там, где ожидается операнд: после оператора, открывающей
69
+ * скобки или ключевого слова. Признак грубый, но его хватает: без него
70
+ * `replace(/\//g, …)` читалось бы как начало строчного комментария и резало
71
+ * строку (проверено гардом компиляции). */
72
+ const REGEX_KEYWORDS = ['return', 'typeof', 'instanceof', 'in', 'of', 'new', 'delete', 'void', 'case', 'do', 'else', 'yield', 'await'];
73
+
74
+ function regexAllowed(last, word) {
75
+ if (last === '') return true;
76
+ if (REGEX_KEYWORDS.indexOf(word) !== -1) return true;
77
+ return '([{,;:=!&|?+-*%~^<>'.indexOf(last) !== -1;
78
+ }
79
+
80
+ function endOfRegex(src, start) {
81
+ let i = start + 1;
82
+ let inClass = false;
83
+ while (i < src.length) {
84
+ const ch = src[i];
85
+ if (ch === '\\') { i += 2; continue; }
86
+ if (ch === '\n') return start + 1; // наткнулись на строку — значит, это был не регексп
87
+ if (ch === '[') inClass = true;
88
+ else if (ch === ']') inClass = false;
89
+ else if (ch === '/' && !inClass) return i + 1;
90
+ i++;
91
+ }
92
+ return start + 1;
93
+ }
94
+
95
+ function endOfString(src, start, quote) {
96
+ let i = start + 1;
97
+ while (i < src.length) {
98
+ const ch = src[i];
99
+ if (ch === '\\') { i += 2; continue; }
100
+ if (ch === quote) return i + 1;
101
+ // В шаблоне `${…}` живёт выражение, а в нём — свои строки.
102
+ if (quote === '`' && ch === '$' && src[i + 1] === '{') { i = endOfTemplateExpr(src, i + 2); continue; }
103
+ i++;
104
+ }
105
+ return src.length;
106
+ }
107
+
108
+ function endOfTemplateExpr(src, start) {
109
+ let depth = 1;
110
+ let i = start;
111
+ while (i < src.length) {
112
+ const ch = src[i];
113
+ if (ch === '\\') { i += 2; continue; }
114
+ if (ch === '{') { depth++; i++; continue; }
115
+ if (ch === '}') { depth--; i++; if (depth === 0) return i; continue; }
116
+ if (ch === '"' || ch === "'" || ch === '`') { i = endOfString(src, i, ch); continue; }
117
+ i++;
118
+ }
119
+ return src.length;
120
+ }
121
+
122
+ export function stripCss(src) {
123
+ return src.replace(/\/\*[\s\S]*?\*\//g, ' ');
124
+ }
125
+
126
+ /* HTML: комментарии разметки (включая маркеры вклеек `<!--icon …-->` и
127
+ * `<!--/icon-->`), комментарии внутри <script> как JS и внутри <style> как CSS. */
128
+ export function stripHtml(src) {
129
+ return src
130
+ .replace(/<!--[\s\S]*?-->/g, '')
131
+ .replace(/(<script\b[^>]*>)([\s\S]*?)(<\/script>)/gi, (_m, open, body, close) => open + stripJs(body) + close)
132
+ .replace(/(<style\b[^>]*>)([\s\S]*?)(<\/style>)/gi, (_m, open, body, close) => open + stripCss(body) + close);
133
+ }
134
+
135
+ export function stripLines(text) {
136
+ return text.split('\n').map((l) => l.trim()).filter((l) => l !== '').join('\n');
137
+ }
138
+
139
+ export function compactJson(text) {
140
+ try { return JSON.stringify(JSON.parse(text)); } catch (_e) { return stripLines(text); }
141
+ }
142
+
143
+ /* Стратегия по расширению. Незнакомое расширение получает снятие отступов и
144
+ * пустых строк — безопасный минимум: снимать комментарии «на глаз» в синтаксисе,
145
+ * которого генератор не знает (например, `#` в YAML или отступы в Python),
146
+ * значило бы мерить уже другой файл. */
147
+ const MINIFY_BY_EXT = {
148
+ '.js': 'strip-js', '.mjs': 'strip-js', '.cjs': 'strip-js',
149
+ '.html': 'strip-html', '.htm': 'strip-html',
150
+ '.css': 'strip-css', '.scss': 'strip-css', '.less': 'strip-css',
151
+ '.json': 'json', '.json5': 'json'
152
+ };
153
+ export const STRATEGIES = ['strip-js', 'strip-html', 'strip-css', 'json', 'strip-lines', 'none'];
154
+
155
+ /* Стратегии, которые и есть минификация: JSON теряет только незначащие пробелы
156
+ * (числа приводятся к кратчайшей записи), и короче его не сделает никто. Остальные
157
+ * — упрощение: они снимают балласт, но не переименовывают и не перестраивают код,
158
+ * и обещать за них точное число нельзя. Список ведёт тот модуль, который владеет
159
+ * стратегиями; метрика по нему решает, точное у неё число или приближённое. */
160
+ export const EXACT_STRATEGIES = ['json'];
161
+
162
+ export function strategyFor(file, cfg) {
163
+ const ext = path.extname(file).toLowerCase();
164
+ return (cfg.minify.ext && cfg.minify.ext[ext]) || MINIFY_BY_EXT[ext] || 'strip-lines';
165
+ }
166
+
167
+ export function minifyForm(text, file, cfg) {
168
+ const how = strategyFor(file, cfg);
169
+ if (how === 'none') return text;
170
+ if (how === 'strip-js') return stripLines(stripJs(text));
171
+ if (how === 'strip-html') return stripLines(stripHtml(text));
172
+ if (how === 'strip-css') return stripLines(stripCss(text));
173
+ if (how === 'json') return compactJson(text);
174
+ if (how === 'strip-lines') return stripLines(text);
175
+ throw new Error('неизвестная стратегия минификации «' + how + '» (есть: ' + STRATEGIES.join(', ') + ')');
176
+ }
177
+
178
+ /* Гард стриппера: он не имеет права выбросить что-то кроме комментариев и
179
+ * отступов, поэтому результат обязан компилироваться. Проверяем только те
180
+ * расширения, где содержимое — валидный JavaScript (список в конфиге,
181
+ * `minify.guard`): TypeScript или JSX хостом не проверяются, и делать вид, что
182
+ * проверили, было бы хуже, чем не проверять.
183
+ *
184
+ * Модуль или скрипт решает текст, а не расширение: проект с бандлером пишет
185
+ * `import`/`export` прямо в `.js` (и с `type: module` в манифесте, и без него), а
186
+ * `vm.Script` разбирает такой файл как скрипт и падает на самом `export`. Гард
187
+ * обязан понимать оба формата, поэтому пробует тот, на который файл похож, и
188
+ * принимает результат, если он разбирается хотя бы одним из двух способов.
189
+ * От этого он не слабеет: настоящая поломка не разберётся ни скриптом, ни
190
+ * модулем, и тогда наружу идёт причина того разбора, которым файл был.
191
+ *
192
+ * Модуль разбирает отдельный рабочий поток (`parse.js`): без него разбор модуля
193
+ * стоил бы запуска Node на каждую клетку. Иначе конфиг вида `eslint.config.mjs`
194
+ * остался бы без гарда, а без гарда его правка могла бы испортить «объём» молча.
195
+ *
196
+ * Когда не разбирается даже исходный текст, стриппер тут ни при чём: в этой
197
+ * графе измеряется не JavaScript (TypeScript, JSX), и это отказ с командой
198
+ * починки — правкой настроек. */
199
+ const MODULE_MARK = /^[ \t]*(?:import|export)\b/m;
200
+ const MODULE_EXT = ['.mjs'];
201
+
202
+ export function assertCompilable(min, rev, p, src) {
203
+ // Скрипт пробуется первым не ради формы, а ради цены: этот разбор идёт
204
+ // в процессе, а модуль — в рабочем потоке.
205
+ const asScript = scriptError(min, p);
206
+ if (asScript === null) return;
207
+ const asModule = moduleError(min);
208
+ if (asModule === null) return;
209
+ const shape = MODULE_EXT.indexOf(path.extname(p).toLowerCase()) >= 0 || MODULE_MARK.test(min);
210
+ if (src !== undefined && scriptError(src, p) !== null && moduleError(src) !== null) {
211
+ refuseCause('файл не JavaScript', 'файл ' + p + ' — не JavaScript: его исходный текст не'
212
+ + ' разбирается ни как скрипт, ни как модуль, так что дело не в стриптере, а '
213
+ + path.extname(p) + ' стоит в minify.guard: ' + (shape ? asModule : asScript) + '\n'
214
+ + ' починка: уберите это расширение из minify.guard или задайте для него '
215
+ + 'minify.ext — например { "' + path.extname(p).toLowerCase() + '": "strip-lines" }');
216
+ }
217
+ // Причина — того разбора, которым файл был: обвинять в чужой форме незачем.
218
+ throw new Error('стриппер испортил ' + p + ' на ' + rev.slice(0, 7) + ': '
219
+ + (shape ? asModule : asScript));
220
+ }
221
+
222
+ // Разбор как скрипт — в процессе: дешевле и без временных файлов.
223
+ function scriptError(text, p) {
224
+ try {
225
+ new vm.Script(text, { filename: p });
226
+ return null;
227
+ } catch (e) {
228
+ return e.message;
229
+ }
230
+ }
231
+
package/src/table.css ADDED
@@ -0,0 +1,35 @@
1
+ /* Гарнитура одна на всю таблицу; числа выравниваются по разрядам за счёт
2
+ * tabular-nums, а не за счёт моноширинного шрифта. */
3
+ table { border-collapse: collapse; font-variant-numeric: tabular-nums; }
4
+ th, td { padding: 2px 7px; border-bottom: 1px solid rgba(127, 127, 127, .25); white-space: nowrap; }
5
+ /* Шапка из двух строк: обе липкие, поэтому вторая сдвинута ровно на высоту первой
6
+ * (line-height 20 + 2px нижней границы), иначе строки накладывались бы друг на друга. */
7
+ thead th { position: sticky; top: 0; z-index: 3; background: Canvas; text-align: center; line-height: 20px; padding: 0 7px; }
8
+ thead tr:first-child th { border-bottom-width: 2px; }
9
+ thead tr:last-child th { top: 22px; }
10
+ .num { text-align: right; }
11
+ .g { border-left: 1px solid rgba(127, 127, 127, .35); }
12
+ /* Липкая левая колонка: фон непрозрачный (Canvas), иначе при скролле вправо под
13
+ * клеткой были бы видны числа. Ярус выше соседних клеток (2) и ниже шапки (3);
14
+ * угол шапки — выше всех, иначе группы колонок наползают на «Коммит». */
15
+ .c-commit { position: sticky; left: 0; z-index: 2; background: Canvas; text-align: left; font-weight: 400; }
16
+ .c-commit a { color: inherit; }
17
+ thead .c-commit { z-index: 6; }
18
+ /* Ширину колонки задаёт этот блок. Без него содержимое ячейки выходило за её
19
+ * границы и рисовалось поверх соседних чисел: у ячейки таблицы нет обрезки. */
20
+ .clip { display: flex; align-items: baseline; gap: 6px; width: 300px; }
21
+ .when { flex: none; opacity: .7; }
22
+ .subj { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; display: block; }
23
+ .subj.plain { opacity: .7; }
24
+ .sect { flex: none; opacity: .7; font-size: 11px; text-decoration: none; border-bottom: 1px dotted currentColor; }
25
+ /* Рост зелёный, спад красный — по договорённости с заказчиком (рост — «больше
26
+ * логики», а не тревога). */
27
+ .up { color: #1e8449; }
28
+ .down { color: #c0392b; }
29
+ .miss { opacity: .5; }
30
+ /* Верхняя строка — текущие размеры: она же и объясняет, к чему относятся дельты. */
31
+ tr.now th, tr.now td { border-bottom: 2px solid rgba(127, 127, 127, .35); }
32
+ tr.now .c-commit { font-weight: 600; }
33
+ /* Подсветка строки — наложением, а не подменой фона: липкая колонка обязана
34
+ * оставаться непрозрачной, иначе под ней при скролле видны числа. */
35
+ tbody tr:hover th, tbody tr:hover td { background-image: linear-gradient(rgba(127, 127, 127, .08), rgba(127, 127, 127, .08)); }
package/src/tokens.js ADDED
@@ -0,0 +1,76 @@
1
+ import path from 'path';
2
+ import { loadOptional } from './optional.js';
3
+
4
+ /* Токенизатор — та же дисциплина, что у минификатора: необязательная зависимость с
5
+ * ленивой загрузкой (устройство — в `src/optional.js`). Отличие одно: токенизатор
6
+ * берёт любой текст, отказать ему не в чем, поэтому отсутствие зависимости — не
7
+ * отказ, а другой счёт: оценка по длине, помеченная приближением в подписи метрики.
8
+ *
9
+ * Семейство — про модели, кодировка — про число: один и тот же файл считается
10
+ * по-разному в `cl100k_base` и `o200k_base`, поэтому кодировка выбирается рядом с
11
+ * семейством, а не подразумевается. Семейство тут одно, и это не недоделка: у
12
+ * остальных нет словаря, который можно было бы назвать их собственным, — считать
13
+ * чужим словарём и называть это семейством значило бы обещать то, чего нет. */
14
+
15
+ export const TOKEN_FAMILIES = {
16
+ openai: { tool: 'gpt-tokenizer', encodings: ['o200k_base', 'cl100k_base'] }
17
+ };
18
+
19
+ export const TOKEN_DEFAULTS = { family: 'openai', encoding: 'o200k_base' };
20
+
21
+ /* Оценка без словаря. Коэффициент снят на текстах этого репозитория (русские
22
+ * документы и код): `README.md` — 3,1 знака на токен, `WORKLOG.md` — около 3,0.
23
+ * Для латиницы та же оценка завышает счёт (там примерно 4 знака на токен), поэтому
24
+ * она и помечена приближением. */
25
+ export const CHARS_PER_TOKEN = 3;
26
+
27
+ /* Форматы, для которых счёт токенов смысла не имеет: картинка, шрифт или архив —
28
+ * это байты, и токенизатор разберёт их как что угодно, а число выйдет случайным.
29
+ * Список нужен, чтобы метрика сказала это словами, а не выдала такой счёт за
30
+ * посчитанный. SVG в него не входит намеренно: это текст, и его токены осмысленны. */
31
+ export const BINARY_EXTS = [
32
+ '.png', '.jpg', '.jpeg', '.gif', '.webp', '.ico', '.avif',
33
+ '.woff', '.woff2', '.ttf', '.otf', '.eot',
34
+ '.pdf', '.zip', '.gz', '.tar', '.mp4', '.mp3', '.mov'
35
+ ];
36
+
37
+ /* Словарь загружается один раз на кодировку: за ним стоят мегабайты таблиц, и
38
+ * платить за них на каждом файле было бы нечем оправдать. */
39
+ const probed = new Map();
40
+
41
+ export function tokenizer(settings) {
42
+ const encoding = (settings || TOKEN_DEFAULTS).encoding;
43
+ if (probed.has(encoding)) return probed.get(encoding);
44
+ const tool = loadOptional(TOKEN_FAMILIES[familyOf(settings)].tool + '/encoding/' + encoding);
45
+ probed.set(encoding, tool);
46
+ return tool;
47
+ }
48
+
49
+ function familyOf(settings) {
50
+ const asked = (settings || TOKEN_DEFAULTS).family;
51
+ return TOKEN_FAMILIES[asked] === undefined ? TOKEN_DEFAULTS.family : asked;
52
+ }
53
+
54
+ /* Счёт одного текста: словарём, если он есть, иначе оценкой. Оба ответа — число
55
+ * условных единиц текста, и различает их не значение, а подпись метрики
56
+ * (`accuracy`), поэтому выдача одного за другое невозможно. */
57
+ export function tokenCount(text, settings) {
58
+ const { tool } = tokenizer(settings);
59
+ if (tool === null) return estimate(text);
60
+ return tool.encode(text).length;
61
+ }
62
+
63
+ /* Оценка по длине — единственное, что можно сказать без словаря. Знаки считаются
64
+ * кодовыми точками: для не-ASCII это ближе к числу токенов, чем единицы UTF-16. */
65
+ export function estimate(text) {
66
+ let chars = 0;
67
+ for (const _ch of text) chars++;
68
+ return Math.ceil(chars / CHARS_PER_TOKEN);
69
+ }
70
+
71
+ /* Бинарный ли файл: счёт токенов для него смысла не имеет. Список форматов ведёт
72
+ * этот модуль, поэтому и подпись метрики, и пометка клетки спрашивают о файле
73
+ * здесь, а не повторяют список у себя. */
74
+ export function isBinary(file) {
75
+ return BINARY_EXTS.indexOf(path.extname(file).toLowerCase()) >= 0;
76
+ }
package/src/tool.js ADDED
@@ -0,0 +1,24 @@
1
+ import fs from 'fs';
2
+
3
+ /* Метаданные самого пакета: имя и версия читаются из его же манифеста, чтобы не
4
+ * держать вторую копию. Отдельный модуль потому, что эти данные описывают
5
+ * упаковку, а не проект-потребитель, и нужны контракту данных. */
6
+
7
+ /* Имя и версия пакета — из его же манифеста, чтобы не держать вторую копию; без
8
+ * файла (чужaя сборка) остаётся заглушка: версия нужна только в данных, и
9
+ * отсутствие манифеста не повод не собирать таблицу. */
10
+ export let TOOL_PKG = { name: '@vernikr/size-report', version: '0.0.0' };
11
+ try {
12
+ TOOL_PKG = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
13
+ } catch (_e) {}
14
+
15
+ /* Как пакет ставится в проект — та же git-ссылка на выпуск, которой учит `README.md`:
16
+ * имени пакета в реестре здесь быть не может, оно занято чужим пакетом, и `add -D
17
+ * <имя>` поставил бы его. Адрес и версия берутся из манифеста, поэтому совет об
18
+ * установке не может разойтись с выпуском, а без адреса (`null`) звать нечего. */
19
+ export function installSpec() {
20
+ const repo = TOOL_PKG.repository === undefined ? ''
21
+ : (typeof TOOL_PKG.repository === 'string' ? TOOL_PKG.repository : TOOL_PKG.repository.url || '');
22
+ const m = repo.match(/github\.com[:/]+([^/\s]+\/[^/\s]+?)(?:\.git)?$/);
23
+ return m === null ? null : 'github:' + m[1] + '#v' + TOOL_PKG.version;
24
+ }
@@ -0,0 +1,79 @@
1
+ # Шаблоны для подключаемого проекта
2
+
3
+ Два файла, которые проект берёт как есть: настройки, проходящие проверку самого
4
+ инструмента, и описание проверки в CI. Ставятся они вместе с пакетом, поэтому
5
+ лежат в его поставке (`files` в `package.json`) и стерегутся проверкой
6
+ `test/templates.test.js`: черновик обязан быть валидным, а команды описания —
7
+ существовать в инструменте.
8
+
9
+ | Файл | Куда | Что делать |
10
+ |---|---|---|
11
+ | `size-report.config.json` | `size-table.config.json` в корне проекта | **Поправить колонки** и, если нужно, остальное |
12
+ | `ci.yml` | `.github/workflows/size-report.yml` | Ничего: файл работает как есть |
13
+
14
+ ## Настройки
15
+
16
+ Скопируйте `size-report.config.json` в корень проекта под именем
17
+ `size-table.config.json`. **Колонки в нём — пример**, а не список ваших файлов:
18
+ в шаблоне стоят `README.md` и `package.json`, потому что они есть почти в любом
19
+ проекте, и с ними первый отчёт соберётся сразу. Свои колонки даёт
20
+ `size --init` (он подбирает крупнейшие файлы по расширениям) — можно взять его
21
+ черновик целиком, а из шаблона перенести ключи, которых `--init` не пишет
22
+ (`skip`, `rows`, `links.commitUrl`), или наоборот: скопировать шаблон и вписать
23
+ колонки руками.
24
+
25
+ Что стоит знать про значения шаблона:
26
+
27
+ - `metrics: ["raw", "min", "tok"]` — три измерения отчёта. `min` считается
28
+ настоящим минификатором (`minify.engine: "esbuild"`), а `tok` — словарём
29
+ `o200k_base`. Оба едут необязательными зависимостями пакета и ставятся обычной
30
+ установкой; если их нет (установка без необязательных зависимостей, платформа
31
+ без них), инструмент работает, но честно говорит, что числа получены другим
32
+ счётом, и отдаёт **код 4** — это не ошибка настройки, а названное приближение.
33
+ - `fixCommand` — команда, которую цитирует подпись отчёта и отказы. В шаблоне это
34
+ `node node_modules/@vernikr/size-report/bin/size.js --write` — путь к
35
+ установленному пакету внутри проекта. **Имени пакета как команды здесь быть не
36
+ может:** `npx <имя>` в проекте без установленного пакета уходит в реестр и тянет
37
+ пакет по сети, то есть совет, который должен выручать, зависит от доступа к
38
+ реестру и от того, что там лежит. Если в проекте есть свой
39
+ скрипт, например `pnpm run sizes`, — впишите его: подпись отчёта будет вести
40
+ к нему.
41
+ - `journal: null` — ссылок на разделы журнала не будет. Если в проекте есть
42
+ `WORKLOG.md` или `CHANGELOG.md`, поставьте объект с `path`, `url`, `pattern` —
43
+ образец печатает `size --init`.
44
+ - `paths` внутри колонки — псевдонимы одного файла: если файл переименовывали,
45
+ перечислите и старое имя, и новое, и колонка не разорвётся.
46
+ - `output: "docs/size-table.html"` — файл таблицы; каталог инструмент создаст сам.
47
+
48
+ ## Проверка в CI
49
+
50
+ Скопируйте `ci.yml` в `.github/workflows/size-report.yml` — правок он не требует,
51
+ если проект на `pnpm`. Что он делает и почему именно так, написано в его
52
+ комментариях; коротко: собирает таблицу заново и сверяет с файлом на диске,
53
+ а затем снимает данные отчёта дважды — обычно и в среде, где настроек git нет
54
+ вовсе (`GIT_CONFIG_GLOBAL=/dev/null`), — и сравнивает снимки побайтово. Второе и
55
+ есть проверка того, что числа не зависят от машины.
56
+
57
+ Своих секретов описание не требует и не должно: пакет ставится из публичного
58
+ репозитория, а pnpm тянет его архив по HTTPS — ни ключа, ни токена ни установке,
59
+ ни самой проверке не нужно. Если проект уйдёт на приватный registry или на свою
60
+ копию пакета, шаг с ключом придётся добавить самому: заводить его в шаблоне за
61
+ проект инструмент не должен.
62
+
63
+ Для `npm` и `yarn` в файле сказано, какие две строки заменить.
64
+
65
+ Проверки полноты (`pnpm exec size check`) в шаблоне намеренно нет: она требует,
66
+ чтобы **каждый** путь истории был колонкой или исключением, — а колонки в шаблоне
67
+ примерные, и на проекте, где они ещё не подобраны, такая проверка была бы красной
68
+ не по делу. Когда колонки обрисуют проект, добавьте шаг сами: `check` назовёт
69
+ пути, которые колонкой не отслеживаются, и коммиты, которые их завели; те, что
70
+ считать не нужно, вписываются в `skip` — тот же список делает путь и исключением.
71
+ Про один коммит отвечает `pnpm exec size explain <sha>`.
72
+
73
+ ## Чего в шаблонах нет
74
+
75
+ Блока для файлов агентов проекта (`AGENTS.md` и подобных) здесь нет намеренно:
76
+ требования такого файла не просят, а выдумывать формат чужого репозитория
77
+ инструмент не должен. Что агенту нужно знать, он возьмёт из `size --help` и
78
+ `size --data` — команды и данные описаны в `README.md` пакета, раздел
79
+ «Для ИИ-агента».
@@ -0,0 +1,67 @@
1
+ # Проверка объёма для проекта, подключившего @vernikr/size-report.
2
+ # Положите файл в .github/workflows/size-report.yml — правок он не требует.
3
+ #
4
+ # Что проверяется и почему так:
5
+ # * таблица объёма на диске сходится с историей git — это числа отчёта;
6
+ # * тот же снимок чисел, снятый в среде, где настроек git нет вовсе,
7
+ # совпадает побайтово: вывод инструмента не должен зависеть от того, что
8
+ # настроено на машине (BLOCKERS.md §B1, §B2).
9
+ #
10
+ # Секретов шаблон не требует и не может потребовать: пакет ставится из
11
+ # публичного репозитория, а pnpm тянет его архив с codeload.github.com по HTTPS —
12
+ # ни ключа, ни токена установке не нужно (проверено установкой в окружении без
13
+ # настроек git и без помощника учётных данных; README пакета, §1). Если проект
14
+ # уйдёт на приватный registry, шаг с ключом придётся добавить самому.
15
+ #
16
+ # Менеджер пакетов: ниже pnpm. Для npm — `npm ci` вместо `pnpm install`,
17
+ # для yarn — `yarn --immutable`. Сама проверка — всегда одна и та же команда
18
+ # `size`: локальный бинарь, поставленный установкой пакета (`pnpm exec size` после
19
+ # `pnpm install`, `npm exec size` после `npm ci`).
20
+ # Имени пакета как команды здесь нет намеренно: `npx <имя>` в проекте без
21
+ # установленного пакета уходит в реестр и тянет пакет оттуда по сети, тогда как
22
+ # шаг проверки обязан работать на том, что поставлено установкой.
23
+ # Нужен явный путь — `node node_modules/@vernikr/size-report/bin/size.js`.
24
+ #
25
+ # Отчёт не в git? Такое тоже задумано требованиями (отчёт — выводимый артефакт):
26
+ # тогда вместо шага «Таблица объёма совпадает с историей» поставьте сборку —
27
+ # `pnpm exec size --write` — и этот шаг будет проверять, что отчёт собирается.
28
+
29
+ name: size-report
30
+
31
+ on: [push, pull_request]
32
+
33
+ jobs:
34
+ size:
35
+ runs-on: ubuntu-latest
36
+ steps:
37
+ # История нужна целиком: таблица строится по коммитам, и на обрезанном
38
+ # клоне инструмент отказывается работать (код 3), а не пишет короткую.
39
+ - uses: actions/checkout@v7
40
+ with:
41
+ fetch-depth: 0
42
+
43
+ - uses: pnpm/action-setup@v4
44
+
45
+ - uses: actions/setup-node@v7
46
+ with:
47
+ node-version: 22
48
+ cache: pnpm
49
+
50
+ - name: Установка
51
+ run: pnpm install --frozen-lockfile
52
+
53
+ # Проверка — та же команда `size` без ключей: она собирает таблицу заново
54
+ # и сверяет с файлом на диске. Своего набора тестов потребителю не нужно.
55
+ - name: Таблица объёма совпадает с историей
56
+ run: pnpm exec size
57
+
58
+ - name: Снимок чисел контракта
59
+ run: pnpm exec size --data > /tmp/size-report-data.json
60
+
61
+ - name: Снимок чисел без настроек git на машине
62
+ run: pnpm exec size --data > /tmp/size-report-data-null.json
63
+ env:
64
+ GIT_CONFIG_GLOBAL: /dev/null
65
+
66
+ - name: Числа не зависят от настроек машины
67
+ run: diff -u /tmp/size-report-data.json /tmp/size-report-data-null.json
@@ -0,0 +1,53 @@
1
+ {
2
+ "output": "docs/size-table.html",
3
+ "locale": "ru",
4
+ "title": "Объём файлов по коммитам",
5
+ "heading": "Объём файлов по коммитам",
6
+ "fixCommand": "node node_modules/@vernikr/size-report/bin/size.js --write",
7
+ "metrics": [
8
+ "raw",
9
+ "min",
10
+ "tok"
11
+ ],
12
+ "minify": {
13
+ "engine": "esbuild",
14
+ "ext": {},
15
+ "guard": [
16
+ ".js",
17
+ ".mjs",
18
+ ".cjs"
19
+ ]
20
+ },
21
+ "tokens": {
22
+ "family": "openai",
23
+ "encoding": "o200k_base"
24
+ },
25
+ "columns": [
26
+ {
27
+ "label": "README.md",
28
+ "paths": [
29
+ "README.md"
30
+ ],
31
+ "category": "docs"
32
+ },
33
+ {
34
+ "label": "package.json",
35
+ "paths": [
36
+ "package.json"
37
+ ],
38
+ "category": "chore"
39
+ }
40
+ ],
41
+ "journal": null,
42
+ "links": {
43
+ "commitUrl": ""
44
+ },
45
+ "rows": {
46
+ "merges": true,
47
+ "sha": true
48
+ },
49
+ "hooks": {
50
+ "enabled": true
51
+ },
52
+ "skip": []
53
+ }