@nitra/cursor 1.8.207 → 1.8.208

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,13 @@
4
4
 
5
5
  Формат — [Keep a Changelog](https://keepachangelog.com/uk/1.1.0/), нумерація — [SemVer](https://semver.org/lang/uk/).
6
6
 
7
+ ## [1.8.208] - 2026-05-08
8
+
9
+ ### Added
10
+
11
+ - `mdc/js-run.mdc` (1.6, з 1.5): новий розділ «Нейминг файлів у `src/conn/`» — префікси `ql-` (GraphQL endpoint), `pg-`/`mysql-` з обовʼязковим `read`/`write` режимом і опційним ідентифікатором підключення для multi-БД (`pg-read-smart.js`, `pg-write-contract.js`); якщо режим не очевидний з імені env — визначати за наявністю операцій зміни даних. Також правило про експорти в `src/conn/`: заборонено `export default`, лише іменований експорт у camelCase від назви файла (`ql-smart.js` → `export const qlSmart`, `pg-write-contract.js` → `export const pgWriteContract`).
12
+ - `scripts/utils/conn-file-rules.mjs` + інтеграція в `scripts/check-js-run.mjs` — для кожного файла всередині `#conn/` каталогу пакета перевіряє: (а) basename відповідає канону `ql-<id>` / `(pg|mysql)-(read|write)[-<id>]` (kebab-case `[a-z0-9-]`); (б) відсутній `export default`; (в) серед іменованих експортів є рівно `<camelCase(basename)>` (`pg-write-contract.js` → `pgWriteContract`). `index.*` пропускається як reexport-барель. Розпізнає `export const/let/var`, `export function`, `export class` і `export { x as Y }` через AST на oxc-parser.
13
+
7
14
  ## [1.8.207] - 2026-05-08
8
15
 
9
16
  ### Added
package/mdc/js-run.mdc CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  description: Це правила для backend проектів на JavaScript/Node.js, сюди входять і job і WEB сервери.
3
3
  alwaysApply: true
4
- version: '1.5'
4
+ version: '1.6'
5
5
  ---
6
6
 
7
7
  ## Область застосування
@@ -121,6 +121,47 @@ import { pool } from '#conn/pg.js'
121
121
  import { gql, graphQLClient } from '@nitra/graphql-request'
122
122
  ```
123
123
 
124
+ ### Нейминг файлів у `src/conn/`
125
+
126
+ Назва файла в `src/conn/` має одразу повідомляти, **до чого** підключаємось і **в якому режимі**:
127
+
128
+ - **GraphQL** — префікс `ql-`, далі ідентифікатор endpoint:
129
+ - `src/conn/ql-contract.js`
130
+ - `src/conn/ql-smart.js`
131
+ - **PostgreSQL** — префікс `pg-`, далі тип підключення (репліка vs мастер): `read` або `write`:
132
+ - `src/conn/pg-read.js`
133
+ - `src/conn/pg-write.js`
134
+ - **PostgreSQL до кількох БД** — додатково ідентифікатор підключення після типу:
135
+ - `src/conn/pg-read-smart.js`
136
+ - `src/conn/pg-write-contract.js`
137
+ - **MySQL / MSSQL** — префікс `mysql-` за тією ж схемою (`mysql-read.js`, `mysql-write-<id>.js` тощо).
138
+
139
+ Підключення до БД **обов'язково** має бути ідентифіковано як `read` (репліка) або `write` (мастер). Якщо з імені змінної оточення (наприклад, `env.PG_CONN`) це не очевидно — визнач режим за операціями в коді: якщо немає операцій зміни даних (`INSERT`/`UPDATE`/`DELETE`/DDL) — це `pg-read.js`, інакше `pg-write.js`.
140
+
141
+ ### Експорти у файлах `src/conn/`
142
+
143
+ У файлах підключень **заборонений** `export default`. Експорт має бути **іменований** і збігатися з назвою файла в camelCase.
144
+
145
+ Приклад — `src/conn/ql-smart.js`:
146
+
147
+ ```javascript title="❌ Так не можна"
148
+ export default new GraphQLClient(env.SMART_QL, {
149
+ headers: {
150
+ 'X-Hasura-Admin-Secret': env.SMART_X_HASURA_ADMIN_SECRET
151
+ }
152
+ })
153
+ ```
154
+
155
+ ```javascript title="✅ Канон: іменований експорт за іменем файла"
156
+ export const qlSmart = new GraphQLClient(env.SMART_QL, {
157
+ headers: {
158
+ 'X-Hasura-Admin-Secret': env.SMART_X_HASURA_ADMIN_SECRET
159
+ }
160
+ })
161
+ ```
162
+
163
+ Відповідно: `pg-read.js` → `export const pgRead = …`, `pg-write-contract.js` → `export const pgWriteContract = …`, `ql-contract.js` → `export const qlContract = …`.
164
+
124
165
  ## CheckEnv
125
166
 
126
167
  Усі змінні оточення, які використовуються в коді, повинні бути перевірені за допомогою `checkEnv` з пакету `@nitra/check-env`. Це гарантує, що всі необхідні змінні оточення встановлені перед запуском програми.
@@ -189,4 +230,10 @@ on:
189
230
 
190
231
  ## Перевірка
191
232
 
192
- `npx @nitra/cursor check js-run` — зокрема для кожного backend workspace-пакета з каталогом **`src/`** перевіряє наявність **`jsconfig.json`** і збіг вмісту з каноном вище.
233
+ `npx @nitra/cursor check js-run` — зокрема для кожного backend workspace-пакета з каталогом **`src/`** перевіряє наявність **`jsconfig.json`** і збіг вмісту з каноном вище. Додатково для файлів у каталозі `#conn/` (за замовчуванням `src/conn/`) перевіряється:
234
+
235
+ - **basename файла** відповідає канону: `ql-<id>` (GraphQL) / `(pg|mysql)-(read|write)[-<id>]` (БД), kebab-case `[a-z0-9-]`;
236
+ - **відсутній `export default`** — лише іменований експорт;
237
+ - **імʼя експорту** дорівнює camelCase від basename (`pg-write-contract.js` → `export const pgWriteContract`).
238
+
239
+ Файли `index.*` у conn-каталозі пропускаються як можливий reexport-барель.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nitra/cursor",
3
- "version": "1.8.207",
3
+ "version": "1.8.208",
4
4
  "description": "CLI для завантаження cursor-правил (префікс n-) у локальний репозиторій",
5
5
  "keywords": [
6
6
  "cli",
@@ -12,6 +12,11 @@
12
12
  * дозволені лише у каталозі conn (за замовчуванням `src/conn/`; за наявності
13
13
  * `package.json#imports['#conn/*']` — у його цільовому каталозі); поза ним — порушення
14
14
  * (див. `utils/conn-imports-scan.mjs`);
15
+ * - «Нейминг та експорти у `#conn/`»: всередині conn-каталогу basename файла має відповідати
16
+ * канону `ql-<id>` / `(pg|mysql)-(read|write)[-<id>]`; `export default` заборонений; має бути
17
+ * іменований експорт з імʼям, що дорівнює camelCase від basename файла (`pg-write-contract.js`
18
+ * → `export const pgWriteContract`); `index.*` як reexport-барель пропускаємо
19
+ * (див. `utils/conn-file-rules.mjs`);
15
20
  * - «process.env / CheckEnv»: пряме `process.env.X` має бути замінено на `env` —
16
21
  * з `@nitra/check-env` (для обов'язкових змінних, із `checkEnv([...])`) або з
17
22
  * `node:process` (для опційних). Коли `env` імпортовано з `@nitra/check-env`,
@@ -40,6 +45,7 @@ import {
40
45
  import { findUncheckedProcessEnvInText, isCheckEnvScanSourceFile } from './utils/check-env-scan.mjs'
41
46
  import { createCheckReporter } from './utils/check-reporter.mjs'
42
47
  import { findDepcheckViolationsForPackage, readAllWorkflowFiles } from './utils/depcheck-workflow.mjs'
48
+ import { findConnFileRuleViolations, isConnFileRulesSourceFile } from './utils/conn-file-rules.mjs'
43
49
  import {
44
50
  findConnFactoryImportsInText,
45
51
  isConnImportsScanSourceFile,
@@ -51,48 +57,6 @@ import { findPromiseSetTimeoutInText, isPromiseSetTimeoutScanSourceFile } from '
51
57
  import { walkDir } from './utils/walkDir.mjs'
52
58
  import { getMonorepoPackageRootDirs } from './utils/workspaces.mjs'
53
59
 
54
- /** Канонічний `jsconfig.json` для backend workspace-пакетів із каталогом `src/` (js-run.mdc). */
55
- const CANONICAL_BACKEND_JSCONFIG = Object.freeze({
56
- compilerOptions: Object.freeze({
57
- lib: Object.freeze(['esnext']),
58
- module: 'NodeNext',
59
- moduleResolution: 'NodeNext',
60
- target: 'esnext',
61
- checkJs: false
62
- }),
63
- include: Object.freeze(['src/**/*'])
64
- })
65
-
66
- /**
67
- * Глибока рівність для JSON-подібних значень (масиви — порядок важливий).
68
- * @param {unknown} a перше значення
69
- * @param {unknown} b друге значення
70
- * @returns {boolean} true, якщо значення структурно ідентичні
71
- */
72
- function deepEqualJson(a, b) {
73
- if (a === b) return true
74
- if (a === null || b === null || typeof a !== typeof b) return false
75
- if (typeof a !== 'object') return false
76
- if (Array.isArray(a) !== Array.isArray(b)) return false
77
- if (Array.isArray(a)) {
78
- if (a.length !== b.length) return false
79
- for (const [i, v] of a.entries()) {
80
- if (!deepEqualJson(v, b[i])) return false
81
- }
82
- return true
83
- }
84
- const ao = /** @type {Record<string, unknown>} */ (a)
85
- const bo = /** @type {Record<string, unknown>} */ (b)
86
- const keysA = Object.keys(ao).toSorted()
87
- const keysB = Object.keys(bo).toSorted()
88
- if (keysA.length !== keysB.length) return false
89
- for (const [i, k] of keysA.entries()) {
90
- if (k !== keysB[i]) return false
91
- if (!deepEqualJson(ao[k], bo[k])) return false
92
- }
93
- return true
94
- }
95
-
96
60
  /**
97
61
  * Чи існує непорожній за змістом маркер каталогу `src/` (рекомендована структура js-run).
98
62
  * @param {string} absPackageRoot абсолютний корінь пакета
@@ -108,40 +72,29 @@ function backendPackageHasSrcDir(absPackageRoot) {
108
72
  }
109
73
 
110
74
  /**
111
- * Перевіряє `jsconfig.json` для backend-пакетів із `src/`.
75
+ * FS-existence для `jsconfig.json` у backend-пакеті з каталогом `src/` (cross-file:
76
+ * наявність каталогу + файла). Структуру самого `jsconfig.json` (canonical
77
+ * compilerOptions і include) валідує `npm/policy/js_run/jsconfig/`; її прогоняє
78
+ * `bun run lint-conftest`.
112
79
  * @param {string} rootDir відносний шлях workspace
113
80
  * @param {string} absPackageRoot абсолютний корінь пакета
114
81
  * @param {string} label префікс `[pkg] `
115
82
  * @param {(msg: string) => void} fail callback для повідомлень про порушення
116
83
  * @param {(msg: string) => void} passFn callback для повідомлень про успішну перевірку
117
- * @returns {Promise<void>}
84
+ * @returns {void}
118
85
  */
119
- async function checkBackendJsconfigWhenSrcPresent(rootDir, absPackageRoot, label, fail, passFn) {
86
+ function checkBackendJsconfigWhenSrcPresent(rootDir, absPackageRoot, label, fail, passFn) {
120
87
  if (!backendPackageHasSrcDir(absPackageRoot)) return
121
88
 
122
89
  const jcPath = join(rootDir, 'jsconfig.json')
123
- if (!existsSync(jcPath)) {
90
+ if (existsSync(jcPath)) {
91
+ passFn(`${label}jsconfig.json є (структуру перевіряє bun run lint-conftest → js_run.jsconfig)`)
92
+ } else {
124
93
  fail(
125
94
  `${label}є каталог src/, але немає jsconfig.json — додай канонічний файл з js-run.mdc ` +
126
95
  `(NodeNext, include: src/**/*).`
127
96
  )
128
- return
129
- }
130
- let parsed
131
- try {
132
- parsed = JSON.parse(await readFile(jcPath, 'utf8'))
133
- } catch {
134
- fail(`${label}jsconfig.json не вдалося розпарсити як JSON`)
135
- return
136
- }
137
- if (!deepEqualJson(parsed, CANONICAL_BACKEND_JSCONFIG)) {
138
- fail(
139
- `${label}jsconfig.json не збігається з каноном js-run.mdc — заміни на шаблон з правила ` +
140
- `(compilerOptions: lib esnext, module/moduleResolution NodeNext, target esnext, checkJs false; include: src/**/*).`
141
- )
142
- return
143
97
  }
144
- passFn(`${label}jsconfig.json узгоджено з js-run (пакет з src/)`)
145
98
  }
146
99
 
147
100
  /**
@@ -236,6 +189,52 @@ async function checkConnImports(absPackageRoot, sourcePaths, pkgJson, label, fai
236
189
  return violations
237
190
  }
238
191
 
192
+ /**
193
+ * Перевіряє правила нейминга та експортів для файлів усередині `#conn/`.
194
+ *
195
+ * Канон імені: `ql-<id>` для GraphQL, `(pg|mysql)-(read|write)[-<id>]` для БД (js-run.mdc,
196
+ * розділ «Нейминг файлів у `src/conn/`»). Експорт у файлі — лише іменований, з імʼям, що
197
+ * дорівнює camelCase від basename файла (`pg-write-contract.js` → `export const pgWriteContract`).
198
+ * @param {string} absPackageRoot абсолютний корінь пакета
199
+ * @param {string[]} sourcePaths абсолютні шляхи до файлів пакета
200
+ * @param {unknown} pkgJson розпарсений package.json пакета (або null)
201
+ * @param {string} label префікс повідомлення `[<pkg>] `
202
+ * @param {(msg: string) => void} fail callback при помилці
203
+ * @returns {Promise<number>} кількість порушень
204
+ */
205
+ async function checkConnFileNamingAndExports(absPackageRoot, sourcePaths, pkgJson, label, fail) {
206
+ const connDir = resolveConnDirFromPackageJson(pkgJson)
207
+ let violations = 0
208
+ for (const absPath of sourcePaths) {
209
+ const rel = relPosix(absPackageRoot, absPath)
210
+ if (!isInsideConnDir(rel, connDir)) continue
211
+ if (!isConnFileRulesSourceFile(rel)) continue
212
+ // пропускаємо реекспортний барель `index.*` (якщо знадобиться) і прихований .d.ts
213
+ const base = rel.slice(rel.lastIndexOf('/') + 1)
214
+ if (base.startsWith('index.')) continue
215
+
216
+ const content = await readFile(absPath, 'utf8')
217
+ for (const v of findConnFileRuleViolations(content, rel)) {
218
+ violations++
219
+ if (v.kind === 'name') {
220
+ fail(
221
+ `${label}${rel} — назва файла в '${connDir}/' не відповідає канону js-run: ` +
222
+ `'ql-<id>', 'pg-{read|write}[-<id>]' або 'mysql-{read|write}[-<id>]' (kebab-case, [a-z0-9-])`
223
+ )
224
+ } else if (v.kind === 'default-export') {
225
+ fail(`${label}${rel} — 'export default' заборонений у '${connDir}/'; зроби іменований експорт`)
226
+ } else {
227
+ const found = v.foundNames?.length ? v.foundNames.join(', ') : '—'
228
+ fail(
229
+ `${label}${rel} — очікується іменований експорт 'export const ${v.expectedName} = …' ` +
230
+ `(camelCase від назви файла); знайдено: ${found}`
231
+ )
232
+ }
233
+ }
234
+ }
235
+ return violations
236
+ }
237
+
239
238
  /**
240
239
  * Перевіряє правило «CheckEnv» для пакета.
241
240
  * @param {string} absPackageRoot абсолютний корінь пакета
@@ -323,6 +322,14 @@ async function checkWorkspacePackage(rootDir, ignorePaths, workflows, fail, pass
323
322
  passFn(`${label}імпорти підключень (bun#SQL / mssql / @nitra/graphql-request#GraphQLClient) лише в '${connDir}/'`)
324
323
  }
325
324
 
325
+ const connFileViolations = await checkConnFileNamingAndExports(absPackageRoot, sourcePaths, pkgJson, label, fail)
326
+ if (connFileViolations === 0) {
327
+ const connDir = resolveConnDirFromPackageJson(pkgJson)
328
+ passFn(
329
+ `${label}файли в '${connDir}/' дотримують канону js-run: нейминг (ql-/pg-/mysql-…) і іменований експорт у camelCase від basename`
330
+ )
331
+ }
332
+
326
333
  const envViolations = await checkProcessEnvUsage(absPackageRoot, sourcePaths, label, fail)
327
334
  if (envViolations === 0) {
328
335
  passFn(
@@ -390,15 +397,11 @@ async function loadPackageJsonAndCheckBunyanDeps(rootDir, label, fail) {
390
397
  const pkgPath = join(rootDir, 'package.json')
391
398
  if (!existsSync(pkgPath)) return null
392
399
  const pkgJson = JSON.parse(await readFile(pkgPath, 'utf8'))
393
- const deps = /** @type {Record<string, unknown>} */ (pkgJson).dependencies
394
- const devDeps = /** @type {Record<string, unknown>} */ (pkgJson).devDependencies
395
- const allDeps = { ...deps, ...devDeps }
396
- if (allDeps['@nitra/bunyan']) {
397
- fail(`${label}@nitra/bunyan знайдено — замінити на @nitra/pino`)
398
- }
399
- if (allDeps.bunyan) {
400
- fail(`${label}bunyan знайдено — замінити на @nitra/pino`)
401
- }
400
+ // Заборону `@nitra/bunyan` / `bunyan` у dependencies/devDependencies перенесено
401
+ // в Rego (`npm/policy/js_run/package_json/`); `bun run lint-conftest` запускає
402
+ // її по всіх workspace `package.json`. Тут лишилася лише AST-перевірка імпортів.
403
+ void label
404
+ void fail
402
405
  return pkgJson
403
406
  }
404
407
 
@@ -411,20 +414,15 @@ async function loadPackageJsonAndCheckBunyanDeps(rootDir, label, fail) {
411
414
  * @param {(msg: string) => void} passFn успішне повідомлення
412
415
  * @returns {Promise<void>} завершується після перевірки configmap
413
416
  */
414
- async function checkOtelConfigmap(rootDir, label, fail, passFn) {
417
+ function checkOtelConfigmap(rootDir, label, fail, passFn) {
415
418
  const configmapPath = join(rootDir, 'k8s', 'base', 'configmap.yaml')
416
419
  if (!existsSync(configmapPath)) return
417
- const content = await readFile(configmapPath, 'utf8')
418
- if (!content.includes('OTEL_RESOURCE_ATTRIBUTES')) {
419
- fail(`${label}k8s/base/configmap.yaml не містить OTEL_RESOURCE_ATTRIBUTES`)
420
- return
421
- }
422
- passFn(`${label}k8s/base/configmap.yaml містить OTEL_RESOURCE_ATTRIBUTES`)
423
- if (content.includes('service.name=') && content.includes('service.namespace=')) {
424
- passFn(`${label}OTEL_RESOURCE_ATTRIBUTES містить service.name та service.namespace`)
425
- } else {
426
- fail(`${label}OTEL_RESOURCE_ATTRIBUTES має містити service.name=<name>,service.namespace=<namespace>`)
427
- }
420
+ // Перевірку `OTEL_RESOURCE_ATTRIBUTES` має містити `service.name=` /
421
+ // `service.namespace=` перенесено в Rego (`npm/policy/js_run/configmap/`);
422
+ // `bun run lint-conftest` запускає її на всіх `k8s/base/configmap.yaml`.
423
+ void label
424
+ void fail
425
+ passFn(`${rootDir}/k8s/base/configmap.yaml є (OTEL — bun run lint-conftest → js_run.configmap)`)
428
426
  }
429
427
 
430
428
  /**
@@ -0,0 +1,170 @@
1
+ /**
2
+ * Перевірки для файлів-підключень у каталозі `#conn` (js-run.mdc → «Нейминг файлів у `src/conn/`»
3
+ * та «Експорти у файлах `src/conn/`»).
4
+ *
5
+ * Канонічна назва файла:
6
+ * - GraphQL: `ql-<id>.{js|mjs|cjs|ts|mts|cts}` (id — kebab-case ідентифікатор endpoint);
7
+ * - PostgreSQL: `pg-{read|write}.{ext}` або `pg-{read|write}-<id>.{ext}` (id — для multi-БД);
8
+ * - MySQL/MSSQL: `mysql-{read|write}.{ext}` або `mysql-{read|write}-<id>.{ext}`.
9
+ *
10
+ * Канонічний експорт — іменований, без `export default`. Імʼя константи має дорівнювати
11
+ * camelCase від basename файла (`pg-write-contract` → `pgWriteContract`).
12
+ *
13
+ * Парсимо через oxc-parser; коли файл не парситься — повертаємо порожні результати, щоб
14
+ * не змішувати помилки синтаксису з порушеннями цього правила.
15
+ */
16
+ import { parseProgramOrNull } from './ast-scan-utils.mjs'
17
+
18
+ const SOURCE_FILE_RE = /\.([cm]?[jt]sx?)$/u
19
+
20
+ /**
21
+ * Канонічний шаблон імені файла в каталозі conn.
22
+ * - `ql-<id>` для GraphQL;
23
+ * - `(pg|mysql)-(read|write)(-<id>)?` для БД.
24
+ * `<id>` — починається з [a-z0-9], далі [a-z0-9-]*.
25
+ */
26
+ const CONN_FILENAME_RE =
27
+ /^(?:ql-[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|(?:pg|mysql)-(?:read|write)(?:-[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)?)\.([cm]?[jt]sx?)$/u
28
+
29
+ /**
30
+ * Чи це файл, який сканується правилом «conn-file» (JS/TS-сімʼя, без `.d.ts`).
31
+ * @param {string} relativePathPosix відносний posix-шлях
32
+ * @returns {boolean} true, якщо потрібно перевіряти
33
+ */
34
+ export function isConnFileRulesSourceFile(relativePathPosix) {
35
+ return SOURCE_FILE_RE.test(relativePathPosix) && !relativePathPosix.endsWith('.d.ts')
36
+ }
37
+
38
+ /**
39
+ * Витягує basename файла без розширення.
40
+ * @param {string} relativePathPosix відносний шлях у posix-форматі
41
+ * @returns {string} basename без розширення (наприклад, `pg-write-contract`)
42
+ */
43
+ function basenameNoExt(relativePathPosix) {
44
+ const last = relativePathPosix.lastIndexOf('/')
45
+ const base = last >= 0 ? relativePathPosix.slice(last + 1) : relativePathPosix
46
+ const dot = base.lastIndexOf('.')
47
+ return dot > 0 ? base.slice(0, dot) : base
48
+ }
49
+
50
+ /**
51
+ * Перетворює kebab-case ідентифікатор у camelCase.
52
+ * @param {string} kebab kebab-case рядок (`pg-write-contract`)
53
+ * @returns {string} camelCase (`pgWriteContract`)
54
+ */
55
+ export function kebabToCamel(kebab) {
56
+ return kebab.replaceAll(/-([a-z0-9])/gu, (_m, c) => c.toUpperCase())
57
+ }
58
+
59
+ /**
60
+ * Чи відповідає назва файла канонічному шаблону для каталогу conn.
61
+ * @param {string} relativePathPosix відносний posix-шлях файла
62
+ * @returns {boolean} true, якщо basename + ext збігається зі схемою
63
+ */
64
+ export function isConnFileNameValid(relativePathPosix) {
65
+ const last = relativePathPosix.lastIndexOf('/')
66
+ const base = last >= 0 ? relativePathPosix.slice(last + 1) : relativePathPosix
67
+ return CONN_FILENAME_RE.test(base)
68
+ }
69
+
70
+ /**
71
+ * Збирає всі імена named-експортів у програмі.
72
+ *
73
+ * Покриває: `export const/let/var X`, `export function X`, `export class X`,
74
+ * `export { X }`, `export { X as Y }` (повертає `Y`). `export *` ігнорується
75
+ * (немає конкретного імені для звірки), `export default` обробляється окремо.
76
+ * @param {unknown} program AST root
77
+ * @returns {string[]} список експортованих імен
78
+ */
79
+ function collectNamedExportNames(program) {
80
+ /** @type {string[]} */
81
+ const out = []
82
+ if (!program || typeof program !== 'object') return out
83
+ const body = /** @type {Record<string, unknown>} */ (program).body
84
+ if (!Array.isArray(body)) return out
85
+ for (const node of body) {
86
+ if (!node || typeof node !== 'object') continue
87
+ const rec = /** @type {Record<string, unknown>} */ (node)
88
+ if (rec.type !== 'ExportNamedDeclaration') continue
89
+ const decl = /** @type {Record<string, unknown> | null} */ (rec.declaration ?? null)
90
+ if (decl) {
91
+ // export const X = ... / export let / export var
92
+ if (decl.type === 'VariableDeclaration' && Array.isArray(decl.declarations)) {
93
+ for (const d of decl.declarations) {
94
+ const id = /** @type {Record<string, unknown> | null} */ (d?.id ?? null)
95
+ if (id && id.type === 'Identifier' && typeof id.name === 'string') out.push(id.name)
96
+ }
97
+ }
98
+ // export function X / export class X
99
+ if (
100
+ (decl.type === 'FunctionDeclaration' || decl.type === 'ClassDeclaration') &&
101
+ decl.id &&
102
+ typeof decl.id === 'object' &&
103
+ typeof /** @type {Record<string, unknown>} */ (decl.id).name === 'string'
104
+ ) {
105
+ out.push(/** @type {string} */ (/** @type {Record<string, unknown>} */ (decl.id).name))
106
+ }
107
+ } else if (Array.isArray(rec.specifiers)) {
108
+ // export { X } / export { X as Y }
109
+ for (const s of rec.specifiers) {
110
+ const exported = /** @type {Record<string, unknown> | null} */ (s?.exported ?? null)
111
+ if (!exported) continue
112
+ // ESTree: Identifier (name) або Literal (value), залежно від спеки
113
+ if (exported.type === 'Identifier' && typeof exported.name === 'string') out.push(exported.name)
114
+ else if (typeof exported.value === 'string') out.push(exported.value)
115
+ }
116
+ }
117
+ }
118
+ return out
119
+ }
120
+
121
+ /**
122
+ * Чи є в програмі `export default ...`.
123
+ * @param {unknown} program AST root
124
+ * @returns {boolean} true, якщо знайдено будь-який ExportDefaultDeclaration
125
+ */
126
+ function hasDefaultExport(program) {
127
+ if (!program || typeof program !== 'object') return false
128
+ const body = /** @type {Record<string, unknown>} */ (program).body
129
+ if (!Array.isArray(body)) return false
130
+ for (const node of body) {
131
+ if (node && typeof node === 'object' && /** @type {Record<string, unknown>} */ (node).type === 'ExportDefaultDeclaration') {
132
+ return true
133
+ }
134
+ }
135
+ return false
136
+ }
137
+
138
+ /**
139
+ * Знаходить порушення правил для одного файла з каталогу conn.
140
+ *
141
+ * Якщо AST не парситься — повертає порожній масив (синтаксис падає в інших перевірках,
142
+ * не дублюємо).
143
+ * @param {string} content вихідний код файла
144
+ * @param {string} relativePathPosix відносний posix-шлях файла (від кореня пакета)
145
+ * @returns {{ kind: 'name' | 'default-export' | 'export-name', expectedName?: string, foundNames?: string[] }[]} список порушень
146
+ */
147
+ export function findConnFileRuleViolations(content, relativePathPosix) {
148
+ /** @type {{ kind: 'name' | 'default-export' | 'export-name', expectedName?: string, foundNames?: string[] }[]} */
149
+ const out = []
150
+ if (!isConnFileNameValid(relativePathPosix)) {
151
+ out.push({ kind: 'name' })
152
+ // якщо назва нестандартна — далі звірку імені експорту не робимо (camelCase двозначний)
153
+ }
154
+
155
+ const program = parseProgramOrNull(content, relativePathPosix)
156
+ if (!program) return out
157
+
158
+ if (hasDefaultExport(program)) {
159
+ out.push({ kind: 'default-export' })
160
+ }
161
+
162
+ if (out.some(v => v.kind === 'name')) return out
163
+
164
+ const expected = kebabToCamel(basenameNoExt(relativePathPosix.slice(relativePathPosix.lastIndexOf('/') + 1)))
165
+ const names = collectNamedExportNames(program)
166
+ if (!names.includes(expected)) {
167
+ out.push({ kind: 'export-name', expectedName: expected, foundNames: names })
168
+ }
169
+ return out
170
+ }