@rt-tools/agent-kit 0.2.0 → 0.4.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.
- package/README.md +235 -18
- package/assets/agents/business-analyst.md +74 -0
- package/assets/agents/project-manager.md +70 -0
- package/assets/agents/qa-engineer.md +72 -0
- package/assets/agents/skill-curator.md +110 -0
- package/assets/agents/spec-critic.md +44 -0
- package/assets/agents/spec-writer.md +50 -0
- package/assets/checks/board.github.mjs +286 -0
- package/assets/checks/check-board.github.mjs +188 -0
- package/assets/checks/check-doc-paths.mjs +163 -0
- package/assets/checks/check-dupes.mjs +277 -0
- package/assets/checks/check-lib-layers.mjs +573 -0
- package/assets/checks/check-reuse.mjs +208 -0
- package/assets/checks/check-schema-drift.mjs +186 -0
- package/assets/checks/check-specs.mjs +1007 -0
- package/assets/checks/check-styles.mjs +109 -0
- package/assets/checks/rt-kit-checks.config.mjs +134 -0
- package/assets/checks/task-new.github.mjs +198 -0
- package/assets/commands/skill-curator.md +70 -0
- package/assets/defaults/gate-map.sh +100 -0
- package/assets/defaults/project.sh +179 -0
- package/assets/hooks/browser-device-id.sh +20 -0
- package/assets/hooks/browser-guard-device-id.sh +28 -0
- package/assets/hooks/browser-guard-no-asking.sh +27 -0
- package/assets/hooks/browser-guard-no-listing.sh +18 -0
- package/assets/hooks/browser-guard-no-other-drivers.sh +79 -0
- package/assets/hooks/browser-guard-require-select.sh +54 -0
- package/assets/hooks/commit-msg.sh +26 -0
- package/assets/hooks/constitution-index.sh +43 -0
- package/assets/hooks/dev-server-guard.sh +115 -0
- package/assets/hooks/docs-guard.sh +282 -0
- package/assets/hooks/git-guard-delivery.sh +167 -0
- package/assets/hooks/git-guard-main.sh +73 -0
- package/assets/hooks/git-guard-push-tests.sh +94 -0
- package/assets/hooks/glossary-load.sh +23 -0
- package/assets/hooks/lint-after-edit.sh +219 -0
- package/assets/hooks/qa-dataid-guard.sh +121 -0
- package/assets/hooks/reuse-first-guard.sh +154 -0
- package/assets/hooks/skill-gate-rearm.sh +23 -0
- package/assets/hooks/skill-gate.sh +128 -0
- package/assets/hooks/skill-loaded.sh +21 -0
- package/assets/hooks/sql-guard.sh +679 -0
- package/assets/hooks/task-context-load.sh +100 -0
- package/assets/hooks/task-flow-guard.sh +107 -0
- package/assets/laws/{access.md → application/access.md} +1 -4
- package/assets/laws/{locales.md → application/locales.md} +1 -3
- package/assets/laws/application/money.md +41 -0
- package/assets/laws/application/ownership.md +32 -0
- package/assets/laws/{search-visibility.md → application/search-visibility.md} +1 -1
- package/assets/laws/code-structure.md +7 -6
- package/assets/laws/delivery.md +53 -3
- package/assets/laws/entity-editing.md +49 -55
- package/assets/laws/entity-models.md +4 -14
- package/assets/laws/frontend-application.md +5 -5
- package/assets/laws/lib-imports.md +14 -1
- package/assets/laws/lists.md +33 -0
- package/assets/laws/navigation.md +40 -0
- package/assets/laws/project-documentation.md +17 -8
- package/assets/laws/reuse-first.md +26 -21
- package/assets/laws/shared-code.md +13 -1
- package/assets/laws/verifiability.md +17 -1
- package/assets/laws/work-conduct.md +48 -0
- package/assets/patterns/admin-lists-screen.md +131 -0
- package/assets/patterns/admin-nav-item.md +71 -0
- package/assets/patterns/angular-patterns-state.md +101 -0
- package/assets/patterns/api-layer-pair.md +88 -0
- package/assets/patterns/browser-verification-measure.md +86 -0
- package/assets/patterns/browser-verification-stand.md +143 -0
- package/assets/patterns/component-structure-new.md +99 -0
- package/assets/patterns/dependencies-upgrade.md +65 -0
- package/assets/patterns/doc-style-sweep.md +137 -0
- package/assets/patterns/doc-style-write.md +109 -0
- package/assets/patterns/entity-aside.md +136 -0
- package/assets/patterns/entity-models-new.md +124 -0
- package/assets/patterns/entity-store.md +91 -0
- package/assets/patterns/git-workflow-commit.azure.md +259 -0
- package/assets/patterns/git-workflow-commit.github.md +333 -0
- package/assets/patterns/git-workflow-commit.gitlab.md +283 -0
- package/assets/patterns/git-workflow-merge.md +99 -0
- package/assets/patterns/git-workflow-migration.md +88 -0
- package/assets/patterns/git-workflow-restart.md +49 -0
- package/assets/patterns/lib-layers-move.md +95 -0
- package/assets/patterns/lib-layers-new.md +82 -0
- package/assets/patterns/ownership-scope-resolve.md +69 -0
- package/assets/patterns/permissions-procedure.md +71 -0
- package/assets/patterns/platform-access-di.md +84 -0
- package/assets/patterns/pricing-quote.md +71 -0
- package/assets/patterns/reuse-first-extend.md +73 -0
- package/assets/patterns/seo-page.md +104 -0
- package/assets/patterns/seo-verify.md +83 -0
- package/assets/patterns/shared-code-new.md +86 -0
- package/assets/patterns/spec-driven-domain.md +107 -0
- package/assets/patterns/spec-driven-rule.md +127 -0
- package/assets/patterns/styling-bem-component.md +88 -0
- package/assets/patterns/styling-bem-layout.md +73 -0
- package/assets/patterns/task-flow-close.md +90 -0
- package/assets/patterns/task-flow-resume.md +94 -0
- package/assets/patterns/task-flow-start.md +117 -0
- package/assets/patterns/testing-e2e.md +92 -0
- package/assets/patterns/testing-unit.md +117 -0
- package/assets/patterns/translations-key.md +64 -0
- package/assets/patterns/ts-procedure.md +65 -0
- package/assets/rules/angular-patterns.md +71 -0
- package/assets/rules/api-layer.md +71 -0
- package/assets/rules/browser-verification.md +87 -0
- package/assets/rules/component-structure.md +64 -0
- package/assets/rules/dependencies.md +66 -0
- package/assets/rules/doc-style.md +103 -0
- package/assets/rules/entity-conventions.md +78 -0
- package/assets/rules/entity-models.md +70 -0
- package/assets/rules/git-workflow.azure.md +116 -0
- package/assets/rules/git-workflow.github.md +123 -0
- package/assets/rules/git-workflow.gitlab.md +113 -0
- package/assets/rules/lib-layers.md +80 -0
- package/assets/rules/lists.md +73 -0
- package/assets/rules/navigation.md +78 -0
- package/assets/rules/ownership-scope.md +63 -0
- package/assets/rules/permissions.md +70 -0
- package/assets/rules/platform-access.md +77 -0
- package/assets/rules/pricing.md +64 -0
- package/assets/rules/reuse-first.md +83 -0
- package/assets/rules/seo.md +71 -0
- package/assets/rules/shared-code.md +70 -0
- package/assets/rules/spec-driven.md +135 -0
- package/assets/rules/styling-bem.md +74 -0
- package/assets/rules/task-flow.md +110 -0
- package/assets/rules/testing.md +100 -0
- package/assets/rules/translations.md +69 -0
- package/assets/rules/typescript-conventions.md +76 -0
- package/assets/skills/agent-kit.md +81 -0
- package/assets/skills/write-a-skill.md +108 -0
- package/assets/templates/gate-map.sh +45 -0
- package/assets/templates/implementation.md +44 -0
- package/assets/templates/pattern.md +5 -1
- package/assets/templates/project.sh +54 -0
- package/assets/templates/rule.md +12 -23
- package/assets/variants.json +20 -0
- package/assets/workflows/feature.js +134 -0
- package/assets/workflows/plan.js +150 -0
- package/bin/agent-kit.d.ts.map +1 -1
- package/bin/agent-kit.js +78 -5
- package/bin/agent-kit.js.map +1 -1
- package/bin/prompt.d.ts +5 -0
- package/bin/prompt.d.ts.map +1 -1
- package/bin/prompt.js +19 -7
- package/bin/prompt.js.map +1 -1
- package/index.d.ts +1 -0
- package/index.d.ts.map +1 -1
- package/index.js +1 -0
- package/index.js.map +1 -1
- package/lib/assets.d.ts +14 -1
- package/lib/assets.d.ts.map +1 -1
- package/lib/assets.js +23 -2
- package/lib/assets.js.map +1 -1
- package/lib/catalog.d.ts +52 -5
- package/lib/catalog.d.ts.map +1 -1
- package/lib/catalog.js +104 -16
- package/lib/catalog.js.map +1 -1
- package/lib/commands.d.ts +22 -1
- package/lib/commands.d.ts.map +1 -1
- package/lib/commands.js +218 -11
- package/lib/commands.js.map +1 -1
- package/lib/companion.d.ts +57 -0
- package/lib/companion.d.ts.map +1 -0
- package/lib/companion.js +60 -0
- package/lib/companion.js.map +1 -0
- package/lib/config.d.ts +42 -2
- package/lib/config.d.ts.map +1 -1
- package/lib/config.js +60 -2
- package/lib/config.js.map +1 -1
- package/lib/freshness.d.ts +14 -0
- package/lib/freshness.d.ts.map +1 -0
- package/lib/freshness.js +116 -0
- package/lib/freshness.js.map +1 -0
- package/lib/hooks-map.d.ts +24 -0
- package/lib/hooks-map.d.ts.map +1 -0
- package/lib/hooks-map.js +72 -0
- package/lib/hooks-map.js.map +1 -0
- package/lib/integrity.d.ts +36 -0
- package/lib/integrity.d.ts.map +1 -0
- package/lib/integrity.js +44 -0
- package/lib/integrity.js.map +1 -0
- package/lib/picker.d.ts +11 -1
- package/lib/picker.d.ts.map +1 -1
- package/lib/picker.js +44 -6
- package/lib/picker.js.map +1 -1
- package/lib/stamp.d.ts +2 -5
- package/lib/stamp.d.ts.map +1 -1
- package/lib/stamp.js +25 -10
- package/lib/stamp.js.map +1 -1
- package/lib/sync.d.ts +29 -0
- package/lib/sync.d.ts.map +1 -1
- package/lib/sync.js +78 -4
- package/lib/sync.js.map +1 -1
- package/lib/variants.d.ts +44 -0
- package/lib/variants.d.ts.map +1 -0
- package/lib/variants.js +82 -0
- package/lib/variants.js.map +1 -0
- package/package.json +1 -1
- package/rt-tools-agent-kit-0.4.0.tgz +0 -0
- package/assets/laws/admin-lists.md +0 -35
- package/assets/laws/admin-navigation.md +0 -38
- package/rt-tools-agent-kit-0.2.0.tgz +0 -0
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Сверка очереди работ с тем, что закон о поставке требует от задачи и её отчёта.
|
|
4
|
+
*
|
|
5
|
+
* Гард поставки отбивает промах в момент, когда его совершают, но действует только
|
|
6
|
+
* на команды агента: тикет, заведённый мимо него, и PR, открытый руками, он не
|
|
7
|
+
* видит. Эта сверка отвечает на другой вопрос — «а вся ли очередь в порядке
|
|
8
|
+
* сейчас», — и потому смотрит на состояние, а не на команду.
|
|
9
|
+
*
|
|
10
|
+
* Судит только открытое. Закрытые задачи и влитые PR — это история: из сорока
|
|
11
|
+
* последних влитых PR тридцать шесть пришли с веток, за которыми задачи не стояло,
|
|
12
|
+
* и красная навсегда проверка ничем не отличалась бы от её отсутствия.
|
|
13
|
+
*
|
|
14
|
+
* Имя ветки не судит. У открытого PR его не переименовать — это новая ветка и новый
|
|
15
|
+
* PR, — а проверка, требующая невыполнимого, обходится, а не исполняется. Имя ветки
|
|
16
|
+
* стережёт гард в момент `git checkout -b` и `gh pr create`.
|
|
17
|
+
*
|
|
18
|
+
* Колонку задачи судит по её отчёту: открытый PR означает разбор, отсутствие — нет.
|
|
19
|
+
* Момент, когда задачу берут в работу, отсюда не виден вовсе — ветки на борде нет, —
|
|
20
|
+
* и «In progress» здесь не требуется ни от кого.
|
|
21
|
+
*
|
|
22
|
+
* Нет сети или нет токена — код возврата ноль: проверка, падающая в самолёте,
|
|
23
|
+
* перестаёт что-либо значить.
|
|
24
|
+
*
|
|
25
|
+
* Ненулевой код возврата и перечень расхождений.
|
|
26
|
+
*/
|
|
27
|
+
import { existsSync, readdirSync, statSync } from 'node:fs';
|
|
28
|
+
import { join } from 'node:path';
|
|
29
|
+
|
|
30
|
+
import {
|
|
31
|
+
IN_REVIEW_STATUS,
|
|
32
|
+
OfflineError,
|
|
33
|
+
STATUS_OPTIONS,
|
|
34
|
+
TASK_KEY,
|
|
35
|
+
botToken,
|
|
36
|
+
fetchBoard,
|
|
37
|
+
fetchIssues,
|
|
38
|
+
fetchOpenPulls,
|
|
39
|
+
numberFromTitle,
|
|
40
|
+
} from './board.mjs';
|
|
41
|
+
import { CONFIG, ROOT } from './rt-kit-checks.config.mjs';
|
|
42
|
+
|
|
43
|
+
const IN_REVIEW = STATUS_OPTIONS[IN_REVIEW_STATUS].name;
|
|
44
|
+
const TASKS_DIR = join(ROOT, CONFIG.tasksDir);
|
|
45
|
+
/** Возраст брошенного черновика, после которого он перестаёт выглядеть начатым сегодня. */
|
|
46
|
+
const DRAFT_DAYS = 7;
|
|
47
|
+
|
|
48
|
+
const problems = [];
|
|
49
|
+
const report = (message) => problems.push(message);
|
|
50
|
+
|
|
51
|
+
function taskDirs() {
|
|
52
|
+
if (!existsSync(TASKS_DIR)) {
|
|
53
|
+
return [];
|
|
54
|
+
}
|
|
55
|
+
return readdirSync(TASKS_DIR, { withFileTypes: true })
|
|
56
|
+
.filter((entry) => entry.isDirectory() && entry.name !== '_template')
|
|
57
|
+
.map((entry) => entry.name);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Разбор просьбы владельца идёт до заведения задачи и лежит в `_draft-<slug>`. Заглохший на
|
|
62
|
+
* середине, он остаётся на диске вне истории и выглядит так же, как начатый сегодня: второй
|
|
63
|
+
* заход берётся за то же самое заново. Возраст здесь — единственный доступный признак:
|
|
64
|
+
* задачи за черновиком ещё нет, и спросить о нём некого.
|
|
65
|
+
*/
|
|
66
|
+
function checkDrafts() {
|
|
67
|
+
const now = Date.now();
|
|
68
|
+
for (const name of taskDirs()) {
|
|
69
|
+
if (!name.startsWith('_draft-')) {
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
const age = Math.floor((now - statSync(join(TASKS_DIR, name)).mtimeMs) / 86400000);
|
|
73
|
+
if (age >= DRAFT_DAYS) {
|
|
74
|
+
report(`docs/tasks/${name}/: разбор брошен ${age} дн. назад — заведи задачу или удали папку`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function closesNumbers(body) {
|
|
80
|
+
return [...String(body ?? '').matchAll(/\bCloses\s+#(\d+)\b/gi)].map((match) => Number(match[1]));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
let checked = { issues: 0, pulls: 0 };
|
|
84
|
+
|
|
85
|
+
// Черновики судятся по диску и потому проверяются всегда: связи для этого не нужно.
|
|
86
|
+
checkDrafts();
|
|
87
|
+
|
|
88
|
+
let offline = false;
|
|
89
|
+
try {
|
|
90
|
+
const options = { token: botToken() ?? undefined };
|
|
91
|
+
const board = fetchBoard(options);
|
|
92
|
+
const issues = fetchIssues('all', options);
|
|
93
|
+
const open = issues.filter((issue) => issue.state === 'OPEN');
|
|
94
|
+
const pulls = fetchOpenPulls(options);
|
|
95
|
+
checked = { issues: issues.length, pulls: pulls.length };
|
|
96
|
+
|
|
97
|
+
for (const item of board.foreign) {
|
|
98
|
+
report(`борда: ${item} — на борде стоят задачи, а не отчёты о них`);
|
|
99
|
+
}
|
|
100
|
+
// Борду проверяем только у открытых задач. Закрытая ушла из очереди мержем, и колонки под
|
|
101
|
+
// неё у борды нет: строку о ней нечем закрыть. Шесть таких строк висели в каждом прогоне и
|
|
102
|
+
// заглушали собой настоящую задачу, которая на борду не попала.
|
|
103
|
+
for (const issue of open) {
|
|
104
|
+
if (!board.issues.has(issue.number)) {
|
|
105
|
+
report(`#${issue.number}: задачи нет на борде — за ней никто не следит`);
|
|
106
|
+
}
|
|
107
|
+
if (numberFromTitle(issue.title) !== issue.number) {
|
|
108
|
+
report(`#${issue.number}: заголовок не начинается с [${TASK_KEY}-${issue.number}] — «${issue.title}»`);
|
|
109
|
+
}
|
|
110
|
+
if (issue.assignees.length === 0) {
|
|
111
|
+
report(`#${issue.number}: у задачи нет исполнителя — по очереди работ не видно, кто её взял`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const openNumbers = new Set(open.map((issue) => issue.number));
|
|
116
|
+
const claimed = new Map();
|
|
117
|
+
for (const pull of pulls) {
|
|
118
|
+
const titleNumber = numberFromTitle(pull.title);
|
|
119
|
+
if (titleNumber === null) {
|
|
120
|
+
report(`PR #${pull.number}: заголовок не начинается с [${TASK_KEY}-<номер>] — «${pull.title}»`);
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
if (!closesNumbers(pull.body).includes(titleNumber)) {
|
|
124
|
+
report(`PR #${pull.number}: в теле нет строки «Closes #${titleNumber}» — на борде он не прикрепится к задаче`);
|
|
125
|
+
}
|
|
126
|
+
if (!openNumbers.has(titleNumber)) {
|
|
127
|
+
report(`PR #${pull.number}: задачи #${titleNumber} нет среди открытых — у задачи одна ветка`);
|
|
128
|
+
}
|
|
129
|
+
if (claimed.has(titleNumber)) {
|
|
130
|
+
report(`PR #${pull.number}: задачу #${titleNumber} уже закрывает PR #${claimed.get(titleNumber)} — у задачи одна ветка`);
|
|
131
|
+
} else {
|
|
132
|
+
claimed.set(titleNumber, pull.number);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Колонка задачи и её отчёт сверяются в обе стороны: открытый PR при задаче в «Backlog»
|
|
137
|
+
// читается как работа, к которой не приступали, а «In review» без открытого PR — как
|
|
138
|
+
// разбор, которого никто не ждёт.
|
|
139
|
+
for (const issue of open) {
|
|
140
|
+
const status = board.items.get(issue.number)?.status ?? null;
|
|
141
|
+
const pull = claimed.get(issue.number);
|
|
142
|
+
if (pull !== undefined && status !== IN_REVIEW) {
|
|
143
|
+
report(
|
|
144
|
+
`#${issue.number}: отчёт PR #${pull} открыт, а задача стоит «${status ?? 'вне колонок'}» — npm run task:move -- ${issue.number} ${IN_REVIEW_STATUS}`
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
if (pull === undefined && status === IN_REVIEW) {
|
|
148
|
+
report(`#${issue.number}: задача ждёт разбора, но открытого PR за ней нет — колонка отстала от работы`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Папка задачи умирает вместе с задачей: то, что объясняет состоявшееся решение, уезжает
|
|
153
|
+
// в `docs/archive/`, остальное удаляется. Оставленная рядом с текущими, она читается как
|
|
154
|
+
// текущая — тем убедительнее, чем старше.
|
|
155
|
+
for (const name of taskDirs()) {
|
|
156
|
+
const number = Number(new RegExp(`^${TASK_KEY}-(\\d+)-`).exec(name)?.[1]);
|
|
157
|
+
if (!Number.isInteger(number) || openNumbers.has(number)) {
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
if (issues.some((issue) => issue.number === number)) {
|
|
161
|
+
report(`docs/tasks/${name}/: задача #${number} закрыта, а папка лежит среди текущих — разбери её`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
} catch (error) {
|
|
165
|
+
if (error instanceof OfflineError) {
|
|
166
|
+
// Очередь работ без связи не проверить, а папки на диске — проверить: молчать про
|
|
167
|
+
// них значило бы терять единственное, что здесь ещё можно сказать.
|
|
168
|
+
console.log(`check-board: очередь работ пропущена, проверять нечем — ${error.message}`);
|
|
169
|
+
offline = true;
|
|
170
|
+
} else {
|
|
171
|
+
console.error(`check-board: ${String(error.message ?? error)}`);
|
|
172
|
+
process.exit(1);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (problems.length > 0) {
|
|
177
|
+
console.error(`check-board: расхождений ${problems.length}\n`);
|
|
178
|
+
problems.forEach((problem) => console.error(` ${problem}`));
|
|
179
|
+
console.error('\nЗадача заводится командой npm run task:new — она делает все четыре шага сразу.');
|
|
180
|
+
console.error('Папка задачи и её разбор — скил task-flow.');
|
|
181
|
+
process.exit(1);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (offline) {
|
|
185
|
+
process.exit(0);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
console.log(`check-board: задач ${checked.issues}, открытых PR ${checked.pulls}, расхождений нет`);
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Проверка того, что пути, названные в документации, существуют.
|
|
4
|
+
*
|
|
5
|
+
* Документ, ссылающийся на исчезнувший файл, хуже отсутствующего: он выглядит
|
|
6
|
+
* действующей справкой и уводит читателя в каталог, которого нет. Накапливается
|
|
7
|
+
* это молча — перекладка дерева правит код и ломает текст, а текст никто не
|
|
8
|
+
* собирает. К моменту заведения проверки из 159 путей, названных в `docs/`,
|
|
9
|
+
* не существовало 80.
|
|
10
|
+
*
|
|
11
|
+
* Считаются только пути в обратных кавычках и вне блоков кода: в блоках лежат
|
|
12
|
+
* команды и вывод, где `dist/apps/api/main.js` — результат сборки, а не файл
|
|
13
|
+
* репозитория. Шаблоны (`*`, `<…>`, `{…}`) пропускаются: это форма пути, а не путь.
|
|
14
|
+
*
|
|
15
|
+
* Документы, которые по устройству говорят о несуществующем — планы будущего и
|
|
16
|
+
* архив, — перечислены в tools/doc-paths-allowlist.json.
|
|
17
|
+
*
|
|
18
|
+
* Ненулевой код возврата и перечень расхождений.
|
|
19
|
+
*/
|
|
20
|
+
import { spawnSync } from 'node:child_process';
|
|
21
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
22
|
+
import { join } from 'node:path';
|
|
23
|
+
|
|
24
|
+
import { allowlistOf, CONFIG, ROOT } from './rt-kit-checks.config.mjs';
|
|
25
|
+
|
|
26
|
+
const ALLOWLIST = allowlistOf('doc-paths');
|
|
27
|
+
// `worktrees` — копии репозитория под `.claude/`: их документы описывают раскладку своей
|
|
28
|
+
// ветки, а проверка ищет пути в дереве текущей. Одна брошенная копия дала 73 расхождения и
|
|
29
|
+
// красный сквозной прогон на ветке, которая её не заводила.
|
|
30
|
+
const SKIPPED_DIRS = CONFIG.skippedDirs;
|
|
31
|
+
/**
|
|
32
|
+
* Архив описывает раскладку, бывшую на момент записи. Править в нём пути — значит
|
|
33
|
+
* переписывать историю задним числом, поэтому он выведен из проверки целиком.
|
|
34
|
+
*/
|
|
35
|
+
const ARCHIVE_DIR = CONFIG.archiveDir;
|
|
36
|
+
/** Расширения, по которым строка в кавычках считается путём, а не именем сущности */
|
|
37
|
+
const EXTENSIONS = 'ts|mts|cts|js|mjs|cjs|json|jsonc|scss|css|html|proto|conf|ya?ml|sh|md|sql|txt|xml|svg|webp|png|ico|env|Dockerfile|lock';
|
|
38
|
+
const PATH_IN_BACKTICKS = new RegExp(`\`([^\`\\n]+?\\.(?:${EXTENSIONS}))\``, 'g');
|
|
39
|
+
|
|
40
|
+
const problems = [];
|
|
41
|
+
const report = (doc, line, path) => problems.push(`${doc}:${line}: нет файла \`${path}\``);
|
|
42
|
+
|
|
43
|
+
function readAllowlist() {
|
|
44
|
+
const path = join(ROOT, ALLOWLIST);
|
|
45
|
+
if (!existsSync(path)) {
|
|
46
|
+
return { files: [], paths: [] };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const raw = JSON.parse(readFileSync(path, 'utf8'));
|
|
50
|
+
|
|
51
|
+
return { files: raw.files ?? [], paths: raw.paths ?? [] };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function collectDocs(dir = '.') {
|
|
55
|
+
const entries = readdirSync(join(ROOT, dir), { withFileTypes: true });
|
|
56
|
+
const found = [];
|
|
57
|
+
|
|
58
|
+
for (const entry of entries) {
|
|
59
|
+
const relativePath = dir === '.' ? entry.name : `${dir}/${entry.name}`;
|
|
60
|
+
if (entry.isDirectory()) {
|
|
61
|
+
if (!SKIPPED_DIRS.includes(entry.name)) {
|
|
62
|
+
found.push(...collectDocs(relativePath));
|
|
63
|
+
}
|
|
64
|
+
} else if (entry.name.endsWith('.md')) {
|
|
65
|
+
found.push(relativePath);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return found.sort();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Документы, которые в репозиторий не попадут: личный черновик, лежащий в дереве и
|
|
74
|
+
* закрытый `.gitignore` или `.git/info/exclude`. Проверка судит репозиторий, а не рабочий
|
|
75
|
+
* стол того, кто её запустил: мёртвая ссылка в чужом черновике держала гейт пуша, хотя ни
|
|
76
|
+
* в одну ветку этот файл не едет.
|
|
77
|
+
*/
|
|
78
|
+
function droppedByGit(docs) {
|
|
79
|
+
if (docs.length === 0) {
|
|
80
|
+
return new Set();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// `--stdin` вместо аргументов: список длиннее ограничения командной строки
|
|
84
|
+
const ignored = spawnSync('git', ['check-ignore', '--stdin'], {
|
|
85
|
+
cwd: ROOT,
|
|
86
|
+
encoding: 'utf8',
|
|
87
|
+
input: docs.join('\n'),
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// Нет git — судить нечем, и проверка остаётся строже нужного
|
|
91
|
+
return new Set((ignored.stdout ?? '').split('\n').filter(Boolean));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Верхний уровень дерева. Проверяются только адреса, укоренённые в нём: `src/index.ts`
|
|
96
|
+
* в правиле означает «любой файл такого вида», а `services/theme/theme.service.ts` —
|
|
97
|
+
* обрывок чужого пути. Ни то, ни другое адресом не является, и требовать их
|
|
98
|
+
* существования значит ловить форму записи вместо ссылки.
|
|
99
|
+
*/
|
|
100
|
+
const ROOTED_IN = new Set(
|
|
101
|
+
readdirSync(ROOT, { withFileTypes: true })
|
|
102
|
+
.filter((entry) => entry.isDirectory() && !SKIPPED_DIRS.includes(entry.name))
|
|
103
|
+
.map((entry) => entry.name)
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Путь ли это вообще. Отсекается всё, что описывает форму, а не адрес: шаблоны,
|
|
108
|
+
* URL и пакеты npm.
|
|
109
|
+
*/
|
|
110
|
+
function looksLikePath(candidate) {
|
|
111
|
+
if (/[*<>{}$|\s]|\.\.\.|…/.test(candidate)) {
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
if (/^(https?:|@|~|\/)/.test(candidate)) {
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return ROOTED_IN.has(candidate.split('/')[0]);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function checkDoc(doc, allowed) {
|
|
122
|
+
const lines = readFileSync(join(ROOT, doc), 'utf8').split('\n');
|
|
123
|
+
let insideFence = false;
|
|
124
|
+
|
|
125
|
+
lines.forEach((line, index) => {
|
|
126
|
+
if (/^\s*(```|~~~)/.test(line)) {
|
|
127
|
+
insideFence = !insideFence;
|
|
128
|
+
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
if (insideFence) {
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
for (const [, candidate] of line.matchAll(PATH_IN_BACKTICKS)) {
|
|
136
|
+
if (!looksLikePath(candidate) || allowed.has(candidate)) {
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
if (!existsSync(join(ROOT, candidate))) {
|
|
140
|
+
report(doc, index + 1, candidate);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const allowlist = readAllowlist();
|
|
147
|
+
const allowedPaths = new Set(allowlist.paths);
|
|
148
|
+
const collected = collectDocs().filter((doc) => !allowlist.files.includes(doc) && !doc.startsWith(ARCHIVE_DIR));
|
|
149
|
+
const dropped = droppedByGit(collected);
|
|
150
|
+
const docs = collected.filter((doc) => !dropped.has(doc));
|
|
151
|
+
|
|
152
|
+
docs.forEach((doc) => checkDoc(doc, allowedPaths));
|
|
153
|
+
|
|
154
|
+
if (problems.length > 0) {
|
|
155
|
+
console.error(`check-doc-paths: расхождений ${problems.length}\n`);
|
|
156
|
+
problems.forEach((problem) => console.error(` ${problem}`));
|
|
157
|
+
console.error(
|
|
158
|
+
`\nЛибо путь устарел и его надо поправить, либо документ описывает ещё не созданное —\nтогда он вносится в ${ALLOWLIST}.`
|
|
159
|
+
);
|
|
160
|
+
process.exit(1);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
console.log(`check-doc-paths: проверено документов ${docs.length}, расхождений нет`);
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Проверка того, что образец не написан второй раз.
|
|
4
|
+
*
|
|
5
|
+
* Повтор заводится молча: домен пишет своё перечисление статусов, потому что
|
|
6
|
+
* чужое лежит в либе, которая ему не видна, — и дальше два набора расходятся
|
|
7
|
+
* по одному значению за раз. Ни линт, ни сборка, ни тесты этого не видят:
|
|
8
|
+
* каждая копия сама по себе исправна.
|
|
9
|
+
*
|
|
10
|
+
* Ловится двумя признаками:
|
|
11
|
+
*
|
|
12
|
+
* 1. Одно имя экспортировано из двух разных либ. Имя совпадает не случайно —
|
|
13
|
+
* его выбирали под одно и то же понятие.
|
|
14
|
+
* 2. Два перечисления с одинаковым набором членов под разными именами.
|
|
15
|
+
* Имя разошлось, а понятие осталось одно.
|
|
16
|
+
* 3. Настройка под одним именем объявлена в двух либах. Экспорта у неё нет,
|
|
17
|
+
* поэтому первый признак её не видел: `DEFAULT_PAGE_SIZE` разошёлся на
|
|
18
|
+
* шесть объявлений, и одно из них успело стать `25` против `20` у
|
|
19
|
+
* остальных. Считаются только имена вида `SCREAMING_SNAKE_CASE` — форма,
|
|
20
|
+
* которой в этом коде записывают настройки, а не местные переменные.
|
|
21
|
+
* 4. Перечисление, повторяющее набор из внешнего пакета. Наборы `@rt-tools/utils`
|
|
22
|
+
* читаются из его `.d.ts` наравне с либами: копия оператора условия и
|
|
23
|
+
* направления порядка лежала на бэкенде дословно, а признак 2 сравнивал
|
|
24
|
+
* только либы между собой и до `node_modules` не доходил.
|
|
25
|
+
*
|
|
26
|
+
* Сравнение членов идёт по нижнему регистру без подчёркиваний, поэтому
|
|
27
|
+
* `NOT_EQUALS` контракта и `NotEquals` приложения считаются одним членом.
|
|
28
|
+
*
|
|
29
|
+
* Ключ повтора несёт и список либ: без него третья копия уже известного имени
|
|
30
|
+
* проходила молча — состав менялся, а ключ оставался прежним.
|
|
31
|
+
*
|
|
32
|
+
* Накопленное к моменту заведения проверки лежит в tools/dupes-allowlist.json
|
|
33
|
+
* и отказом не считается: гейт падает на НОВОМ повторе, а старое остаётся
|
|
34
|
+
* видимым числом в сводке. Разбор долга идёт отдельными задачами — иначе
|
|
35
|
+
* проверку пришлось бы заводить вместе с правкой сорока мест.
|
|
36
|
+
*
|
|
37
|
+
* Ненулевой код возврата и перечень расхождений.
|
|
38
|
+
*/
|
|
39
|
+
import { readFileSync, readdirSync } from 'node:fs';
|
|
40
|
+
import { join } from 'node:path';
|
|
41
|
+
|
|
42
|
+
import { allowlistOf, CONFIG, ROOT } from './rt-kit-checks.config.mjs';
|
|
43
|
+
|
|
44
|
+
const ALLOWLIST = allowlistOf('dupes');
|
|
45
|
+
/**
|
|
46
|
+
* Где ищутся повторы. Корни берутся из настройки: зашитое имя молча не находило ни одного файла
|
|
47
|
+
* у дерева, которое держит код иначе, и проверка зеленела на пустом обходе.
|
|
48
|
+
*/
|
|
49
|
+
const SOURCE_ROOTS = CONFIG.sourceRoots;
|
|
50
|
+
/**
|
|
51
|
+
* `gen` и `generated` — контракт и клиент Prisma: их объявления и есть источник, с
|
|
52
|
+
* которым сверяются остальные. Считать их копией значит требовать правки того, что
|
|
53
|
+
* пересобирается генератором.
|
|
54
|
+
*/
|
|
55
|
+
const SKIPPED_DIRS = CONFIG.skippedDirs;
|
|
56
|
+
/** Минимум членов, при котором совпадение набора перечислений о чём-то говорит */
|
|
57
|
+
const MIN_ENUM_MEMBERS = 2;
|
|
58
|
+
|
|
59
|
+
const allowlist = JSON.parse(readFileSync(join(ROOT, ALLOWLIST), 'utf8'));
|
|
60
|
+
const known = new Set([...(allowlist.accepted ?? []), ...(allowlist.debt ?? [])]);
|
|
61
|
+
const debt = new Set(allowlist.debt ?? []);
|
|
62
|
+
|
|
63
|
+
function collectFiles(dir) {
|
|
64
|
+
const files = [];
|
|
65
|
+
for (const entry of readdirSync(join(ROOT, dir), { withFileTypes: true })) {
|
|
66
|
+
if (SKIPPED_DIRS.includes(entry.name)) {
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
const path = `${dir}/${entry.name}`;
|
|
70
|
+
if (entry.isDirectory()) {
|
|
71
|
+
files.push(...collectFiles(path));
|
|
72
|
+
} else if (entry.name.endsWith('.ts') && !entry.name.endsWith('.spec.ts')) {
|
|
73
|
+
files.push(path);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return files;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Корень либы: путь до каталога `src`. Повтор внутри одной либы повтором не считается */
|
|
80
|
+
function libOf(path) {
|
|
81
|
+
const parts = path.split('/');
|
|
82
|
+
const at = parts.indexOf('src');
|
|
83
|
+
return at === -1 ? path : parts.slice(0, at).join('/');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const EXPORT_RE = /^export (?:const|function|interface|enum|type|class|abstract class) (\w+)/gm;
|
|
87
|
+
const ENUM_RE = /export (?:declare )?enum (\w+)\s*\{([^}]*)\}/g;
|
|
88
|
+
const MEMBER_RE = /(\w+)\s*=/g;
|
|
89
|
+
/**
|
|
90
|
+
* Настройка-число: имя в верхнем регистре и значение из одних чисел — предел,
|
|
91
|
+
* размер, длительность. Строки и таблицы соответствий сюда не подпадают
|
|
92
|
+
* намеренно: `LOG_CONTEXT` объявлен в восьми либах, и у каждой он свой по смыслу,
|
|
93
|
+
* а совпадение имени числа означает совпадение понятия.
|
|
94
|
+
*/
|
|
95
|
+
const SETTING_RE = /^(?:export )?const ([A-Z][A-Z0-9_]*)\s*(?::\s*number\s*)?=\s*\d[\d\s*+\-/_.]*;/gm;
|
|
96
|
+
/**
|
|
97
|
+
* Настройка-строка и настройка-список. Сверяются по значению, а не по имени: одно имя
|
|
98
|
+
* значит здесь разное — `BEM_BLOCK` объявлен в тридцати шести либах, `LOG_CONTEXT` в
|
|
99
|
+
* десяти, и у каждой либы своё. Одинаковое значение под разными именами — наоборот,
|
|
100
|
+
* почти всегда одно понятие, записанное дважды: имя события правки, код отказа о повторе,
|
|
101
|
+
* набор принимаемых типов вложений.
|
|
102
|
+
*/
|
|
103
|
+
const STRING_SETTING_RE =
|
|
104
|
+
/^(?:export )?const ([A-Z][A-Z0-9_]*)\s*(?::[^=]*)?=\s*('[^']*'|"[^"]*"|`[^`]*`|\[[^\]]*\])\s*(?:as const\s*)?;/gm;
|
|
105
|
+
/** Таблица соответствий: перевод статуса в контракт, колонки в поля сортировки, ключи подписей */
|
|
106
|
+
const TABLE_RE = /^(?:export )?const ([A-Z][A-Z0-9_]*)\s*(?::[^=]*)?=\s*\{([^}]*?)\}\s*(?:as const\s*)?;/gms;
|
|
107
|
+
const PAIR_RE = /([\w'"[\].]+)\s*:\s*([^,\n]+)/g;
|
|
108
|
+
/**
|
|
109
|
+
* Значение короче этого о совпадении понятий не говорит: пустая строка, дефис и `id`
|
|
110
|
+
* совпадают у всех подряд.
|
|
111
|
+
*/
|
|
112
|
+
const MIN_VALUE_LENGTH = 4;
|
|
113
|
+
/** Минимум пар, при котором совпадение таблиц о чём-то говорит */
|
|
114
|
+
const MIN_TABLE_PAIRS = 2;
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Пакеты, чьи наборы считаются наравне с либами. Своё перечисление под уже
|
|
118
|
+
* объявленный там набор — такая же копия, как и между двумя либами.
|
|
119
|
+
*/
|
|
120
|
+
const EXTERNAL_ENUM_SOURCES = ['node_modules/@rt-tools/utils/esm/lib/interfaces'];
|
|
121
|
+
|
|
122
|
+
const exportsByName = new Map();
|
|
123
|
+
const settingsByName = new Map();
|
|
124
|
+
const enums = [];
|
|
125
|
+
/** Строковые настройки и списки под ключом значения: `значение → [{ имя, либа }]` */
|
|
126
|
+
const namesByValue = new Map();
|
|
127
|
+
/** Таблицы соответствий: набор пар под ключом, имя и либа рядом */
|
|
128
|
+
const tables = [];
|
|
129
|
+
|
|
130
|
+
/** Значение без кавычек и пробелов; список — отсортированным набором членов */
|
|
131
|
+
function valueOf(raw) {
|
|
132
|
+
if (raw.startsWith('[')) {
|
|
133
|
+
const items = [...raw.matchAll(/'([^']*)'|"([^"]*)"|`([^`]*)`/g)].map((item) => item[1] ?? item[2] ?? item[3]);
|
|
134
|
+
|
|
135
|
+
return items.length > 0 ? `[${[...items].sort().join('|')}]` : '';
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return raw.slice(1, -1).trim();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function collectEnums(text, lib) {
|
|
142
|
+
for (const match of text.matchAll(ENUM_RE)) {
|
|
143
|
+
const members = [...match[2].matchAll(MEMBER_RE)].map((member) => member[1].toLowerCase().replaceAll('_', ''));
|
|
144
|
+
enums.push({ name: match[1], lib, members: new Set(members) });
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
for (const path of SOURCE_ROOTS.flatMap((root) => collectFiles(root))) {
|
|
149
|
+
const text = readFileSync(join(ROOT, path), 'utf8');
|
|
150
|
+
const lib = libOf(path);
|
|
151
|
+
|
|
152
|
+
for (const match of text.matchAll(EXPORT_RE)) {
|
|
153
|
+
const name = match[1];
|
|
154
|
+
if (!exportsByName.has(name)) {
|
|
155
|
+
exportsByName.set(name, new Set());
|
|
156
|
+
}
|
|
157
|
+
exportsByName.get(name).add(lib);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
for (const match of text.matchAll(SETTING_RE)) {
|
|
161
|
+
const name = match[1];
|
|
162
|
+
if (!settingsByName.has(name)) {
|
|
163
|
+
settingsByName.set(name, new Set());
|
|
164
|
+
}
|
|
165
|
+
settingsByName.get(name).add(lib);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
for (const [, name, raw] of text.matchAll(STRING_SETTING_RE)) {
|
|
169
|
+
const value = valueOf(raw);
|
|
170
|
+
if (value.length < MIN_VALUE_LENGTH) {
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
if (!namesByValue.has(value)) {
|
|
174
|
+
namesByValue.set(value, []);
|
|
175
|
+
}
|
|
176
|
+
namesByValue.get(value).push({ name, lib });
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
for (const [, name, body] of text.matchAll(TABLE_RE)) {
|
|
180
|
+
const pairs = [...body.matchAll(PAIR_RE)].map(
|
|
181
|
+
([, key, value]) => `${key.replaceAll(/['"[\]]/g, '')}:${value.trim().replace(/,$/, '')}`
|
|
182
|
+
);
|
|
183
|
+
if (pairs.length >= MIN_TABLE_PAIRS) {
|
|
184
|
+
tables.push({ name, lib, pairs: [...pairs].sort().join('|') });
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
collectEnums(text, lib);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
for (const dir of EXTERNAL_ENUM_SOURCES) {
|
|
192
|
+
for (const entry of readdirSync(join(ROOT, dir), { withFileTypes: true })) {
|
|
193
|
+
if (entry.isFile() && entry.name.endsWith('.d.ts')) {
|
|
194
|
+
collectEnums(readFileSync(join(ROOT, dir, entry.name), 'utf8'), dir.replace('node_modules/', ''));
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const findings = [];
|
|
200
|
+
|
|
201
|
+
for (const [name, libs] of [...exportsByName.entries()].sort()) {
|
|
202
|
+
if (libs.size < 2) {
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
const where = [...libs].sort().join(', ');
|
|
206
|
+
findings.push({ key: `export ${name} @ ${where}`, text: `${name} экспортируется из ${libs.size} либ: ${where}` });
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
for (const [name, libs] of [...settingsByName.entries()].sort()) {
|
|
210
|
+
if (libs.size < 2) {
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
const where = [...libs].sort().join(', ');
|
|
214
|
+
findings.push({ key: `setting ${name} @ ${where}`, text: `${name} объявлена в ${libs.size} либах: ${where}` });
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
for (const [value, places] of [...namesByValue.entries()].sort()) {
|
|
218
|
+
const libs = new Set(places.map((place) => place.lib));
|
|
219
|
+
if (libs.size < 2) {
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
const where = places
|
|
223
|
+
.map((place) => `${place.name} @ ${place.lib}`)
|
|
224
|
+
.sort()
|
|
225
|
+
.join(' ~ ');
|
|
226
|
+
findings.push({ key: `value ${value} @ ${where}`, text: `значение ${value} объявлено в ${libs.size} либах: ${where}` });
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
for (let i = 0; i < tables.length; i++) {
|
|
230
|
+
for (let j = i + 1; j < tables.length; j++) {
|
|
231
|
+
const [first, second] = [tables[i], tables[j]];
|
|
232
|
+
if (first.lib === second.lib || first.pairs !== second.pairs) {
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
const key = `table ${[`${first.name} @ ${first.lib}`, `${second.name} @ ${second.lib}`].sort().join(' ~ ')}`;
|
|
236
|
+
findings.push({ key, text: `${first.name} (${first.lib}) и ${second.name} (${second.lib}) — одна таблица соответствий` });
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const sameMembers = (first, second) =>
|
|
241
|
+
first.size === second.size && first.size >= MIN_ENUM_MEMBERS && [...first].every((member) => second.has(member));
|
|
242
|
+
|
|
243
|
+
for (let i = 0; i < enums.length; i++) {
|
|
244
|
+
for (let j = i + 1; j < enums.length; j++) {
|
|
245
|
+
const [first, second] = [enums[i], enums[j]];
|
|
246
|
+
if (first.lib === second.lib || first.name === second.name || !sameMembers(first.members, second.members)) {
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
const key = `enum ${[`${first.name} @ ${first.lib}`, `${second.name} @ ${second.lib}`].sort().join(' ~ ')}`;
|
|
250
|
+
findings.push({
|
|
251
|
+
key,
|
|
252
|
+
text: `${first.name} (${first.lib}) и ${second.name} (${second.lib}) — один набор членов: ${[...first.members].sort().join(', ')}`,
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const fresh = findings.filter((finding) => !known.has(finding.key));
|
|
258
|
+
const staleKeys = [...known].filter((key) => !findings.some((finding) => finding.key === key));
|
|
259
|
+
|
|
260
|
+
if (process.argv.includes('--baseline')) {
|
|
261
|
+
const keys = findings.map((finding) => finding.key).sort();
|
|
262
|
+
console.log(JSON.stringify({ ...allowlist, debt: keys }, null, 4));
|
|
263
|
+
process.exit(0);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const problems = [
|
|
267
|
+
...fresh.map((finding) => finding.text),
|
|
268
|
+
...staleKeys.map((key) => `${key}: значится в ${ALLOWLIST}, но повтора больше нет — строку убрать`),
|
|
269
|
+
];
|
|
270
|
+
|
|
271
|
+
if (problems.length > 0) {
|
|
272
|
+
console.error(`check-dupes: расхождений ${problems.length}\n`);
|
|
273
|
+
problems.forEach((problem) => console.error(` ${problem}`));
|
|
274
|
+
process.exit(1);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
console.log(`check-dupes: повторов ${findings.length}, из них принято ${findings.length - debt.size}, долг ${debt.size} — новых нет`);
|