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