@vernikr/size-report 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/css.js ADDED
@@ -0,0 +1,37 @@
1
+ import fs from 'node:fs';
2
+
3
+ /* Оформление отчёта — обычные `.css` рядом с кодом, а не строки внутри модулей:
4
+ * то же правило, что и у программы страницы (`src/page/app.js`), — исходник видит
5
+ * редактор, а не только шаблонная строка. Читаются они с диска относительно
6
+ * своего места, поэтому работают и у того, кто поставил пакет.
7
+ *
8
+ * Наборов три, и у каждого своя роль:
9
+ *
10
+ * 1. `table.css` — **общая часть таблицы**: геометрия клеток, липкие шапка и
11
+ * колонка коммита, подпись коммита, цвета дельт. Её берут оба вывода, поэтому
12
+ * расхождению в оформлении самой таблицы взяться неоткуда. Файл — дословный
13
+ * текст этой части, без шапки: он попадает в артефакт побайтово, а артефакт
14
+ * заморожен эталоном паритета, и любая добавленная строка меняла бы
15
+ * зафиксированный вывод проекта-потребителя.
16
+ * 2. `artifact.css` — оформление статического артефакта **сверх таблицы**: холст,
17
+ * заголовок, примечание.
18
+ * 3. `page/app.css` — оформление страницы **сверх таблицы**: холст, панель
19
+ * выбора, легенда, состояния пустоты и адаптации под узкое окно.
20
+ *
21
+ * Соглашение о цвете дельт задано один раз — в `table.css`: `.up` зелёный, `.down`
22
+ * красный (рост — «больше логики», а не тревога). Артефакт заморожен побайтово, и
23
+ * менять его цвета нельзя, не пересняв эталон; страница следует тому же
24
+ * соглашению, потому что два отчёта одной истории не могут показывать рост
25
+ * разными цветами. Смена соглашения — две строки в `table.css` и пересъёмка
26
+ * эталона артефакта; отдельного места у цвета дельт нет намеренно.
27
+ *
28
+ * Путь у `readCss` — от каталога `src/`: так его видит движок, где бы он ни лежал.
29
+ */
30
+
31
+ export const TABLE_CSS = readCss('./table.css');
32
+ export const ARTIFACT_CSS = readCss('./artifact.css');
33
+ export const PAGE_CSS = readCss('./page/app.css');
34
+
35
+ function readCss(name) {
36
+ return fs.readFileSync(new URL(name, import.meta.url), 'utf8').trim();
37
+ }
package/src/data.js ADDED
@@ -0,0 +1,105 @@
1
+ import path from 'path';
2
+ import { LOCALES } from './locales.js';
3
+ import { metricView } from './metrics.js';
4
+ import { rowHref } from './journal.js';
5
+ import { build, skipLine } from './history.js';
6
+ import { TOOL_PKG } from './tool.js';
7
+
8
+ /* Категории файлов и контракт со страницей: абсолютные значения и устройство
9
+ * таблицы, без единой производной величины. Всё, что страница считает сама,
10
+ * начинается там, где этот модуль заканчивается. */
11
+
12
+ /* Категория файла — только для быстрых кнопок «включить/выключить группу» на
13
+ * странице: на числа она не влияет. Правило одно — расширение даёт категорию, всё
14
+ * остальное считается кодом; категория, заданная в настройках колонки, старше
15
+ * правила, и в данных видно, откуда она взялась (`categoryBy`): ручное решение
16
+ * объяснимо, а таблица расширений — догадка по имени файла. */
17
+ export const CATEGORY_EXTS = {
18
+ docs: ['.md', '.markdown', '.rst', '.txt', '.adoc'],
19
+ chore: ['.json', '.yaml', '.yml', '.toml', '.ini', '.cfg', '.conf', '.lock', '.editorconfig'],
20
+ assets: ['.svg', '.png', '.jpg', '.jpeg', '.gif', '.webp', '.ico', '.woff', '.woff2', '.ttf', '.otf']
21
+ };
22
+ export const CATEGORY_ORDER = ['code', 'docs', 'chore', 'assets'];
23
+
24
+ export function categoryOf(col) {
25
+ if (col.category) return { key: col.category, by: 'config' };
26
+ const ext = path.extname(col.paths[col.paths.length - 1]).toLowerCase();
27
+ const known = CATEGORY_ORDER.find((key) => (CATEGORY_EXTS[key] || []).indexOf(ext) >= 0);
28
+ return { key: known === undefined ? 'code' : known, by: 'auto' };
29
+ }
30
+
31
+ /* Контракт между движком и страницей: абсолютные значения и устройство таблицы —
32
+ * и ни одной производной величины. Дельты, суммы, «сейчас» и фильтры считает
33
+ * страница: движок не знает, что включено в просмотр, поэтому заранее посчитать
34
+ * сумму он не может. Числа в контракте те же, что в артефакте, — это та же правда,
35
+ * разложенная по полям.
36
+ *
37
+ * Причины пропущенных коммитов идут строками — и остаются ими: у страницы нет
38
+ * вопроса, на который пригодилось бы поле («почему у коммита нет строки» задают
39
+ * командой `explain`, и там причина уже разложена). */
40
+ export function reportData(cfg, root) {
41
+ const { rows, state, dropped } = build(cfg, root);
42
+ const loc = LOCALES[cfg.locale];
43
+ const files = cfg.columns.map((col, i) => {
44
+ const cat = categoryOf(col);
45
+ return {
46
+ label: col.label,
47
+ path: state[i] === null ? null : state[i].path,
48
+ paths: col.paths,
49
+ category: cat.key,
50
+ categoryBy: cat.by
51
+ };
52
+ });
53
+ return {
54
+ schema: 1,
55
+ tool: { name: TOOL_PKG.name, version: TOOL_PKG.version },
56
+ report: {
57
+ locale: cfg.locale,
58
+ title: cfg.title || loc.heading,
59
+ heading: cfg.heading || loc.heading,
60
+ artifact: cfg.output,
61
+ fixCommand: cfg.fixCommand,
62
+ journal: cfg.journal === null ? null : { path: cfg.journal.path },
63
+ showSha: cfg.rows.sha
64
+ },
65
+ metrics: cfg.metrics.map((key) => Object.assign({ key: key }, metricView(key, cfg))),
66
+ categories: CATEGORY_ORDER.filter((key) => files.some((f) => f.category === key))
67
+ .map((key) => ({ key: key, label: loc.categories[key] })),
68
+ files: files,
69
+ rows: rows.map((r) => ({
70
+ sha: r.sha,
71
+ when: r.when,
72
+ subject: r.subject,
73
+ section: r.section === null ? null : { id: r.section.id, head: r.section.head, added: r.section.added },
74
+ href: rowHref(r.section, r.sha, cfg),
75
+ values: r.cells
76
+ })),
77
+ now: state.map((s) => (s === null ? null : s.cells)),
78
+ approx: approxMarks(rows, state, cfg),
79
+ skipped: dropped.map(skipLine)
80
+ };
81
+ }
82
+
83
+ /* Пометки приближённых клеток — по одной записи на метрику: строка знаков по
84
+ * клеткам строк и строка знаков по верхней строке «сейчас». '1' — число получено
85
+ * упрощением или оценкой, '0' — точное. Метрика без ни одной пометки в отчёте не
86
+ * появляется вовсе: все числа точны — молчание.
87
+ *
88
+ * Знак ставит движок там же, где считает число, — из того же правила, что и
89
+ * подпись метрики. Поэтому страница ничего про пути и форматы не выводит: она
90
+ * только показывает то, что сказано, и второго правила точности не заводит. */
91
+ function approxMarks(rows, state, cfg) {
92
+ const out = {};
93
+ cfg.metrics.forEach((m) => {
94
+ const mark = (flags) => (flags !== null && flags[m] ? '1' : '0');
95
+ const inRows = rows.map((r) => r.approx.map(mark).join('')).join('');
96
+ const now = state.map((s) => mark(s === null ? null : s.approx)).join('');
97
+ if (inRows.indexOf('1') >= 0 || now.indexOf('1') >= 0) out[m] = { rows: inRows, now: now };
98
+ });
99
+ return out;
100
+ }
101
+
102
+ /* Производные величины живут в `src/derived.js`: их считает и артефакт (импорт
103
+ * ниже), и страница (получает тот же файл текстом). Второго расчёта той же
104
+ * таблицы нет вовсе, поэтому разойтись молча двум отчётам нечем — это стережёт
105
+ * `test/contract.test.js`. */
package/src/derived.js ADDED
@@ -0,0 +1,115 @@
1
+ /* Производные величины отчёта: из абсолютных значений получаются итоги, дельты,
2
+ * содержимое клетки и подпись коммита.
3
+ *
4
+ * Единственное место, где это считается. Оба вывода пользуются этим файлом:
5
+ * статический артефакт импортирует его как обычный модуль, а страница получает
6
+ * его текст вклеенным в свой единственный файл (внешних ссылок страница иметь не
7
+ * может). Поэтому у этого файла два требования, и оба обязательны:
8
+ *
9
+ * 1. Ни импортов, ни состояния модуля — иначе текст нельзя вклеить;
10
+ * 2. Один `import` на строку и экспорт объявлением (`export function`), а не
11
+ * списком имён: модульный синтаксис при вклейке снимается построчно, и
12
+ * непонятая строка не должна молча попасть в страницу (`pageScript`).
13
+ *
14
+ * Расхождение двух отчётов возможно только здесь, поэтому и стеречь его надо
15
+ * здесь: `test/contract.test.js` сверяет числа страницы с числами артефакта и
16
+ * следит, чтобы у страницы не появилось своего расчёта. */
17
+
18
+ // Разряды тонкими пробелами: toLocaleString зависит от ICU сборки Node, а строка
19
+ // таблицы обязана совпадать побайтово на любой машине.
20
+ export function group(n) {
21
+ return String(n).replace(/\B(?=(\d{3})+(?!\d))/g, '\u2009');
22
+ }
23
+
24
+ /* Итог: сумма по включённым файлам. Выключенный файл не участвует ни в таблице,
25
+ * ни в сумме, — иначе «итого» отвечало бы не про то, что видно. */
26
+ export function totalsOf(values, metrics, on) {
27
+ const out = {};
28
+ metrics.forEach((m) => { out[m] = 0; });
29
+ values.forEach((v, i) => {
30
+ if (v === null || (on !== undefined && !on[i])) return;
31
+ metrics.forEach((m) => { out[m] += v[m]; });
32
+ });
33
+ return out;
34
+ }
35
+
36
+ /* Дельта к предыдущему коммиту. Появление файла — рост на весь его объём: иначе
37
+ * сумма дельт по колонке не сходилась бы с текущим размером. */
38
+ export function deltaOf(now, before) {
39
+ return before === null || before === undefined ? now : now - before;
40
+ }
41
+
42
+ /* Содержимое клетки строки-коммита: что в ней написано и каким цветом. Разметку
43
+ * из этого делает каждый вывод сам (строка HTML или узел DOM), а правила одни.
44
+ * Пустая клетка — «не менялось», `—` — файла в ревизии нет.
45
+ * `minus` — знак минуса: у артефакта он заморожен эталоном побайтово, страница
46
+ * ставит типографский. */
47
+ export function cellParts(value, delta, minus) {
48
+ if (value === null) return { text: '—', dir: null, miss: true };
49
+ if (!delta) return { text: '', dir: null, miss: false };
50
+ return {
51
+ text: (delta > 0 ? '+' : minus) + group(Math.abs(delta)),
52
+ dir: delta > 0 ? 'up' : 'down',
53
+ miss: false
54
+ };
55
+ }
56
+
57
+ // Клетка верхней строки: абсолютный размер, без дельты.
58
+ export function valueParts(value) {
59
+ return value === null ? { text: '—', miss: true } : { text: group(value), miss: false };
60
+ }
61
+
62
+ /* Строка-коммит: блок «общий объём» и по блоку на включённый файл, в каждом —
63
+ * клетка на метрику. Отбор включённых файлов происходит здесь, поэтому и таблица,
64
+ * и суммы считаются от одного выбора. */
65
+ export function rowModel(values, prev, metrics, on) {
66
+ const total = totalsOf(values, metrics, on);
67
+ const prevTotal = prev === null ? null : totalsOf(prev, metrics, on);
68
+ const out = {
69
+ total: metrics.map((m) => ({
70
+ value: total[m],
71
+ delta: deltaOf(total[m], prevTotal === null ? null : prevTotal[m])
72
+ })),
73
+ files: []
74
+ };
75
+ values.forEach((v, i) => {
76
+ if (on !== undefined && !on[i]) return;
77
+ const before = prev === null || prev[i] === null ? null : prev[i];
78
+ out.files.push(metrics.map((m) => {
79
+ const value = v === null ? null : v[m];
80
+ const was = value === null || before === null ? null : before[m];
81
+ return { value: value, delta: value === null ? null : deltaOf(value, was) };
82
+ }));
83
+ });
84
+ return out;
85
+ }
86
+
87
+ /* Верхняя строка — абсолютные размеры на HEAD: абсолютное число стоит в таблице
88
+ * один раз, и именно с ним сходятся все дельты под ним. */
89
+ export function nowModel(values, metrics, on) {
90
+ const total = totalsOf(values, metrics, on);
91
+ const files = [];
92
+ values.forEach((v, i) => {
93
+ if (on !== undefined && !on[i]) return;
94
+ files.push(metrics.map((m) => (v === null ? null : v[m])));
95
+ });
96
+ return { total: metrics.map((m) => total[m]), files: files };
97
+ }
98
+
99
+ /* Подпись коммита в терминах данных: что показать, чем подписать и куда вести.
100
+ * Ссылку считает `rowHref` движка — то же место, откуда её берёт контракт для
101
+ * страницы, поэтому оба вывода ведут туда же. Подпись всплывающей строки тоже
102
+ * здесь: два вывода не должны подписывать один коммит по-разному. */
103
+ export function commitParts(row, showSha, href) {
104
+ const short = showSha ? row.sha.slice(0, 7) : '';
105
+ return {
106
+ when: row.when,
107
+ subject: row.subject,
108
+ short: short,
109
+ title: short === '' ? row.subject : row.subject + ' · ' + short,
110
+ href: href || null,
111
+ mark: row.section === null
112
+ ? { text: '—', title: null }
113
+ : { text: '§' + row.section.id + (row.section.added ? '' : '*'), title: row.section.head }
114
+ };
115
+ }
package/src/doctor.js ADDED
@@ -0,0 +1,225 @@
1
+ import { EXIT, Refusal, cliCommand } from './refusal.js';
2
+ import { GIT_PINS, git, gitEnv } from './git.js';
3
+ import { loadConfig } from './config.js';
4
+ import { TOOL_PKG } from './tool.js';
5
+ import { coverage, coverageText } from './check.js';
6
+ import { hookStatus } from './hook.js';
7
+ import { minifier } from './minify.js';
8
+ import { tokenizer } from './tokens.js';
9
+
10
+ /* Диагностика одним ответом (`size doctor`): отвечает ли машина за числа, чем
11
+ * считаются метрики здесь и сейчас, годятся ли настройки, всё ли из истории
12
+ * покрыто. Ничего своего он не считает: покрытие — тот же ответ, что даёт
13
+ * `size check` (`coverage`), окружение — факты этой машины, зависимости — те же
14
+ * загрузчики, которыми пользуются датчики. Второго расчёта в пакете нет.
15
+ *
16
+ * Правило ответа: `ok` значит «делать нечего», а у находки назван уровень.
17
+ * `action` — что-то надо сделать (и, где возможно, названа команда починки);
18
+ * `note` — наблюдение: знать полезно, делать нечего. Код выхода — первый по
19
+ * важности, а не «всё хорошо»: 2 — настройки нечитаемы (читать больше нечего),
20
+ * 3 — история обрезана, 1 — покрытие неполно, 4 — число приближённо. Порядок
21
+ * именно такой: сначала то, что мешает считать, потом то, что требует починки,
22
+ * потом честная оговорка о счёте. Хук в этот порядок не входит: отчёт собирается
23
+ * и без него, поэтому сломанный хук — находка без своего кода (вердикт `ok` при
24
+ * этом всё равно «есть дело»).
25
+ *
26
+ * Чего ответ не делает: не говорит, «правильно» ли выбраны колонки (это знает
27
+ * проект), и не угадывает там, где данных нет, — отсутствие ответа называется
28
+ * словами (`coverage: null` и находка с причиной).
29
+ */
30
+
31
+ /* Окружение: что за машина и что она говорит о числах. Без ответа git ответ
32
+ * честно неполон (`git: null`), а не выдуман. */
33
+ function environment(root) {
34
+ const env = {
35
+ node: process.version,
36
+ platform: process.platform,
37
+ root: root,
38
+ git: null,
39
+ shallow: null,
40
+ pins: GIT_PINS.slice(),
41
+ locale: gitEnv().LC_ALL
42
+ };
43
+ try {
44
+ env.git = git(root, ['--version']).trim();
45
+ env.shallow = git(root, ['rev-parse', '--is-shallow-repository']).trim() === 'true';
46
+ } catch (_e) {
47
+ // git не ответил — об этом скажет находка, а не выдуманное значение.
48
+ }
49
+ return env;
50
+ }
51
+
52
+ /* Зависимости: чем метрики считаются здесь и сейчас. Спрашиваются те же
53
+ * загрузчики, что и у датчиков (`minifier`, `tokenizer`), поэтому ответ не может
54
+ * разойтись с числом: без минификатора `min` считает упрощением, без словаря
55
+ * `tok` — оценкой.
56
+ *
57
+ * Загружается только то, о чём проект действительно спросил: словарь весит
58
+ * мегабайты, и трогать его ради строки «есть» значило бы заплатить за ответ,
59
+ * которого у чисел не было (то же правило, что у отчёта: `test/tokens.test.js`).
60
+ * Ненужный датчик назван не «неизвестным», а ненужным — на точность он не влияет,
61
+ * и это и есть ответ; «неизвестно» остаётся там, где настройки нечитаемы и
62
+ * спросить не у кого. */
63
+ const UNREADABLE = 'неизвестно: настройки нечитаемы';
64
+
65
+ /* Спрошено — спрашиваем загрузчик; не спрошено — говорим об этом словами и не
66
+ * платим за него. */
67
+ function entry(asked, name, metric, load, note) {
68
+ if (asked !== true) return { name: name, metric: metric, present: null, note: note };
69
+ const { tool, version } = load();
70
+ return { name: name, metric: metric, present: tool !== null, version: version };
71
+ }
72
+
73
+ function dependencies(cfg) {
74
+ const asksMinify = cfg === null ? null : cfg.minify.engine === 'esbuild';
75
+ const asksTokens = cfg === null ? null : cfg.metrics.indexOf('tok') >= 0;
76
+ return [
77
+ entry(asksMinify, 'esbuild', 'min', () => minifier(),
78
+ asksMinify === null ? UNREADABLE : 'не спрашивается: «minify» считает снятием балласта'),
79
+ entry(asksTokens, 'gpt-tokenizer', 'tok', () => tokenizer(cfg.tokens),
80
+ asksTokens === null ? UNREADABLE : 'не спрашивается: метрики ' + cfg.metrics.join(' '))
81
+ ];
82
+ }
83
+
84
+ export function doctor(root, configFile) {
85
+ const rep = {
86
+ schema: 1,
87
+ ok: false,
88
+ tool: { name: TOOL_PKG.name, version: TOOL_PKG.version },
89
+ environment: environment(root),
90
+ config: null,
91
+ dependencies: null,
92
+ hooks: null,
93
+ coverage: null,
94
+ findings: [],
95
+ exit: EXIT.OK
96
+ };
97
+ let cfg = null;
98
+ try {
99
+ cfg = loadConfig(configFile, root);
100
+ rep.config = { file: configFile, ok: true, columns: cfg.columns.length, metrics: cfg.metrics };
101
+ } catch (e) {
102
+ // Отказ настроек здесь не отказ, а находка: диагностика затем и нужна, чтобы
103
+ // назвать причину и починку, — их и несёт текст отказа.
104
+ if (!(e instanceof Refusal)) throw e;
105
+ rep.config = { file: configFile, ok: false, problem: e.message };
106
+ rep.findings.push({ level: 'action', what: e.message });
107
+ rep.exit = e.code;
108
+ }
109
+ rep.dependencies = dependencies(cfg);
110
+ rep.hooks = hooksReport(root, cfg);
111
+ if (rep.hooks.installed && rep.hooks.enabled === false) {
112
+ rep.findings.push({
113
+ level: 'action',
114
+ what: 'хук установлен, но автоматика выключена настройкой hooks.enabled: отчёт обновляется руками',
115
+ fix: 'верните «"hooks": {"enabled": true}» в файл настроек или снимите хук: ' + cliCommand('uninstall-hook')
116
+ });
117
+ }
118
+ if (rep.hooks.installed && rep.hooks.last !== null && HOOK_BAD.indexOf(rep.hooks.last.result) >= 0) {
119
+ rep.findings.push({
120
+ level: 'action',
121
+ what: 'хук: последний запуск не пересобрал отчёт — ' + rep.hooks.last.why,
122
+ fix: 'починьте то, на что жалуется причина, и пересоберите отчёт: ' + cliCommand('--write')
123
+ });
124
+ }
125
+ if (cfg === null) {
126
+ rep.findings.push({
127
+ level: 'note',
128
+ what: 'покрытие не считалось: настройки нечитаемы — почините их и спросите снова'
129
+ });
130
+ } else {
131
+ try {
132
+ rep.coverage = coverage(cfg, root, configFile);
133
+ } catch (e) {
134
+ if (!(e instanceof Refusal)) throw e;
135
+ rep.findings.push({ level: 'action', what: e.message });
136
+ rep.exit = e.code;
137
+ }
138
+ }
139
+
140
+ /* Что попадает в находки, а что нет: в отчёте уже целиком стоит блок покрытия
141
+ * (тот же текст, что у `size check`), поэтому неполнота здесь второй раз не
142
+ * пересказывается — она меняет вердикт и код выхода. Находкой становится то,
143
+ * чего в блоке покрытия нет: нечитаемые настройки, обрезанная история, а по
144
+ * датчикам — их причина и починка (их `size check` печатает отдельной строкой
145
+ * `!`, а здесь они часть того же ответа). Неполнота старше приближения: из
146
+ * двух причин починки код выхода несёт ту, без которой чисел нет вовсе. */
147
+ if (rep.coverage !== null) {
148
+ rep.coverage.sensors.forEach((gap) => {
149
+ rep.findings.push({ level: 'action', what: gap.why, fix: gap.fix });
150
+ });
151
+ if (!rep.coverage.ok) rep.exit = EXIT.VIOLATION;
152
+ else if (rep.coverage.sensors.length > 0) rep.exit = EXIT.SENSOR;
153
+ }
154
+
155
+ /* «Делать нечего»: ни одной находки-действия и покрытие сосчитано и полно.
156
+ * Покрытие спрашивается отдельно, потому что его неполнота говорится не находкой,
157
+ * а блоком покрытия (см. выше), — а вердикт она менять обязана. */
158
+ rep.ok = rep.coverage !== null && rep.coverage.ok
159
+ && !rep.findings.some((f) => f.level === 'action');
160
+ return rep;
161
+ }
162
+
163
+ /* Итог последнего запуска хука словами: по нему человек понимает, что произошло
164
+ * после коммита, не заглядывая в `.git`. */
165
+ const HOOK_RESULT = {
166
+ committed: 'отчёт пересобран и закоммичен',
167
+ rebuilt: 'отчёт пересобран без коммита',
168
+ unchanged: 'менять было нечего',
169
+ refused: 'отказ',
170
+ failed: 'ошибка',
171
+ skipped: 'пропущен'
172
+ };
173
+ // Итоги, которые требуют действий: отказ инструмента и его собственная ошибка.
174
+ const HOOK_BAD = ['refused', 'failed'];
175
+
176
+ /* Состояние хука: установлен ли, включён ли настройкой и чем кончился последний
177
+ * запуск. «Не установлен» — не находка: автоматика ставится явной командой,
178
+ * и её отсутствие — решение проекта, а не забывчивость. */
179
+ function hooksReport(root, cfg) {
180
+ const status = hookStatus(root);
181
+ return {
182
+ installed: status.installed,
183
+ files: status.files,
184
+ enabled: cfg === null ? null : cfg.hooks.enabled,
185
+ last: status.last
186
+ };
187
+ }
188
+
189
+ function hookLine(hooks) {
190
+ if (!hooks.installed) return 'не установлен (ставится командой ' + cliCommand('install-hook') + ')';
191
+ const last = hooks.last === null
192
+ ? 'ещё не запускался'
193
+ : 'последний запуск ' + hooks.last.at + ' — ' + (HOOK_RESULT[hooks.last.result] || hooks.last.result)
194
+ + (hooks.last.commit ? ' (' + hooks.last.commit + ')' : '')
195
+ + (hooks.last.why ? ': ' + hooks.last.why.split('\n')[0] : '');
196
+ return hooks.files.join(', ') + (hooks.enabled === false ? ' (выключен настройкой)' : '') + '; ' + last;
197
+ }
198
+
199
+ /* Текст для человека. Покрытие печатает `coverageText` — тот же, что у
200
+ * `size check`: два ответа об одном не должны разойтись формулировками. */
201
+ export function doctorText(rep) {
202
+ const env = rep.environment;
203
+ const lines = [];
204
+ lines.push((rep.ok ? '✓ ' : '✗ ') + rep.tool.name + ' ' + rep.tool.version + ': диагностика ' + env.root);
205
+ lines.push(' окружение: Node ' + env.node + ', ' + env.platform + ', '
206
+ + (env.git === null ? 'git недоступен' : env.git)
207
+ + (env.shallow === null ? '' : env.shallow ? ', история обрезана' : ', история полная'));
208
+ // Закрепления — механизм, а не украшение: движок ставит их сам на границе вызова,
209
+ // поэтому настройки машины на числа не влияют (проверка — `test/environment.test.js`).
210
+ lines.push(' git читается с закреплениями: ' + env.pins.join(', ') + '; локаль ' + env.locale
211
+ + ' (настройки машины на числа не влияют)');
212
+ lines.push(' настройки: ' + (rep.config.ok
213
+ ? rep.config.file + ' — ' + rep.config.columns + ' колонок, метрики ' + rep.config.metrics.join(' ')
214
+ : rep.config.file + ' — нечитаемы'));
215
+ lines.push(' зависимости: ' + rep.dependencies.map((d) => d.name
216
+ + (d.present === null ? ' — ' + d.note : d.present ? ' ' + d.version + ' есть' : ' нет')
217
+ + ' (' + d.metric + ')').join(', '));
218
+ lines.push(' хук: ' + hookLine(rep.hooks));
219
+ if (rep.coverage) lines.push(coverageText(rep.coverage));
220
+ rep.findings.forEach((f) => {
221
+ lines.push((f.level === 'action' ? '✗ ' : '· ') + f.what);
222
+ if (f.fix) lines.push(' починка: ' + f.fix);
223
+ });
224
+ return lines.join('\n');
225
+ }
package/src/explain.js ADDED
@@ -0,0 +1,113 @@
1
+ import { assertFullHistory, readHistory, resolveCommit } from './git.js';
2
+ import { measureHistory } from './history.js';
3
+ import { cliCommand, refuseCause } from './refusal.js';
4
+ import { CONFIG_NAME } from './config.js';
5
+
6
+ /* Почему у коммита нет строки — ответ на конкретный вопрос про конкретный коммит.
7
+ *
8
+ * Ответ строится на том же проходе, что и сам отчёт: причина берётся у движка, а
9
+ * не выводится здесь заново, — иначе два ответа о том же коммите разошлись бы. Но
10
+ * причина у движка одна на два случая («без изменения объёма» — это и «числа не
11
+ * сдвинулись», и «ни одного файла колонок»), потому что отчёту эта разница не
12
+ * нужна; здесь она и есть суть вопроса, поэтому к причине добавляются улики —
13
+ * какие файлы коммит тронул и что из них колонки, что исключено, а что не
14
+ * отслеживается вовсе. Улики читаются из тех же фактов (список изменённых путей
15
+ * коммита), так что выдумать их нельзя: чего нет в истории — о том молчание. */
16
+
17
+ const REASON_TEXT = {
18
+ merge: 'коммит — слияние, а строки слияний скрыты настройкой «rows.merges: false»',
19
+ report: 'тронут только сам отчёт (и то, что перечислено в «skip»)',
20
+ outside: 'ни один файл коммита не отслеживается колонкой',
21
+ flat: 'числа не сдвинулись: файлы колонок тронуты, а объём не изменился'
22
+ };
23
+
24
+ export function explainCommit(cfg, root, target) {
25
+ assertFullHistory(root);
26
+ const commits = readHistory(root);
27
+ /* Имя ревизии разрешает git, и только если имени нет — ищем начало sha по
28
+ * списку коммитов: так у неоднозначного префикса остаётся человеческий отказ
29
+ * со списком подходящих, а у имени — правила git, а не наши. */
30
+ const resolved = resolveCommit(root, String(target));
31
+ const needle = String(target).toLowerCase();
32
+ const found = resolved === null
33
+ ? commits.filter((c) => c.sha.toLowerCase().indexOf(needle) === 0)
34
+ : commits.filter((c) => c.sha === resolved);
35
+ // Имя разрешилось, а коммита в отчёте нет: это не «нет коммита» — коммит есть,
36
+ // и сказать надо именно это, иначе человек пойдёт искать проблему в истории.
37
+ if (resolved !== null && found.length === 0) {
38
+ refuseCause('коммит вне истории', '«' + target + '» — это коммит ' + resolved.slice(0, 7)
39
+ + ', но его нет в истории отчёта: строки строятся по коммитам текущей ветки'
40
+ + '\n починка: посмотрите историю отчёта: git log --oneline'
41
+ + ' (всю историю репозитория показывает git log --all)');
42
+ }
43
+ if (found.length === 0) {
44
+ refuseCause('нет такого коммита', '«' + target + '» — не имя ревизии и не начало sha'
45
+ + '\n починка: посмотрите историю: git log --oneline');
46
+ }
47
+ if (found.length > 1) {
48
+ refuseCause('коммит назван неточно', 'префикс «' + target + '» неоднозначен: подходят '
49
+ + found.length + ' коммитов'
50
+ + '\n ' + found.slice(0, 5).map((c) => c.sha.slice(0, 7) + ' ' + c.subject).join('\n ')
51
+ + '\n починка: назовите больше знаков');
52
+ }
53
+ const c = found[0];
54
+ const measured = measureHistory(cfg, root, commits);
55
+ const row = measured.rows.findIndex((r) => r.sha === c.sha);
56
+ const dropped = measured.dropped.find((d) => d.sha === c.sha);
57
+
58
+ const tracked = new Set();
59
+ cfg.columns.forEach((col) => col.paths.forEach((p) => tracked.add(p)));
60
+ const excluded = new Set([cfg.output].concat(cfg.skip || []));
61
+ const touched = { columns: [], excluded: [], untracked: [] };
62
+ c.files.forEach((f) => {
63
+ if (tracked.has(f)) { if (touched.columns.indexOf(f) < 0) touched.columns.push(f); return; }
64
+ if (excluded.has(f)) { if (touched.excluded.indexOf(f) < 0) touched.excluded.push(f); return; }
65
+ if (touched.untracked.indexOf(f) < 0) touched.untracked.push(f);
66
+ });
67
+
68
+ /* Разница, которой нет в строке отчёта: «без изменения объёма» у коммита мимо
69
+ * колонок означает не то же самое, что у коммита, тронувшего колонку. */
70
+ let reason = row >= 0 ? null : dropped.reason;
71
+ if (reason === 'flat' && touched.columns.length === 0) reason = 'outside';
72
+
73
+ const fix = {
74
+ merge: 'включите строки слияний: "rows": { "merges": true }',
75
+ report: 'не требуется: строка про коммит не может лежать внутри самого коммита — обновляйте отчёт отдельным коммитом',
76
+ outside: 'допишите ' + (touched.untracked.length > 0 ? 'эти пути' : 'тронутые файлы')
77
+ + ' колонкой или в «skip» файла ' + CONFIG_NAME,
78
+ flat: 'не требуется: числа не изменились — строка без единого числа читалась бы как поломка'
79
+ }[reason];
80
+
81
+ return {
82
+ schema: 1,
83
+ sha: c.sha,
84
+ subject: c.subject,
85
+ when: c.when,
86
+ row: row < 0 ? null : row + 1,
87
+ rows: measured.rows.length,
88
+ reason: reason,
89
+ touched: touched,
90
+ fix: fix === undefined ? null : fix
91
+ };
92
+ }
93
+
94
+ /* Отказ для объяснения даётся человеку текстом, а агенту — полем: «есть строка» и
95
+ * «нет строки» одинаково успешные ответы, поэтому код выхода 0 у обоих. */
96
+ export function explainText(rep) {
97
+ const lines = [];
98
+ const where = ' коммит ' + rep.sha.slice(0, 7) + ' «' + rep.subject.slice(0, 60) + '»';
99
+ if (rep.row !== null) {
100
+ lines.push('✓ строка есть: ' + rep.row + '-я из ' + rep.rows + ' — объём изменился');
101
+ } else {
102
+ lines.push('— строка не нужна: ' + REASON_TEXT[rep.reason]);
103
+ }
104
+ lines.push(where);
105
+ if (rep.touched.columns.length > 0) lines.push(' тронуты колонки: ' + rep.touched.columns.join(', '));
106
+ if (rep.touched.excluded.length > 0) lines.push(' исключено настройками: ' + rep.touched.excluded.join(', '));
107
+ if (rep.touched.untracked.length > 0) {
108
+ lines.push(' мимо колонок и исключений: ' + rep.touched.untracked.join(', ')
109
+ + ' — за это отвечает проверка полноты: ' + cliCommand('check'));
110
+ }
111
+ if (rep.fix !== null) lines.push(' починка: ' + rep.fix);
112
+ return lines.join('\n');
113
+ }