@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/CHANGELOG.md +241 -0
- package/LICENSE +21 -0
- package/README.md +892 -0
- package/bin/size.js +6 -0
- package/package.json +61 -0
- package/src/artifact.css +8 -0
- package/src/artifact.js +21 -0
- package/src/check.js +142 -0
- package/src/cli.js +476 -0
- package/src/config.js +158 -0
- package/src/css.js +37 -0
- package/src/data.js +105 -0
- package/src/derived.js +115 -0
- package/src/doctor.js +225 -0
- package/src/explain.js +113 -0
- package/src/git.js +221 -0
- package/src/history.js +206 -0
- package/src/hook.js +404 -0
- package/src/journal.js +66 -0
- package/src/locales.js +124 -0
- package/src/metrics.js +268 -0
- package/src/minify.js +67 -0
- package/src/optional.js +31 -0
- package/src/page/app.css +165 -0
- package/src/page/app.js +560 -0
- package/src/page/build.js +102 -0
- package/src/parse-worker.js +34 -0
- package/src/parse.js +136 -0
- package/src/refusal.js +133 -0
- package/src/render.js +131 -0
- package/src/size-table.js +86 -0
- package/src/strip.js +231 -0
- package/src/table.css +35 -0
- package/src/tokens.js +76 -0
- package/src/tool.js +24 -0
- package/templates/README.md +79 -0
- package/templates/ci.yml +67 -0
- package/templates/size-report.config.json +53 -0
package/src/hook.js
ADDED
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { fileURLToPath } from 'url';
|
|
4
|
+
import { EXIT, Refusal, cliCommand, refuseCause } from './refusal.js';
|
|
5
|
+
import { installSpec } from './tool.js';
|
|
6
|
+
import { git, gitTry } from './git.js';
|
|
7
|
+
import { loadConfig } from './config.js';
|
|
8
|
+
import { TOOL_PKG } from './tool.js';
|
|
9
|
+
import { rebuild } from './artifact.js';
|
|
10
|
+
|
|
11
|
+
/* Хуки `post-commit` и `post-merge`: после каждого коммита и слияния отчёт
|
|
12
|
+
* пересобирается сам, а если он лежит в git — ложится отдельным коммитом.
|
|
13
|
+
* Требования §7.1–§7.3 (автообновление, защита от зацикливания, отключаемость) и
|
|
14
|
+
* шаг 5 плана.
|
|
15
|
+
*
|
|
16
|
+
* Что он делает и почему именно так:
|
|
17
|
+
*
|
|
18
|
+
* - **Ставится только явной командой** (`install-hook`) и снимается
|
|
19
|
+
* (`uninstall-hook`): установка — это правка чужого `.git`, и делать её вслепую
|
|
20
|
+
* нельзя. До установки проект не меняется ничем, после снятия — возвращается к
|
|
21
|
+
* прежнему поведению.
|
|
22
|
+
* - **Сам коммитов не создаёт** — за одним исключением: отчёт, лежащий в git,
|
|
23
|
+
* коммитится отдельно от кода. Раньше это делал человек (отсюда ловушка «правка
|
|
24
|
+
* кода и таблицы в одном коммите»), и хук для того и нужен, чтобы ручного шага не
|
|
25
|
+
* было. Правило одно на два состояния проекта: отчёт в git — отдельный коммит;
|
|
26
|
+
* отчёт вне git — только пересборка (ровно то, что описано в требованиях §7.2:
|
|
27
|
+
* пересборка коммита не порождает).
|
|
28
|
+
* - **Коммитит только путь отчёта**: дерево берётся от HEAD, и в нём подменяется
|
|
29
|
+
* ровно путь отчёта, поэтому в коммит физически не может попасть ничего другого —
|
|
30
|
+
* ни индекс, ни чужая незакоммиченная работа («не может потерять работу»).
|
|
31
|
+
* - **Слияние — такой же случай, как обычный коммит**, но с одной поправкой к тому,
|
|
32
|
+
* что зовёт git: `post-commit` при `git merge` не выполняется (git создаёт коммит
|
|
33
|
+
* слияния сам), поэтому ставится ещё и `post-merge`. Строка слияния ложится в
|
|
34
|
+
* отчёт по общему правилу (`rows.merges`), а поведение при вызове второго файла
|
|
35
|
+
* то же, что при повторном запуске: пересобирать нечего — коммита нет.
|
|
36
|
+
* - **Зацикливание невозможно по двум причинам.** Коммит отчёта собирается
|
|
37
|
+
* плумбингом (`commit-tree`), а он хуков не зовёт вовсе, — вложенного запуска не
|
|
38
|
+
* бывает по устройству, а не по флагу в окружении. И сам отчёт — путь, который
|
|
39
|
+
* строки не получает (инвариант «строка про коммит не может лежать внутри самого
|
|
40
|
+
* коммита»), поэтому та же пересборка даёт те же байты и второго коммита не будет.
|
|
41
|
+
* Плюс замок, чтобы два хука не пошли одновременно.
|
|
42
|
+
* - **Отказ инструмента не роняет коммит.** Коммит уже сделан, и блокировать в нём
|
|
43
|
+
* нечего (требование §8: инструмент только показывает). Причина печатается одной
|
|
44
|
+
* строкой и запоминается — её видно в `size doctor` (`src/doctor.js`).
|
|
45
|
+
* - **В окружениях, где обновлять отчёт не нужно** (интеграция, чужая машина,
|
|
46
|
+
* зависимостей нет) хук молчит: сам файл хука в git не едет — он лежит в `.git`,
|
|
47
|
+
* то есть у каждого клона свой, — а внутри есть проверки «есть ли чем звать
|
|
48
|
+
* инструмент».
|
|
49
|
+
*
|
|
50
|
+
* Место состояния — git-каталог (`<git-dir>/size-report/`), а не рабочее дерево:
|
|
51
|
+
* иначе замок и запись о запуске торчали бы в `git status` как неотслеживаемые
|
|
52
|
+
* файлы. Состояние локально для клона, как и сам хук. */
|
|
53
|
+
|
|
54
|
+
// Второй выключатель: окружение, где автообновление не нужно вовсе (CI, чужая
|
|
55
|
+
// машина), — и явный рубильник для тех, кто не хочет править настройки.
|
|
56
|
+
const NO_HOOK = 'SIZE_REPORT_NO_HOOK';
|
|
57
|
+
/* Два файла, а не один: на обычный коммит git зовёт `post-commit`, а на слияние —
|
|
58
|
+
* только `post-merge` (`post-commit` при `git merge` не выполняется вовсе). Вход
|
|
59
|
+
* у обоих один, поэтому лишнего запуска не бывает: тот, что сработал вторым,
|
|
60
|
+
* видит, что менять нечего, и молчит. */
|
|
61
|
+
const HOOKS = ['post-commit', 'post-merge'];
|
|
62
|
+
// Метка «этот файл наш»: по ней хук отличается от чужого, который перезаписывать
|
|
63
|
+
// нельзя, и по ней же снятие понимает, что удалять.
|
|
64
|
+
const MARK = '# size-report: hook';
|
|
65
|
+
const STATE = 'hook.json';
|
|
66
|
+
const LOCK = 'hook.lock';
|
|
67
|
+
|
|
68
|
+
function gitDir(root) {
|
|
69
|
+
return git(root, ['rev-parse', '--absolute-git-dir']).trim();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function stateDir(root) {
|
|
73
|
+
return path.join(gitDir(root), 'size-report');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/* Куда git читает хуки. `core.hooksPath` перебивает `.git/hooks`, и это чужая
|
|
77
|
+
* настройка: такой каталог часто лежит в другом репозитории и версионируется, а
|
|
78
|
+
* вставлять строку в чужой скрипт — править чужой файл. Поэтому с чужим путём
|
|
79
|
+
* установка отказывает и называет готовую строку. */
|
|
80
|
+
function hooksDir(root) {
|
|
81
|
+
const custom = gitTry(root, ['config', '--get', 'core.hooksPath']);
|
|
82
|
+
if (custom.status === 0 && custom.stdout.trim() !== '') {
|
|
83
|
+
return { dir: path.resolve(root, custom.stdout.trim()), custom: true };
|
|
84
|
+
}
|
|
85
|
+
return { dir: path.join(gitDir(root), 'hooks'), custom: false };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function hookFile(root, name) {
|
|
89
|
+
return path.join(hooksDir(root).dir, name);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/* Движок, которым хук зовёт инструмент, — тот, что нашла бы сама установка. Путь
|
|
93
|
+
* записывается в скрипт на время установки: у монорепозитория `node_modules` может
|
|
94
|
+
* лежать выше корня проекта, и общий относительный путь там не работает. Путь
|
|
95
|
+
* цитируется: в нём может стоять пробел, а хук — это sh, а не список аргументов. */
|
|
96
|
+
function hookEntry(root) {
|
|
97
|
+
const candidates = [
|
|
98
|
+
path.join(root, 'node_modules', TOOL_PKG.name, 'bin', 'size.js'),
|
|
99
|
+
path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'bin', 'size.js')
|
|
100
|
+
];
|
|
101
|
+
const found = candidates.find((p) => fs.existsSync(p));
|
|
102
|
+
if (found === undefined) return null;
|
|
103
|
+
const rel = path.relative(root, found);
|
|
104
|
+
const shown = rel !== '' && rel.indexOf('..') !== 0 ? './' + rel : found;
|
|
105
|
+
return { path: shown, quoted: shQuote(shown) };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function shQuote(text) {
|
|
109
|
+
return '"' + text.replace(/(["\\$`])/g, '\\$1') + '"';
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Готовая строка для чужого хука: её копируют как есть, поэтому она без метки.
|
|
113
|
+
function runLine(entry) {
|
|
114
|
+
return 'node ' + entry.path + ' hook-run';
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/* Тело хука. Проверки перед запуском — это ответ «что он НЕ делает там, где
|
|
118
|
+
* обновлять нечего»: без `node` (GUI-клиент git с урезанным PATH) и без движка
|
|
119
|
+
* (зависимости не поставлены, клон без установки) хук выходит молча — шум после
|
|
120
|
+
* каждого коммита был бы хуже отсутствия автоматики. */
|
|
121
|
+
function script(entry) {
|
|
122
|
+
return '#!/bin/sh\n'
|
|
123
|
+
+ MARK + ': обновление отчёта после коммита.\n'
|
|
124
|
+
+ '# Ставится и снимается командами `size install-hook` / `size uninstall-hook`;\n'
|
|
125
|
+
+ '# правки в этом файле не сохранятся — команда перезаписывает его целиком.\n'
|
|
126
|
+
+ '# Коммитов хук сам не делает: пересборка и коммит отчёта — в `size hook-run`.\n'
|
|
127
|
+
+ 'command -v node >/dev/null 2>&1 || exit 0\n'
|
|
128
|
+
+ '[ -f ' + entry.quoted + ' ] || exit 0\n'
|
|
129
|
+
+ 'exec node ' + entry.quoted + ' hook-run\n';
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/* Состояние хука для `size doctor`: установлен ли и чем кончился последний
|
|
133
|
+
* запуск. Ничего не считает и ни к чему не обязывает. */
|
|
134
|
+
export function hookStatus(root) {
|
|
135
|
+
const files = HOOKS.map((name) => hookFile(root, name));
|
|
136
|
+
return {
|
|
137
|
+
files: files.map((f) => path.relative(root, f)),
|
|
138
|
+
installed: files.some((f) => fs.existsSync(f)),
|
|
139
|
+
last: hookState(root)
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function isOurs(file) {
|
|
144
|
+
return fs.existsSync(file) && fs.readFileSync(file, 'utf8').indexOf(MARK) >= 0;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/* Запись о последнем запуске. Отсутствие файла — «хук ещё не запускался», а не
|
|
148
|
+
* ошибка: до первого коммита её и не должно быть. */
|
|
149
|
+
function hookState(root) {
|
|
150
|
+
try {
|
|
151
|
+
return JSON.parse(fs.readFileSync(path.join(stateDir(root), STATE), 'utf8'));
|
|
152
|
+
} catch (_e) {
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function installHook(root, cfg) {
|
|
158
|
+
const entry = hookEntry(root);
|
|
159
|
+
if (entry === null) {
|
|
160
|
+
// Совет называет ту установку, которой учит README: имя пакета в реестре занято
|
|
161
|
+
// чужим пакетом, и `add -D <имя>` поставил бы его (REFACTOR R-4.21).
|
|
162
|
+
const spec = installSpec();
|
|
163
|
+
refuseCause('нечем звать инструмент', 'не нашлось чем звать инструмент: хук без него молчал бы'
|
|
164
|
+
+ ' после каждого коммита.\n'
|
|
165
|
+
+ ' починка: поставьте пакет зависимостью проекта'
|
|
166
|
+
+ (spec === null ? '' : ' (так, как он ставится в этот проект: pnpm add -D ' + spec + ')')
|
|
167
|
+
+ ' и повторите установку');
|
|
168
|
+
}
|
|
169
|
+
const hooks = hooksDir(root);
|
|
170
|
+
if (hooks.custom) {
|
|
171
|
+
refuseCause('чужой core.hooksPath', 'в проекте задан core.hooksPath (' + hooks.dir + '): этот каталог может лежать'
|
|
172
|
+
+ ' в другом репозитории, и править его инструмент не станет.\n'
|
|
173
|
+
+ ' починка: впишите в свой хук строку «' + runLine(entry) + '»');
|
|
174
|
+
}
|
|
175
|
+
const files = HOOKS.map((name) => path.join(hooks.dir, name));
|
|
176
|
+
const rels = files.map((f) => path.relative(root, f));
|
|
177
|
+
files.forEach((file, i) => {
|
|
178
|
+
if (fs.existsSync(file) && !isOurs(file)) {
|
|
179
|
+
refuseCause('чужой хук', 'хук ' + rels[i] + ' уже есть и поставлен не этим инструментом (в нём нет метки).\n'
|
|
180
|
+
+ ' починка: позовите инструмент из своего хука строкой «' + runLine(entry) + '»'
|
|
181
|
+
+ ' — перезаписи чужого файла нет намеренно');
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
if (files.every(isOurs)) {
|
|
185
|
+
return { code: EXIT.OK, lines: [
|
|
186
|
+
'· хук уже установлен: ' + rels.join(', '),
|
|
187
|
+
' автоматика работает после каждого коммита и слияния',
|
|
188
|
+
' снять: ' + cliCommand('uninstall-hook')
|
|
189
|
+
] };
|
|
190
|
+
}
|
|
191
|
+
fs.mkdirSync(hooks.dir, { recursive: true });
|
|
192
|
+
files.forEach((file) => {
|
|
193
|
+
fs.writeFileSync(file, script(entry));
|
|
194
|
+
fs.chmodSync(file, 0o755);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
const lines = [
|
|
198
|
+
'✓ хук: ' + rels.join(', '),
|
|
199
|
+
' после каждого коммита и слияния пересобирает ' + cfg.output
|
|
200
|
+
+ ' и, если этот файл в git, кладёт его отдельным коммитом',
|
|
201
|
+
' сам он коммитит только отчёт: индекс и ваша незакоммиченная работа не тронуты',
|
|
202
|
+
' выключить, не снимая: «"hooks": {"enabled": false}» в файле настроек',
|
|
203
|
+
' снять: ' + cliCommand('uninstall-hook')
|
|
204
|
+
];
|
|
205
|
+
if (cfg.hooks.enabled === false) {
|
|
206
|
+
lines.push('! сейчас автоматика выключена настройкой hooks.enabled — хук будет молчать');
|
|
207
|
+
}
|
|
208
|
+
return { code: EXIT.OK, lines: lines };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/* Снятие: убирается только то, что поставили мы. Файл не «похож на наш», а помечен
|
|
212
|
+
* меткой, иначе чужой хук был бы стёрт молча. */
|
|
213
|
+
export function uninstallHook(root) {
|
|
214
|
+
const files = HOOKS.map((name) => hookFile(root, name));
|
|
215
|
+
const rels = files.map((f) => path.relative(root, f));
|
|
216
|
+
const present = files.filter((f) => fs.existsSync(f));
|
|
217
|
+
if (present.length === 0) {
|
|
218
|
+
return { code: EXIT.OK, lines: ['· хук не установлен: ' + rels.join(', ')] };
|
|
219
|
+
}
|
|
220
|
+
present.forEach((file) => {
|
|
221
|
+
if (!isOurs(file)) {
|
|
222
|
+
refuseCause('чужой хук', 'хук ' + path.relative(root, file) + ' поставлен не этим инструментом — не трогаю.\n'
|
|
223
|
+
+ ' починка: уберите из него строку с «hook-run», если она там есть');
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
present.forEach((file) => fs.rmSync(file));
|
|
227
|
+
fs.rmSync(stateDir(root), { recursive: true, force: true });
|
|
228
|
+
return { code: EXIT.OK, lines: ['✓ хук снят: ' + rels.join(', ') + ' (проект ведёт себя как до установки)'] };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/* Замок: два хука одновременно (например, коммит из двух терминалов) не должны
|
|
232
|
+
* пересобирать один файл. Замок с живым владельцем — «уже идёт»; замок, оставшийся
|
|
233
|
+
* от убитого процесса, забирается: иначе однажды прерванный прогон запретил бы хук
|
|
234
|
+
* навсегда. */
|
|
235
|
+
function acquire(root) {
|
|
236
|
+
const file = path.join(stateDir(root), LOCK);
|
|
237
|
+
fs.mkdirSync(stateDir(root), { recursive: true });
|
|
238
|
+
const take = () => {
|
|
239
|
+
fs.writeFileSync(file, String(process.pid) + '\n', { flag: 'wx' });
|
|
240
|
+
return { ok: true, file: file, release: () => fs.rmSync(file, { force: true }) };
|
|
241
|
+
};
|
|
242
|
+
try {
|
|
243
|
+
return take();
|
|
244
|
+
} catch (e) {
|
|
245
|
+
if (e.code !== 'EEXIST') throw e;
|
|
246
|
+
}
|
|
247
|
+
let owner = null;
|
|
248
|
+
try {
|
|
249
|
+
owner = Number(fs.readFileSync(file, 'utf8').trim());
|
|
250
|
+
} catch (_e) {
|
|
251
|
+
owner = null;
|
|
252
|
+
}
|
|
253
|
+
if (owner !== null && owner > 0 && alive(owner)) return { ok: false, file: file };
|
|
254
|
+
fs.rmSync(file, { force: true });
|
|
255
|
+
return take();
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function alive(pid) {
|
|
259
|
+
try {
|
|
260
|
+
process.kill(pid, 0);
|
|
261
|
+
return true;
|
|
262
|
+
} catch (_e) {
|
|
263
|
+
return false;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/* Запись о запуске для `size doctor`. Её провал (нет прав, нет каталога) не должен
|
|
268
|
+
* становиться шумом после коммита: это не то, ради чего хук запускают. */
|
|
269
|
+
function record(root, fields, note) {
|
|
270
|
+
const state = Object.assign({ schema: 1, at: new Date().toISOString() }, fields);
|
|
271
|
+
try {
|
|
272
|
+
fs.mkdirSync(stateDir(root), { recursive: true });
|
|
273
|
+
fs.writeFileSync(path.join(stateDir(root), STATE), JSON.stringify(state, null, 2) + '\n');
|
|
274
|
+
} catch (_e) {
|
|
275
|
+
// см. выше: запись — не цель прогона
|
|
276
|
+
}
|
|
277
|
+
return { code: EXIT.OK, note: note === undefined ? '' : note };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/* Коммит отчёта собирается плумбингом, а не `git commit --only`: `--only` отказывает
|
|
281
|
+
* на слиянии («cannot do a partial commit during a merge» — MERGE_HEAD жив, пока хук
|
|
282
|
+
* `post-merge` работает), а развилка «обычный случай так, слияние иначе» оставила бы
|
|
283
|
+
* один из двух путей почти без хода. Дерево берётся от HEAD, и в нём подменяется
|
|
284
|
+
* ровно путь отчёта, поэтому в коммит не может попасть ни индекс, ни чужая правка,
|
|
285
|
+
* а `commit-tree` хуков не зовёт — вложенного запуска не бывает по устройству.
|
|
286
|
+
* Подпись коммита не спрашивается: настройка машины не должна останавливать коммит.
|
|
287
|
+
* Дешёвый индекс здесь — временный: настоящий трогается один раз, и только в записи
|
|
288
|
+
* об отчёте, иначе после коммита дерево было бы грязным. */
|
|
289
|
+
function commitReport(root, rel, file, message, branch, head) {
|
|
290
|
+
const fail = (why) => ({ ok: false, why: why });
|
|
291
|
+
const blob = gitTry(root, ['hash-object', '-w', '--', file]);
|
|
292
|
+
if (blob.status !== 0) return fail('git hash-object: ' + blob.stderr.trim());
|
|
293
|
+
const listed = gitTry(root, ['ls-files', '-s', '--', rel]);
|
|
294
|
+
const mode = listed.stdout.trim().split(' ')[0] || '100644';
|
|
295
|
+
const indexFile = path.join(stateDir(root), 'commit-index');
|
|
296
|
+
const env = { GIT_INDEX_FILE: indexFile };
|
|
297
|
+
const entry = [mode, blob.stdout.trim(), rel].join(',');
|
|
298
|
+
fs.rmSync(indexFile, { force: true });
|
|
299
|
+
try {
|
|
300
|
+
const read = gitTry(root, ['read-tree', head], env);
|
|
301
|
+
if (read.status !== 0) return fail('git read-tree: ' + read.stderr.trim());
|
|
302
|
+
const put = gitTry(root, ['update-index', '--add', '--cacheinfo', entry], env);
|
|
303
|
+
if (put.status !== 0) return fail('git update-index: ' + put.stderr.trim());
|
|
304
|
+
const tree = gitTry(root, ['write-tree'], env);
|
|
305
|
+
if (tree.status !== 0) return fail('git write-tree: ' + tree.stderr.trim());
|
|
306
|
+
const made = gitTry(root, ['commit-tree', tree.stdout.trim(), '-p', head, '-m', message]);
|
|
307
|
+
if (made.status !== 0) return fail('git commit-tree: ' + made.stderr.trim());
|
|
308
|
+
const moved = gitTry(root, ['update-ref', '-m', message, branch, made.stdout.trim(), head]);
|
|
309
|
+
if (moved.status !== 0) return fail('git update-ref: ' + moved.stderr.trim());
|
|
310
|
+
const synced = gitTry(root, ['update-index', '--add', '--cacheinfo', entry]);
|
|
311
|
+
if (synced.status !== 0) return fail('git update-index: ' + synced.stderr.trim());
|
|
312
|
+
return { ok: true, sha: made.stdout.trim().slice(0, 7) };
|
|
313
|
+
} finally {
|
|
314
|
+
fs.rmSync(indexFile, { force: true });
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
export function hookRun(root, configFile) {
|
|
319
|
+
if (process.env.CI || process.env[NO_HOOK]) {
|
|
320
|
+
return record(root, {
|
|
321
|
+
result: 'skipped',
|
|
322
|
+
why: 'окружение без автообновления (' + (process.env.CI ? 'CI' : NO_HOOK) + ')'
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
const head = gitTry(root, ['rev-parse', 'HEAD']);
|
|
326
|
+
const sha = head.status === 0 ? head.stdout.trim() : null;
|
|
327
|
+
const lock = acquire(root);
|
|
328
|
+
if (!lock.ok) {
|
|
329
|
+
return record(root, {
|
|
330
|
+
result: 'skipped',
|
|
331
|
+
head: sha,
|
|
332
|
+
why: 'обновление уже идёт (замок ' + path.relative(root, lock.file) + ')'
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
try {
|
|
336
|
+
return runLocked(root, configFile, sha);
|
|
337
|
+
} finally {
|
|
338
|
+
lock.release();
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function runLocked(root, configFile, sha) {
|
|
343
|
+
let cfg;
|
|
344
|
+
try {
|
|
345
|
+
cfg = loadConfig(configFile, root);
|
|
346
|
+
} catch (e) {
|
|
347
|
+
if (!(e instanceof Refusal)) throw e;
|
|
348
|
+
return record(root, { result: 'skipped', head: sha, why: 'настройки нечитаемы: ' + e.message });
|
|
349
|
+
}
|
|
350
|
+
if (cfg.hooks.enabled === false) {
|
|
351
|
+
return record(root, { result: 'skipped', head: sha, report: cfg.output, why: 'выключено настройкой hooks.enabled' });
|
|
352
|
+
}
|
|
353
|
+
/* Отделённый HEAD — это rebase, cherry-pick и bisect: коммитить в такое состояние
|
|
354
|
+
* нельзя (запись окажется ни на одной ветке), а пересобирать отчёт по промежуточному
|
|
355
|
+
* состоянию некому. Слиянию это не мешает: `post-merge` приходит уже после того,
|
|
356
|
+
* как коммит слияния создан. */
|
|
357
|
+
const branch = gitTry(root, ['symbolic-ref', '-q', 'HEAD']).stdout.trim();
|
|
358
|
+
if (branch === '') {
|
|
359
|
+
return record(root, { result: 'skipped', head: sha, report: cfg.output, why: 'HEAD отделён (rebase, cherry-pick, bisect)' });
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
let out;
|
|
363
|
+
try {
|
|
364
|
+
out = rebuild(cfg, root);
|
|
365
|
+
} catch (e) {
|
|
366
|
+
/* Отказ инструмента (нет истории, не разобрался файл, нет настроек) — это не
|
|
367
|
+
* отказ хука: коммит уже сделан, и валить его нечем и незачем. Причина едет
|
|
368
|
+
* в `size doctor`, а человеку хватает одной строки. Неожиданная ошибка — дефект
|
|
369
|
+
* инструмента, и стек печатается: иначе его нечем разбирать. */
|
|
370
|
+
if (e instanceof Refusal) {
|
|
371
|
+
return record(root, { result: 'refused', head: sha, report: cfg.output, why: e.message }, '✗ size-report: ' + e.message.split('\n')[0]);
|
|
372
|
+
}
|
|
373
|
+
return record(root, { result: 'failed', head: sha, report: cfg.output, why: String(e.message) },
|
|
374
|
+
'✗ size-report: внутренняя ошибка: ' + e.stack);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const rel = path.relative(root, out.file);
|
|
378
|
+
if (gitTry(root, ['ls-files', '--error-unmatch', '--', rel]).status !== 0) {
|
|
379
|
+
return record(root, {
|
|
380
|
+
result: 'rebuilt',
|
|
381
|
+
head: sha,
|
|
382
|
+
report: cfg.output,
|
|
383
|
+
why: 'отчёт не отслеживается git: пересобран, коммита нет'
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
const diff = gitTry(root, ['diff', '--quiet', 'HEAD', '--', rel]);
|
|
387
|
+
if (diff.status === 0) {
|
|
388
|
+
return record(root, { result: 'unchanged', head: sha, report: cfg.output });
|
|
389
|
+
}
|
|
390
|
+
if (diff.status !== 1) {
|
|
391
|
+
return record(root, { result: 'failed', head: sha, report: cfg.output, why: 'git diff: ' + diff.stderr.trim() },
|
|
392
|
+
'✗ size-report: git diff -- ' + rel + ': ' + diff.stderr.trim());
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const message = 'chore(report): отчёт пересобран после ' + sha.slice(0, 7);
|
|
396
|
+
const commit = commitReport(root, rel, out.file, message, branch, sha);
|
|
397
|
+
if (!commit.ok) {
|
|
398
|
+
return record(root, { result: 'refused', head: sha, report: cfg.output, why: commit.why },
|
|
399
|
+
'✗ size-report: коммит отчёта не прошёл: ' + commit.why);
|
|
400
|
+
}
|
|
401
|
+
const made = commit.sha;
|
|
402
|
+
return record(root, { result: 'committed', head: sha, report: cfg.output, commit: made },
|
|
403
|
+
'✓ size-report: ' + cfg.output + ' пересобран и закоммичен (' + made + ')');
|
|
404
|
+
}
|
package/src/journal.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/* Журнал и ссылки: к какому разделу относится коммит и куда ведёт его описание.
|
|
2
|
+
* Работает на тексте, а не на git: разделы ищутся в уже прочитанном файле. */
|
|
3
|
+
|
|
4
|
+
/* Журнал (WORKLOG/CHANGELOG/…): раздел, к которому относится коммит. Формат
|
|
5
|
+
* заголовков задаётся регуляркой в конфиге; именованные группы `id` (короткий
|
|
6
|
+
* номер для `§N`) и `title` необязательны — без них номер берётся первым словом. */
|
|
7
|
+
export function parseSections(text, pattern) {
|
|
8
|
+
const re = new RegExp(pattern);
|
|
9
|
+
const list = [];
|
|
10
|
+
let cur = null;
|
|
11
|
+
text.split('\n').forEach((line) => {
|
|
12
|
+
const m = re.exec(line);
|
|
13
|
+
if (m) {
|
|
14
|
+
const g = m.groups || {};
|
|
15
|
+
const head = line.replace(/^#+\s*/, '').trim();
|
|
16
|
+
const id = g.id === undefined ? head.split(/\s+/)[0] : g.id;
|
|
17
|
+
cur = { id: id, head: head, body: '' };
|
|
18
|
+
list.push(cur);
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
if (cur) cur.body += line + '\n';
|
|
22
|
+
});
|
|
23
|
+
return list;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/* Раздел, к которому относится коммит: сначала вновь заведённый, иначе —
|
|
27
|
+
* последний из отредактированных (журналы ведутся по возрастанию, так что правка
|
|
28
|
+
* почти всегда касается хвоста). */
|
|
29
|
+
export function touchedSection(prevText, nowText, pattern) {
|
|
30
|
+
const prev = new Map(parseSections(prevText, pattern).map((s) => [s.head, s.body]));
|
|
31
|
+
const now = parseSections(nowText, pattern);
|
|
32
|
+
const added = now.filter((s) => !prev.has(s.head));
|
|
33
|
+
if (added.length > 0) {
|
|
34
|
+
const s = added[added.length - 1];
|
|
35
|
+
return { id: s.id, head: s.head, added: true };
|
|
36
|
+
}
|
|
37
|
+
const edited = now.filter((s) => prev.get(s.head) !== s.body);
|
|
38
|
+
if (edited.length > 0) {
|
|
39
|
+
const s = edited[edited.length - 1];
|
|
40
|
+
return { id: s.id, head: s.head, added: false };
|
|
41
|
+
}
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/* Якорь — как у GitHub (github-slugger): пунктуация снимается целиком, каждый
|
|
46
|
+
* пробел становится дефисом. Отсюда «13 — эфф» → «13--эфф» (два дефиса: тире
|
|
47
|
+
* выброшено, пробелы остались каждый своим). Дефис, подчёркивание и буквы
|
|
48
|
+
* (включая кириллицу) сохраняются, остальные пробельные символы — нет. */
|
|
49
|
+
export function anchor(head) {
|
|
50
|
+
return head.toLowerCase().replace(/[^\p{L}\p{N} _-]/gu, '').replace(/ /g, '-');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function sectionLink(section, cfg) {
|
|
54
|
+
if (!section || !cfg.journal) return null;
|
|
55
|
+
const what = cfg.journal.anchor === 'title' ? section.id : section.head;
|
|
56
|
+
return cfg.journal.url + '#' + anchor(what);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/* Ссылка строки: на раздел журнала, если раздел есть, иначе на сам коммит (шаблон
|
|
60
|
+
* из настроек). Одно место для артефакта и для данных страницы: адрес раздела —
|
|
61
|
+
* правило GitHub, и второе его воплощение разъехалось бы с первым. */
|
|
62
|
+
export function rowHref(section, sha, cfg) {
|
|
63
|
+
if (section) return sectionLink(section, cfg);
|
|
64
|
+
if (!cfg.links.commitUrl) return null;
|
|
65
|
+
return cfg.links.commitUrl.replace(/\{sha\}/g, sha).replace(/\{short\}/g, sha.slice(0, 7));
|
|
66
|
+
}
|
package/src/locales.js
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/* Тексты отчёта: заголовки, подписи и служебные слова на языке проекта. Это
|
|
2
|
+
* словарь, а не рендер: механика таблицы от языка не зависит. */
|
|
3
|
+
|
|
4
|
+
/* Тексты артефакта: колонка, метрики и служебные слова. Механика (заголовки
|
|
5
|
+
* таблицы, клетки, дельты) от локали не зависит, поэтому перевод — это словарь,
|
|
6
|
+
* а не отдельный рендер. */
|
|
7
|
+
export const LOCALES = {
|
|
8
|
+
ru: {
|
|
9
|
+
html: 'ru',
|
|
10
|
+
heading: 'Объём файлов по коммитам',
|
|
11
|
+
commit: 'Коммит',
|
|
12
|
+
total: 'Общий объём',
|
|
13
|
+
now: 'сейчас',
|
|
14
|
+
categories: { code: 'Код', docs: 'Документация', chore: 'Служебные', assets: 'Ресурсы' },
|
|
15
|
+
/* Тексты страницы отчёта: в артефакт они не попадают, поэтому меняются
|
|
16
|
+
* независимо от его заморожённых байтов. */
|
|
17
|
+
page: {
|
|
18
|
+
metrics: 'Метрики',
|
|
19
|
+
files: 'Файлы',
|
|
20
|
+
dir: 'все файлы папки {name} ({n})',
|
|
21
|
+
all: 'все',
|
|
22
|
+
sub: '{tool} {version} · рядом со страницей — статическая таблица: {artifact}',
|
|
23
|
+
legendUp: 'рост',
|
|
24
|
+
legendDown: 'спад',
|
|
25
|
+
legendSame: 'пустая клетка — не менялось',
|
|
26
|
+
legendMiss: '«—» — файла в этой ревизии ещё нет',
|
|
27
|
+
legendApprox: 'подчёркнутое число — приближённое: способ назван под метриками',
|
|
28
|
+
/* Слова точности: подпись метрики говорит про худшее в колонке, а клетки —
|
|
29
|
+
* про своё число. Одно слово на подпись и на подсказку клетки, чтобы не
|
|
30
|
+
* разошлось и не звучало по-разному. */
|
|
31
|
+
exact: 'точное число',
|
|
32
|
+
approximate: 'приближение',
|
|
33
|
+
approximateCell: 'приближённое число: ',
|
|
34
|
+
panelMethod: 'способ:',
|
|
35
|
+
emptyMetrics: 'Ни одна метрика не выбрана — таблицу не из чего собрать. '
|
|
36
|
+
+ 'Включите хотя бы одну в панели выше: без метрик нет ни одной колонки с числом.',
|
|
37
|
+
noFiles: 'Ни один файл не выбран: в таблице остался только общий объём. '
|
|
38
|
+
+ 'Включите файл в панели выше, чтобы увидеть его колонки.',
|
|
39
|
+
linkForeign: 'Ссылка собрана в другом отчёте и не применена: показано то, что '
|
|
40
|
+
+ 'выбрано в этом браузере. Адрес не тронут.',
|
|
41
|
+
linkBroken: 'В адресе испорченный выбор — прочитать его не удалось. Показано то, '
|
|
42
|
+
+ 'что выбрано в этом браузере.',
|
|
43
|
+
linkExtra: 'Часть ссылки пропущена: в этом отчёте нет {n} названных имён. '
|
|
44
|
+
+ 'Остальное применено.',
|
|
45
|
+
note: 'Абсолютные размеры стоят один раз — в строке «{now}». Строк: {rows}: коммиты без '
|
|
46
|
+
+ 'изменения объёма и коммиты, тронувшие только выключенные файлы, строк не получают. '
|
|
47
|
+
+ 'Собрано из истории git: {command}.'
|
|
48
|
+
},
|
|
49
|
+
note: {
|
|
50
|
+
intro: 'Строка — коммит, колонка — файл. ',
|
|
51
|
+
metricSep: ', ',
|
|
52
|
+
metricEnd: '. ',
|
|
53
|
+
numbers: 'В клетке — изменение к предыдущему коммиту (он идёт строкой ниже): '
|
|
54
|
+
+ '<span class="up">рост</span> / <span class="down">спад</span>, пустая клетка — '
|
|
55
|
+
+ 'не менялось, <code>—</code> — файла в той ревизии ещё нет. Абсолютные размеры '
|
|
56
|
+
+ 'стоят один раз: в верхней строке «{now}» — текущий размер. ',
|
|
57
|
+
journal: 'Описание ведёт в раздел {journal}: <code>§N</code> — раздел заведён этим коммитом, '
|
|
58
|
+
+ '<code>§N*</code> — коммит правил уже существующий раздел, <code>—</code> — {journal} не трогал. ',
|
|
59
|
+
noJournal: 'Описание — коммит; раздел журнала задаётся в конфиге (journal). ',
|
|
60
|
+
columns: 'Колонки таблицы: {columns}. ',
|
|
61
|
+
rows: 'Строк: {rows}. Собрано из истории git: <code>{command}</code>. '
|
|
62
|
+
+ 'Строк не получают коммиты, тронувшие только этот файл, и коммиты без изменения объёма.',
|
|
63
|
+
noJournalMark: 'этот коммит {journal} не трогал'
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
en: {
|
|
67
|
+
html: 'en',
|
|
68
|
+
heading: 'File size by commit',
|
|
69
|
+
commit: 'Commit',
|
|
70
|
+
total: 'Total',
|
|
71
|
+
now: 'now',
|
|
72
|
+
categories: { code: 'Code', docs: 'Documentation', chore: 'Chores', assets: 'Assets' },
|
|
73
|
+
page: {
|
|
74
|
+
metrics: 'Metrics',
|
|
75
|
+
files: 'Files',
|
|
76
|
+
dir: 'all files in “{name}” ({n})',
|
|
77
|
+
all: 'all',
|
|
78
|
+
sub: '{tool} {version} · a static table sits next to this page: {artifact}',
|
|
79
|
+
legendUp: 'growth',
|
|
80
|
+
legendDown: 'fall',
|
|
81
|
+
legendSame: 'an empty cell — no change',
|
|
82
|
+
legendMiss: '“—” — the file does not exist in that revision yet',
|
|
83
|
+
legendApprox: 'an underlined number is approximate: its way of measuring is named under the metrics',
|
|
84
|
+
exact: 'an exact number',
|
|
85
|
+
approximate: 'an approximation',
|
|
86
|
+
approximateCell: 'an approximate number: ',
|
|
87
|
+
panelMethod: 'method:',
|
|
88
|
+
emptyMetrics: 'No metric is selected — there is nothing to build the table from. '
|
|
89
|
+
+ 'Turn on at least one above: without metrics there is no column holding a number.',
|
|
90
|
+
noFiles: 'No file is selected: the table shows the total only. '
|
|
91
|
+
+ 'Turn on a file above to see its columns.',
|
|
92
|
+
linkForeign: 'The link was built for another report, so it is not applied: you '
|
|
93
|
+
+ 'see what this browser has. The address is left as it is.',
|
|
94
|
+
linkBroken: 'The choice in the address is unreadable. You see what this browser '
|
|
95
|
+
+ 'has.',
|
|
96
|
+
linkExtra: 'Part of the link is skipped: this report has none of the {n} names '
|
|
97
|
+
+ 'it mentions. The rest is applied.',
|
|
98
|
+
note: 'Absolute sizes appear once — in the “{now}” row. Rows: {rows}: commits that change '
|
|
99
|
+
+ 'no number, and commits touching only the disabled files, get no row. '
|
|
100
|
+
+ 'Collected from git history: {command}.'
|
|
101
|
+
},
|
|
102
|
+
note: {
|
|
103
|
+
intro: 'A row is a commit, a column is a file. ',
|
|
104
|
+
metricSep: ', ',
|
|
105
|
+
metricEnd: '. ',
|
|
106
|
+
numbers: 'A cell holds the change against the previous commit (the row below): '
|
|
107
|
+
+ '<span class="up">growth</span> / <span class="down">fall</span>, an empty cell — '
|
|
108
|
+
+ 'no change, <code>—</code> — the file does not exist in that revision yet. Absolute '
|
|
109
|
+
+ 'sizes appear once, in the top “{now}” row. ',
|
|
110
|
+
journal: 'The description links to a {journal} section: <code>§N</code> — the section was opened '
|
|
111
|
+
+ 'by this commit, <code>§N*</code> — the commit edited an existing section, '
|
|
112
|
+
+ '<code>—</code> — {journal} was not touched. ',
|
|
113
|
+
noJournal: 'The description is the commit itself; a journal is set by the `journal` config key. ',
|
|
114
|
+
columns: 'Columns: {columns}. ',
|
|
115
|
+
rows: 'Rows: {rows}. Collected from git history: <code>{command}</code>. '
|
|
116
|
+
+ 'Commits touching only this file get no row, as do commits changing no number.',
|
|
117
|
+
noJournalMark: 'this commit did not touch {journal}'
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
export function fill(text, vars) {
|
|
123
|
+
return text.replace(/\{(\w+)\}/g, (_m, k) => (vars[k] === undefined ? '' : String(vars[k])));
|
|
124
|
+
}
|