@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 +7 -0
- package/mdc/js-run.mdc +49 -2
- package/package.json +1 -1
- package/scripts/check-js-run.mjs +81 -83
- package/scripts/utils/conn-file-rules.mjs +170 -0
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.
|
|
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
package/scripts/check-js-run.mjs
CHANGED
|
@@ -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
|
-
*
|
|
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 {
|
|
84
|
+
* @returns {void}
|
|
118
85
|
*/
|
|
119
|
-
|
|
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 (
|
|
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
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
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
|
-
|
|
417
|
+
function checkOtelConfigmap(rootDir, label, fail, passFn) {
|
|
415
418
|
const configmapPath = join(rootDir, 'k8s', 'base', 'configmap.yaml')
|
|
416
419
|
if (!existsSync(configmapPath)) return
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
passFn(`${
|
|
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
|
+
}
|