@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/bin/size.js ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env node
2
+ /* Точка входа команды `size`. Разбор аргументов и режимы живут в движке: держать
3
+ * их здесь значило бы, что движок нельзя импортировать, не исполнив команду. */
4
+ import { main } from '../src/size-table.js';
5
+
6
+ process.exitCode = main();
package/package.json ADDED
@@ -0,0 +1,61 @@
1
+ {
2
+ "name": "@vernikr/size-report",
3
+ "version": "1.1.1",
4
+ "author": "vernikr",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/vernikr/size-report.git"
8
+ },
9
+ "description": "Учёт роста объёма кода и документов по истории git: raw / min / токены, интерактивный отчёт вне git",
10
+ "type": "module",
11
+ "license": "MIT",
12
+ "packageManager": "pnpm@10.6.1",
13
+ "engines": {
14
+ "node": ">=20.19"
15
+ },
16
+ "bin": {
17
+ "size": "bin/size.js"
18
+ },
19
+ "exports": {
20
+ ".": "./src/size-table.js"
21
+ },
22
+ "publishConfig": {
23
+ "access": "public"
24
+ },
25
+ "files": [
26
+ "bin",
27
+ "src",
28
+ "templates",
29
+ "README.md",
30
+ "CHANGELOG.md",
31
+ "LICENSE"
32
+ ],
33
+ "sideEffects": false,
34
+ "scripts": {
35
+ "test": "node tools/run-tests.js fast",
36
+ "test:all": "node tools/run-tests.js full",
37
+ "suites:measure": "node tools/run-tests.js measure",
38
+ "lint": "eslint . || true",
39
+ "lint:strict": "eslint .",
40
+ "parity": "node tools/parity-freeze.js",
41
+ "parity:live": "node tools/parity-live.js",
42
+ "pack:check": "node tools/pack-check.js",
43
+ "check:standards": "node tools/check-standards.js",
44
+ "fixture": "node tools/make-fixture.js"
45
+ },
46
+ "keywords": [
47
+ "size",
48
+ "tokens",
49
+ "minify",
50
+ "git-history",
51
+ "report"
52
+ ],
53
+ "devDependencies": {
54
+ "eslint": "^9.18.0",
55
+ "jsdom": "~26"
56
+ },
57
+ "optionalDependencies": {
58
+ "esbuild": "0.28.2",
59
+ "gpt-tokenizer": "4.0.0"
60
+ }
61
+ }
@@ -0,0 +1,8 @@
1
+ :root { color-scheme: light dark; }
2
+ /* Фон и цвет текста заданы явно и одной парой (Canvas/CanvasText): без этого
3
+ * страница берёт цвет текста из схемы, а фон — нет, и в тёмной схеме числа
4
+ * оказывались белыми на белом. */
5
+ body { margin: 0; padding: 20px; background: Canvas; color: CanvasText; font: 12.5px/1.4 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; }
6
+ h1 { margin: 0 0 6px; font-size: 16px; }
7
+ .note { margin: 0 0 14px; max-width: 80em; opacity: .75; font-size: 12px; }
8
+ .note code { background: rgba(127, 127, 127, .15); padding: 0 3px; border-radius: 3px; }
@@ -0,0 +1,21 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { build } from './history.js';
4
+ import { render } from './render.js';
5
+
6
+ /* Артефакт на диске: единственное место, где отчёт превращается в файл. Им
7
+ * пользуются оба потребителя — режим `--write` и хук после коммита (`src/hook.js`),
8
+ * поэтому «что записано в файл» не может разойтись между ними: в коммит хук кладёт
9
+ * ровно те байты, которые показывает `--write`.
10
+ *
11
+ * Каталог создаётся здесь же: `--write .size-report/report.html` в свежем проекте —
12
+ * обычный запуск, а не ошибка пользователя. */
13
+
14
+ export function rebuild(cfg, root) {
15
+ const { rows, dropped, state } = build(cfg, root);
16
+ const html = render(rows, cfg);
17
+ const file = path.join(root, cfg.output);
18
+ fs.mkdirSync(path.dirname(file), { recursive: true });
19
+ fs.writeFileSync(file, html);
20
+ return { rows: rows, dropped: dropped, state: state, html: html, file: file };
21
+ }
package/src/check.js ADDED
@@ -0,0 +1,142 @@
1
+ import { assertFullHistory, readHistory } from './git.js';
2
+ import { measureHistory } from './history.js';
3
+ import { sensorGaps } from './metrics.js';
4
+ import { CONFIG_NAME } from './config.js';
5
+ import { cliCommand } from './refusal.js';
6
+
7
+ /* Полнота покрытия — ответ на вопрос «всё ли в истории попало в отчёт». Правило
8
+ * из требований (§4.2) одно: каждый путь, тронутый историей, обязан быть либо
9
+ * колонкой, либо объявленным исключением (`skip` и сам файл отчёта), а путь,
10
+ * который не то и не другое, — это нарушение: изменение прошло мимо отчёта.
11
+ *
12
+ * Поэтому полнота считается по фактам истории (объединение изменённых путей всех
13
+ * коммитов), а не по списку файлов в дереве: файл, появившийся и удалённый до
14
+ * HEAD, в дереве не виден, а история его помнит — и его правки не посчитаны
15
+ * ничем. Отсюда же и то, чего инструмент не утверждает: он не говорит, «правильные»
16
+ * ли колонки выбрал проект, — он говорит только, что мимо них ничего не прошло и
17
+ * что именно не поместилось.
18
+ *
19
+ * Сводка по строкам идёт тем же проходом, что и отчёты (`measureHistory`), — по
20
+ * тем же причинам, что и везде: второго расчёта в пакете нет. Отсюда цена: ответ
21
+ * стоит одного прохода по истории, как и любой отчёт. */
22
+
23
+ // Сколько путей и коммитов показывать человеку в тексте: остальное — числом.
24
+ const SHOW = 8;
25
+
26
+ /* Список коммитов одной причины — строками не длиннее экрана: у большой истории
27
+ * одних пропущенных коммитов набирается полсотни, и одна строка на всех не
28
+ * читается вовсе. Все они остаются в ответе (и целиком — в `--json`), но перенос
29
+ * делает его читаемым. */
30
+ function wrapped(head, items) {
31
+ const lines = [];
32
+ let line = head;
33
+ items.forEach((item, i) => {
34
+ const piece = (i === 0 ? '' : ' ') + item + (i === items.length - 1 ? '' : ',');
35
+ if (line.length + piece.length > 100) { lines.push(line); line = ' ' + piece.trim(); return; }
36
+ line += piece;
37
+ });
38
+ lines.push(line);
39
+ return lines;
40
+ }
41
+
42
+ const REASON_WORD = { merge: 'слияние', report: 'только таблица', flat: 'без изменения объёма' };
43
+
44
+ function short(sha) {
45
+ return sha.slice(0, 7);
46
+ }
47
+
48
+ /* Пути, тронутые историей, разложенные на три части: отслеживаемые колонками,
49
+ * исключённые объявлением и незнакомые. Знакомство с путём проверяется по
50
+ * колонкам целиком, а не по одной метке: у колонки путей может быть несколько
51
+ * (переименование), и любой из них её и есть. */
52
+ function pathCoverage(cfg, commits) {
53
+ const tracked = new Set();
54
+ cfg.columns.forEach((col) => col.paths.forEach((p) => tracked.add(p)));
55
+ const excluded = new Set([cfg.output].concat(cfg.skip || []));
56
+ const seen = { covered: new Set(), excluded: new Set() };
57
+ const unknown = new Map();
58
+ commits.forEach((c) => {
59
+ c.files.forEach((f) => {
60
+ if (tracked.has(f)) { seen.covered.add(f); return; }
61
+ if (excluded.has(f)) { seen.excluded.add(f); return; }
62
+ /* Коммит, заведший путь, — первый по истории (порядок чтения — от старых к
63
+ * новым), и показывается он человеку как улика: по нему видно, чья это была
64
+ * правка. */
65
+ if (!unknown.has(f)) unknown.set(f, { path: f, since: c.sha, subject: c.subject });
66
+ });
67
+ });
68
+ return {
69
+ covered: [...seen.covered].sort(),
70
+ excluded: [...seen.excluded].sort(),
71
+ unknown: [...unknown.values()]
72
+ };
73
+ }
74
+
75
+ /* Полный ответ: настройки, история, пути, датчики — и вердикт. `ok` — это только
76
+ * «мимо отчёта не прошло»: датчик, считающий приближённо, вердикта не меняет (это
77
+ * отдельный код выхода, как и у остальных режимов). */
78
+ export function coverage(cfg, root, configFile) {
79
+ assertFullHistory(root);
80
+ const commits = readHistory(root);
81
+ const measured = measureHistory(cfg, root, commits);
82
+ const paths = pathCoverage(cfg, commits);
83
+ const byReason = { merge: 0, report: 0, flat: 0 };
84
+ measured.dropped.forEach((d) => { byReason[d.reason] += 1; });
85
+ return {
86
+ schema: 1,
87
+ ok: paths.unknown.length === 0,
88
+ config: {
89
+ file: configFile,
90
+ columns: cfg.columns.length,
91
+ metrics: cfg.metrics,
92
+ excluded: [...new Set([cfg.output].concat(cfg.skip || []))].sort()
93
+ },
94
+ history: {
95
+ commits: commits.length,
96
+ rows: measured.rows.length,
97
+ dropped: measured.dropped.map((d) => ({ sha: d.sha, reason: d.reason })),
98
+ byReason: byReason
99
+ },
100
+ paths: {
101
+ covered: paths.covered.length,
102
+ excluded: paths.excluded.length,
103
+ unknown: paths.unknown
104
+ },
105
+ sensors: sensorGaps(cfg)
106
+ };
107
+ }
108
+
109
+ /* Текст для человека: короткое «да» или список того, что не поместилось, — с
110
+ * путём, коммитом, который его завёл, и готовой командой. Печатает `cli`, а не
111
+ * этот модуль: у модуля нет и не должно быть вывода. */
112
+ export function coverageText(rep) {
113
+ const lines = [];
114
+ const unknown = rep.paths.unknown;
115
+ const counts = ' история: ' + rep.history.commits + ' коммитов, ' + rep.history.rows + ' строк, без строки '
116
+ + rep.history.dropped.length;
117
+ if (!rep.ok) {
118
+ lines.push('✗ покрытие: ' + unknown.length + (unknown.length === 1 ? ' путь' : ' путей')
119
+ + ' истории не отслеживается и не исключён');
120
+ unknown.slice(0, SHOW).forEach((u) => {
121
+ lines.push(' ' + u.path + ' — с ' + short(u.since) + ' «' + u.subject.slice(0, 60) + '»');
122
+ });
123
+ if (unknown.length > SHOW) lines.push(' … ещё ' + (unknown.length - SHOW));
124
+ lines.push(' починка: допишите эти пути колонкой или в «skip» файла ' + CONFIG_NAME
125
+ + '; черновик колонок по расширениям даёт ' + cliCommand('--init draft.json'));
126
+ } else {
127
+ lines.push('✓ покрытие: ' + rep.history.commits + ' коммитов истории, ' + rep.history.rows
128
+ + ' строк, тронутые пути отслеживаются или исключены');
129
+ }
130
+ lines.push(counts + ' (' + Object.keys(rep.history.byReason)
131
+ .filter((k) => rep.history.byReason[k] > 0)
132
+ .map((k) => REASON_WORD[k] + ' ' + rep.history.byReason[k]).join(', ') + ')');
133
+ if (rep.history.dropped.length > 0) {
134
+ ['merge', 'report', 'flat'].forEach((key) => {
135
+ const shas = rep.history.dropped.filter((d) => d.reason === key).map((d) => short(d.sha));
136
+ if (shas.length > 0) lines.push(...wrapped(' ' + REASON_WORD[key] + ' (' + shas.length + '): ', shas));
137
+ });
138
+ }
139
+ lines.push(' пути: ' + rep.paths.covered + ' отслеживаются, ' + rep.paths.excluded + ' исключены'
140
+ + (rep.ok ? '' : ', ' + unknown.length + ' незнакомы'));
141
+ return lines.join('\n');
142
+ }