@vernikr/size-report 2.4.0 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +989 -1081
  2. package/bin/postinstall.js +17 -18
  3. package/bin/size.js +2 -2
  4. package/package.json +3 -4
  5. package/src/args.js +72 -72
  6. package/src/artifact.js +14 -14
  7. package/src/check.js +41 -42
  8. package/src/cli.js +26 -29
  9. package/src/config.js +87 -91
  10. package/src/css.js +14 -14
  11. package/src/data.js +26 -50
  12. package/src/derived.js +31 -35
  13. package/src/doctor.js +95 -99
  14. package/src/explain.js +46 -47
  15. package/src/git.js +66 -71
  16. package/src/history.js +74 -83
  17. package/src/hook.js +130 -149
  18. package/src/init.js +37 -37
  19. package/src/journal.js +17 -15
  20. package/src/locales.js +31 -22
  21. package/src/metrics.js +72 -89
  22. package/src/minify.js +28 -27
  23. package/src/modes.js +57 -60
  24. package/src/optional.js +13 -11
  25. package/src/page/app.css +76 -94
  26. package/src/page/app.js +124 -80
  27. package/src/page/build.js +193 -50
  28. package/src/page/dom.js +8 -9
  29. package/src/page/panel.js +157 -69
  30. package/src/page/payload.js +168 -0
  31. package/src/page/state.js +144 -104
  32. package/src/page/table.js +270 -86
  33. package/src/parse-worker.js +10 -10
  34. package/src/parse.js +43 -45
  35. package/src/project.js +100 -104
  36. package/src/refusal.js +75 -76
  37. package/src/size-table.js +41 -76
  38. package/src/strip/forms.js +5 -5
  39. package/src/strip/guard.js +28 -28
  40. package/src/strip/js.js +27 -27
  41. package/src/strip.js +17 -21
  42. package/src/table.css +54 -19
  43. package/src/tokens.js +27 -27
  44. package/src/tool.js +10 -11
  45. package/templates/README.md +71 -77
  46. package/templates/ci.yml +33 -33
  47. package/templates/size-report.config.json +3 -3
  48. package/CHANGELOG.md +0 -690
package/src/project.js CHANGED
@@ -4,46 +4,44 @@ import { execFileSync } from 'child_process';
4
4
  import { MAX_BUF, git, gitArgv, gitEnv, gitTry, readHistory } from './git.js';
5
5
  import { cliCommand, invocation } from './refusal.js';
6
6
 
7
- /* Настройки, выведенные из самого проекта: что считать, где журнал, куда писать и
8
- * что колонкой быть не может.
7
+ /* Settings derived from the project itself: what to measure, where the journal is, where to write,
8
+ * and what cannot be a column.
9
9
  *
10
- * Зачем отдельно от `src/config.js`. Тот читает **готовые** настройки и только
11
- * затем их проверяет, а этот смотрит на проект впервые и почти всё о нём
12
- * догадывается та же работа, что была у черновика (`--init`). Отсюда две роли
13
- * одного вывода: без файла настроек он и есть настройки (проект работает сразу,
14
- * ничего не заводив), а с `--init` он же ложится файлом, который дальше правят.
15
- * Второго вывода «как угадать проект» в пакете нет.
10
+ * Why separate from `src/config.js`: that one reads **ready** settings and checks them, while this
11
+ * one looks at the project for the first time and guesses about almost everything — the same work
12
+ * the draft used to do (`--init`). Hence the two roles of one output: with no settings file it *is*
13
+ * the settings (the project works at once, having set up nothing), and under `--init` the very same
14
+ * output is written to a file, which is edited afterwards. There is no second way to guess a project
15
+ * in this package.
16
16
  *
17
- * Два правила, из которых выведено всё остальное.
17
+ * Two rules, from which everything else follows.
18
18
  *
19
- * **Колонка это файл.** Состояние движка хранит на колонку один путь, а список
20
- * путей колонки её переименования ревизии берётся тот, который в ней есть).
21
- * Поэтому «папка целиком» колонкой быть не может, и профиль называет файлы, а не
22
- * группы путей. Колонкой идёт **каждый** отслеживаемый файл, который можно
23
- * измерить: выборка из проекта выдавала объём выборки за объём проекта.
19
+ * **A column is a file.** The report names a column by one path — the first of its list — while the
20
+ * whole list is the column's renames, and a revision resolves whichever of them is present there. So
21
+ * "a whole directory" cannot be a column, and the profile names files rather than groups of paths. A
22
+ * column is **every** tracked file that can be measured: a sample of the project passed the volume of
23
+ * the sample off as the volume of the project.
24
24
  *
25
- * **Профиль обязан проходить ту же проверку, которой его встретит первый запуск**
26
- * (BLOCKERS §N2, REFACTOR R-0.4): путь, не ставший колонкой и не объявленный
27
- * исключением, это код 1 на первом же прогоне. Поэтому `skip` называет и то,
28
- * что колонкой быть не может (сам отчёт, замки зависимостей, собранное, незнакомый
29
- * формат, слишком крупный файл), и то, чего git не отслеживает: так первый `check`
30
- * зелёный, а про каждый файл вне отчёта сказано, почему его там нет.
25
+ * **The profile has to pass the very check its first run will apply**: a path that became neither a
26
+ * column nor a declared exception is code 1 on that first run. So `skip` names both what cannot be a
27
+ * column (the report itself, dependency locks, built output, an unknown format, a file too large) and
28
+ * what git does not track: that way the first `check` is green, and every file outside the report has
29
+ * a stated reason for standing there.
31
30
  */
32
31
 
33
- /* Что вообще берётся колонкой: текстовые формы, с которыми движок умеет работать.
34
- * Файл без знакомого расширения (`LICENSE`, `.gitignore`) колонкой не становится и
35
- * называется исключением. */
32
+ /* What may become a column at all: the text forms the engine can work with. A file without a known
33
+ * extension (`LICENSE`, `.gitignore`) never becomes one and is named as an exception. */
36
34
  const KNOWN_EXTS = ['.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx', '.html', '.css', '.scss',
37
35
  '.json', '.yaml', '.yml', '.toml', '.md', '.txt', '.py', '.rb', '.go', '.rs', '.sh'];
38
36
 
39
37
  const JOURNALS = ['WORKLOG.md', 'CHANGELOG.md', 'CHANGES.md', 'HISTORY.md'];
40
38
 
41
- /* Порог по размеру предохранитель: очень крупный файл в истории обычно собран или
42
- * сгенерирован, и в отчёте он перевесил бы весь проект. */
39
+ /* The size threshold is a guard: a very large file is usually built or generated, and in the report
40
+ * it would outweigh the whole project. */
43
41
  const MAX_BYTES = 512 * 1024;
44
42
 
45
- /* Индекс путь и размер: `ls-files -s` даёт объекты, размер спрашивается у них
46
- * одной пачкой (`cat-file --batch-check`), а не чтением содержимого. */
43
+ /* The index gives paths and sizes: `ls-files -s` yields the objects, and their sizes are asked for in
44
+ * one batch (`cat-file --batch-check`) rather than by reading the content. */
47
45
  function indexFiles(root) {
48
46
  const listed = git(root, ['ls-files', '-s']).split('\n').filter((l) => l !== '');
49
47
  const sizes = new Map();
@@ -60,25 +58,25 @@ function indexFiles(root) {
60
58
  return listed.map((line) => ({ p: line.split('\t')[1], size: sizes.get(line.split(/\s+/)[1]) || 0 }));
61
59
  }
62
60
 
63
- /* История объединение путей всех коммитов, тем же чтением, которым пользуется
64
- * полнота (`readHistory`): иначе профиль и `check` считали бы разные истории. Файл,
65
- * живущий только в истории, в отчёте просто пуст на HEAD — это не потеря, а факт, и
66
- * колонкой (или исключением) он быть обязан. */
61
+ /* The history is the union of the paths of every commit, read the same way coverage reads it
62
+ * (`readHistory`): otherwise the profile and `check` would count different histories. A file living
63
+ * only in the history is simply empty at HEAD — a fact rather than a loss, and it has to be a column
64
+ * (or an exception). */
67
65
  function historyPaths(root) {
68
66
  const seen = new Set();
69
67
  try {
70
68
  readHistory(root).forEach((c) => c.files.forEach((f) => seen.add(f)));
71
69
  } catch (e) {
72
- // У репозитория без коммитов истории нет вовсе: `git log` там отказывает, и
73
- // это состояние проекта, а не дефект инструмента — `--init` обязан работать и
74
- // в нём (первый запуск бывает и до первого коммита).
70
+ // A repository without commits has no history at all: `git log` refuses there, and that is a state
71
+ // of the project rather than a defect of the tool — `--init` has to work in one too (a first run
72
+ // may come before the first commit).
75
73
  if (/does not have any commits/.test(String(e.stderr))) return seen;
76
74
  throw e;
77
75
  }
78
76
  return seen;
79
77
  }
80
78
 
81
- // Дерево и история одним списком: колонкой становится всё, что тронула история.
79
+ // The tree and the history in one list: everything the history touched may become a column.
82
80
  function allPaths(root) {
83
81
  const files = indexFiles(root);
84
82
  const known = new Set(files.map((f) => f.p));
@@ -88,23 +86,22 @@ function allPaths(root) {
88
86
  return files;
89
87
  }
90
88
 
91
- // Наличие каталога решает, куда ляжет отчёт: рядом с доками или в корне.
89
+ // Whether the project has this path at all; nothing else is asked of this helper.
92
90
  function exists(root, p) {
93
91
  return fs.existsSync(path.join(root, p));
94
92
  }
95
93
 
96
- /* Куда ложится отчётодно на пакет: `docs/size-report.html`, и каталог создаётся
97
- * сам. Прежняя развилка («в доки, если они есть, иначе в корень») убрана по замеру:
98
- * в свежем проекте каталога `docs` ещё нет, и отчёт оказывался в корнето есть
99
- * ни в том месте, где его ищут, ни в том, куда его хочет положить человек,
100
- * который ставит пакет ради отчёта. Каталог здесь не признак проекта, а часть
101
- * адреса; создавать его — работа того, кто пишет файл (`writeFileEnsured`). */
94
+ /* Where the report lands one answer for the package: `docs/size-report.html`, with the directory
95
+ * created by the writer (`writeFileEnsured`). A fork on the existence of `docs/` was measured and
96
+ * dropped: a fresh project has no such directory, so the report ended up in the root neither where
97
+ * it is looked for nor where a person installing the package for the sake of the report would put it.
98
+ * The directory is part of the address here, not a sign of a project. */
102
99
  function outputOf() {
103
100
  return 'docs/size-report.html';
104
101
  }
105
102
 
106
- /* Менеджер пакетов по lock-файлу, а не догадкой: команда обязана существовать
107
- * в чужом проекте. Один на два места (команду починки и подсказку `--init`). */
103
+ /* The package manager comes from a lock file rather than a guess: the command has to exist in
104
+ * someone else's project. One answer for two places (the fix command and the `--init` prompt). */
108
105
  export function packageManager(root) {
109
106
  if (exists(root, 'pnpm-lock.yaml')) return 'pnpm';
110
107
  return exists(root, 'yarn.lock') ? 'yarn' : 'npm';
@@ -118,12 +115,12 @@ function readJson(file) {
118
115
  }
119
116
  }
120
117
 
121
- /* Подпись артефакта и отказы цитируют эту команду, поэтому она обязана работать
122
- * здесь и сейчас: свой скрипт только если он объявлен (иначе `npm run sizes`
123
- * отвечает «нет такого скрипта», а совет, который не работает, худшая подсказка),
124
- * иначе установленный пакет внутри проекта. Имени пакета в команде нет намеренно:
125
- * `npx <имя>` без установленного пакета уходит в реестр и запускает чужой код
126
- * (REFACTOR R-4.21). */
118
+ /* The artifact's note and the refusals quote this command, so it has to work right here and now: the
119
+ * project's own script only if it is declared (or `npm run sizes` answers "no such script", and
120
+ * advice that does not work is the worst kind), otherwise the installed package inside the project.
121
+ * The package name is deliberately absent from the command: with no package installed it would send the
122
+ * reader to the registry, which serves a revision the project never pinned, while a path inside the
123
+ * project refuses on the spot. */
127
124
  function fixCommandOf(root) {
128
125
  const pkg = readJson(path.join(root, 'package.json')) || {};
129
126
  const script = pkg.scripts === undefined ? '' : pkg.scripts.sizes;
@@ -131,10 +128,10 @@ function fixCommandOf(root) {
131
128
  return packageManager(root) + ' run sizes';
132
129
  }
133
130
 
134
- /* Ссылка на коммит из адреса origin: два хозяина собирают её одним правилом из
135
- * владельца и пути к репозиторию (у GitLab в пути бывают подгруппы, поэтому путь
136
- * берётся целиком), а всякий третий хозяин догадка, которая ведёт не туда.
137
- * Remote нет или он не тот ссылок нет: пустой шаблон строка просто не несёт. */
131
+ /* The commit link comes from the origin address: the two hosts are covered by one rule that builds
132
+ * the address from the host and the repository path (GitLab nests subgroups, hence the whole path),
133
+ * while any third host would be a guess leading somewhere else. No remote, or the wrong one — no
134
+ * links: an empty template is simply not spelled out. */
138
135
  const REMOTE_RE = /^\S+?(?:@|\/\/)(?:[^@/]*@)?(github\.com|gitlab\.com)[/:]+((?:[^/\s]+\/)*[^/\s]+?)(?:\.git)?$/;
139
136
 
140
137
  function commitUrlOf(root) {
@@ -148,9 +145,9 @@ function journalOf(root) {
148
145
  return JOURNALS.find((p) => exists(root, p)) || '';
149
146
  }
150
147
 
151
- /* Колонкой не становится: сам отчёт (его размер зависит от числа строк, то есть от
152
- * себя), замки зависимостей (их размер про чужие пакеты), карты и собранное (их
153
- * сделала сборка, а не человек) и всё, чего движок не умеет читать. */
148
+ /* What never becomes a column: the report itself (its size depends on the number of rows, that is,
149
+ * on itself), dependency locks (their size is about someone else's packages), maps and built output
150
+ * (made by a build rather than by a person), and anything the engine cannot read. */
154
151
  function generated(p, output) {
155
152
  const ext = path.extname(p).toLowerCase();
156
153
  return p === output || KNOWN_EXTS.indexOf(ext) < 0
@@ -158,8 +155,8 @@ function generated(p, output) {
158
155
  || /\.min\./.test(p) || /\.map$/.test(p);
159
156
  }
160
157
 
161
- /* Метка колонки имя файла; совпадение имён в разных папках разводится путём, а
162
- * если и путь занят числом. Повтор метки проверка настроек не пропустит. */
158
+ /* A column's label is the file name; a clash of names in different directories is split by the path
159
+ * and, if that is taken too, by a number. The settings check lets no repeated label through. */
163
160
  function labelFor(used, p) {
164
161
  const candidates = [path.basename(p), p];
165
162
  const free = candidates.find((name) => used.indexOf(name) < 0);
@@ -169,17 +166,16 @@ function labelFor(used, p) {
169
166
  return p + ' (' + n + ')';
170
167
  }
171
168
 
172
- /* Выбор колонок: колонкой идёт всё, что можно измерить,то есть каждый файл
173
- * проекта, а не выборка из него. Выборка (прежде крупнейшие по одному от каждого
174
- * расширения, всего не больше двенадцати) врала дважды: отчёт называл объём файлов,
175
- * которых в нём не было, а читатель принимал это за объём проекта. Границы остались
176
- * только у того, что колонкой **не может** быть: сам отчёт, замки, собранное,
177
- * незнакомый формат и слишком крупный файл (`generated` и `MAX_BYTES`) они уходят
178
- * в `skip` и называются там.
169
+ /* How columns are chosen: everything measurable becomes onethat is, every file of the project
170
+ * rather than a sample of it. The sample (formerly the largest file per extension, at most twelve)
171
+ * lied twice: the report named the volume of files it did not contain, and a reader took that for the
172
+ * volume of the project. The only things left out are those that **cannot** be a column: the report
173
+ * itself, locks, built output, an unknown format, and a file too large (`generated` and `MAX_BYTES`) —
174
+ * they go to `skip` and are named there.
179
175
  *
180
- * Порядок колонок по кругу от каждого расширения, от крупнейшего к мелкому:
181
- * сперва то, чего в проекте больше всего, и в каждом расширении крупное раньше
182
- * мелкого. Это порядок чтения отчёта, а не вес: числа от него не зависят. */
176
+ * The order of columns is a ring over the extensions, largest first: what the project has most of comes
177
+ * first, and within an extension the large comes before the small. That is the reading order of the
178
+ * report rather than weight numbers do not depend on it. */
183
179
  function columnsOf(files, journal) {
184
180
  const byExt = new Map();
185
181
  files.forEach((f) => {
@@ -211,23 +207,23 @@ export function projectConfig(root) {
211
207
  const output = outputOf(root);
212
208
  const journal = journalOf(root);
213
209
  const files = allPaths(root);
214
- /* Колонками идёт отслеживаемое git: файл, которого на HEAD нет, измерять нечего
215
- * отчёте он был бы пуст во всех строках). Путь, живущий только в истории,
216
- * поэтому исключение, а не колонка; сам список путей всё равно полный. */
210
+ /* Only what git tracks becomes a column: a file absent at HEAD has nothing to measure (it would be
211
+ * empty in every row of the report). A path living only in the history is therefore an exception
212
+ * rather than a column, while the list of paths stays complete either way. */
217
213
  const tracked = new Set(indexFiles(root).map((f) => f.p));
218
214
  const readable = files.filter((f) => tracked.has(f.p) && !generated(f.p, output) && f.size <= MAX_BYTES);
219
215
  const columns = columnsOf(readable, journal);
220
216
  const taken = new Set(columns.reduce((all, c) => all.concat(c.paths), []));
221
- // Язык, заголовок, порядок строк и выключатель хука не выводятся ни из чего:
222
- // они берутся из умолчаний (`DEFAULT_CONFIG`), а не выдаются за вывод из проекта.
217
+ // The language, the title, the row order and the hook switch are derived from nothing: they come
218
+ // from the defaults (`DEFAULT_CONFIG`) instead of being passed off as derived from the project.
223
219
  return {
224
220
  output: output,
225
221
  fixCommand: fixCommandOf(root),
226
222
  metrics: ['raw', 'min', 'tok'],
227
- // Настоящее сжатие и настоящий словарь, а не приближения: новый проект не должен
228
- // начинать с чисел, которые честны наполовину. Без необязательной зависимости
229
- // метрика отступает к другому счёту и прогон возвращает код 4 — это сказано
230
- // подписью метрики, а не умолчанием.
223
+ // Real compression and a real dictionary rather than their cheaper substitutes: a new project must
224
+ // not start with numbers counted another way. Without the optional dependency the metric falls back
225
+ // to another count and the run returns code 4 — said by the metric label rather than left to a
226
+ // default.
231
227
  minify: { engine: 'esbuild' },
232
228
  tokens: { family: 'openai', encoding: 'o200k_base' },
233
229
  columns: columns,
@@ -238,30 +234,30 @@ export function projectConfig(root) {
238
234
  anchor: 'heading'
239
235
  },
240
236
  links: { commitUrl: commitUrlOf(root) },
241
- // Исключения всё, что колонкой не стало: то, что ею быть не может, и то, чего
242
- // git не отслеживает (путь из одной истории). Так первый запуск полон, а
243
- // названное в `skip` это ответ на вопрос «почему его нет в отчёте».
237
+ // Exceptions are everything that did not become a column: what cannot be one, and what git does
238
+ // not track (a path from the history alone). That way the first run is complete, and what `skip`
239
+ // names is the answer to "why is it not in the report".
244
240
  skip: [output].concat(files.filter((f) => !taken.has(f.p)).map((f) => f.p))
245
241
  .filter((p, i, all) => all.indexOf(p) === i)
246
242
  };
247
243
  }
248
244
 
249
- /* Путь самого отчёта в проекте ли он назван (не пустой, не абсолютный и не с
250
- * выходом наверх): только такой путь дереву можно назвать листом. */
245
+ /* Whether the report's own path lies inside the project (non-empty, not absolute, not climbing out):
246
+ * only such a path can be named as a leaf of the tree. */
251
247
  const ownPath = (output) => output !== '' && !path.isAbsolute(output) && output.indexOf('..') !== 0;
252
248
 
253
- /* Каталог проекта для страницы: все пути, которые видит git, и причина у тех из
254
- * них, что колонкой не стали (`null` — стал: страница по этому знаку решает, лист
255
- * галочка или подпись). Дерево страницы дерево проекта, поэтому пути берутся
256
- * из индекса, а не из колонок; причины **теми же** правилами, по которым профиль
257
- * выбирает колонки (`generated` и предел размера), иначе подсказка говорила бы
258
- * одно, а выбор колонок делал другое. Колонка, чьего файла на HEAD уже нет, в
259
- * каталог не попадает: в индексе её нет, а на месте в дереве её держит страница.
249
+ /* The project catalogue for the page: every path git sees, with a reason for those that did not become
250
+ * columns (`null` means it did the page reads this mark to decide between a checkbox and a label).
251
+ * The page's tree is the project's tree, so the paths come from the index rather than from the columns,
252
+ * and the reasons follow **the same** rules the profile uses to pick columns (`generated` and the size
253
+ * limit) otherwise the hint would say one thing while the choice of columns did another. A column
254
+ * whose file is gone from HEAD does not enter the catalogue: the index does not hold it, and the page
255
+ * keeps its place in the tree.
260
256
  *
261
- * Сам отчёт в каталоге есть всегда и пока он ещё не собран, и когда он вне git:
262
- * его отслеживаемость свойство момента, а не проекта. Зависеть от неё отчёт не
263
- * должен: иначе первая же пересборка в свежем клоне даёт другие байты (отчёт в
264
- * каталоге появился), и хук коммитит его второй раз на пустом месте. */
257
+ * The report itself is always in the catalogue, both before it is first built and while it is not
258
+ * tracked: whether it is tracked is a property of the moment rather than of the project. The report
259
+ * must not depend on it otherwise the first rebuild in a fresh clone yields different bytes (the
260
+ * report appeared in the catalogue) and the hook commits it a second time out of nowhere. */
265
261
  export function projectTree(root, output, measured) {
266
262
  const files = indexFiles(root);
267
263
  if (ownPath(output) && files.every((f) => f.p !== output)) files.push({ p: output, size: 0 });
@@ -272,9 +268,9 @@ export function projectTree(root, output, measured) {
272
268
  }));
273
269
  }
274
270
 
275
- /* Имя черновика (`sniffColumns`) осталось публичным: на него опираются те, кто звал
276
- * вывод колонок (`test/api.test.js` держит список имён), и форма ответа та же
277
- * колонки, знакомые расширения проекта и сколько путей всего. */
271
+ /* The draft's name (`sniffColumns`) stays public: the callers of the column output rely on it
272
+ * (`test/api.test.js` holds the list of names), and the shape of the answer is the same columns,
273
+ * the extensions the project knows, and how many paths there are in total. */
278
274
  export function sniffColumns(root) {
279
275
  const files = allPaths(root);
280
276
  const exts = [...new Set(files.map((f) => path.extname(f.p).toLowerCase()))]
@@ -282,20 +278,20 @@ export function sniffColumns(root) {
282
278
  return { columns: projectConfig(root).columns, exts: exts, total: files.length };
283
279
  }
284
280
 
285
- /* Что сказать человеку, когда настроек нет и работать пришлось на выведенных: одна
286
- * строка про то, что вышло, и одна про то, чем это закрепить. Текст тем же
287
- * списком, что и находка `doctor`: два ответа об одном не должны разойтись словами. */
281
+ /* What to tell a person when there is no settings file and the settings had to be derived: one line
282
+ * about what came out, one about how to pin it. The text comes from the same list as the `doctor`
283
+ * finding: two answers about one thing must not drift apart in words. */
288
284
  export function derivedSummary(cfg) {
289
285
  const labels = cfg.columns.map((c) => c.label);
290
- return 'настройки выведены из проекта (файла нет): колонок ' + cfg.columns.length
286
+ return 'settings derived from the project (no file): columns ' + cfg.columns.length
291
287
  + ' (' + labels.slice(0, 5).join(', ') + (labels.length > 5 ? ', …' : '') + '),'
292
- + ' исключено путей ' + cfg.skip.length;
288
+ + ' paths skipped ' + cfg.skip.length;
293
289
  }
294
290
 
295
291
  export function derivedLines(cfg) {
296
292
  return [
297
293
  '! ' + derivedSummary(cfg),
298
- ' закрепить их файлом (дальше правьте как удобно; иначе состав колонок меняется'
299
- + ' от прогона к прогону): ' + cliCommand('--init')
294
+ ' pin them with a file of their own (then edit it as you like; otherwise the set of columns'
295
+ + ' changes from run to run): ' + cliCommand('--init')
300
296
  ];
301
297
  }