@nitra/cursor 1.8.153 → 1.8.155

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/auto-rules.md CHANGED
@@ -18,11 +18,11 @@ graphql - якщо хоч в одному js або vue файлі присут
18
18
 
19
19
  js-lint - якщо присутній хоч один js файл
20
20
 
21
- js-pino - якщо присутній хоч один js файл, не в монорепо проекті з vue та директорії tempo
21
+ js-run - якщо це вкладена директорія з package.json (не в корені) та в devDependencies немає vite
22
22
 
23
23
  js-mssql - якщо в хоч одному package.json в секції dependencies присутній пакет mssql
24
24
 
25
- js-bun-db - якщо в хоч одному package.json в секції dependencies присутній пакет pg або mysql2 або є імпорт sql/SQL з Bun (приклад: import { sql } from "bun")
25
+ js-bun-db - якщо в хоч одному package.json в секції dependencies присутній пакет pg, pg-format або mysql2 або є імпорт sql/SQL з Bun (приклад: import { sql } from "bun")
26
26
 
27
27
  k8s - якщо присутня хоч одна директорія k8s
28
28
 
package/mdc/js-bun-db.mdc CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  description: Використання pg / mysql2 / Bun SQL у Node.js та Bun
3
3
  alwaysApply: true
4
- version: '1.2'
4
+ version: '1.3'
5
5
  ---
6
6
 
7
7
  ## Підтримувані версії баз даних
@@ -10,12 +10,14 @@ PostgreSQL 18+, MariaDB 10.6+ (сумісний з MySQL-протоколом,
10
10
 
11
11
  ## Заміна на Bun native SQL
12
12
 
13
- Якщо в проєкті використовуються бібліотеки `pg` або `mysql2`, їх потрібно замінити на Bun native SQL: <https://bun.com/docs/runtime/sql>.
13
+ Якщо в проєкті використовуються бібліотеки `pg`, `pg-format` або `mysql2`, їх потрібно замінити на Bun native SQL: <https://bun.com/docs/runtime/sql>.
14
14
 
15
- - Видалити з `dependencies`: `pg`, `pg-pool`, `pg-native`, `mysql`, `mysql2`.
15
+ - Видалити з `dependencies`: `pg`, `pg-pool`, `pg-native`, `pg-format`, `mysql`, `mysql2`.
16
16
  - Видалити з коду: усі `import` / `require` цих пакетів та власні обгортки над ними.
17
17
  - Замінити на `import { sql, SQL } from 'bun'` — Bun має вбудований клієнт із пулом, prepared statements та tagged templates.
18
18
 
19
+ `pg-format` — це ручне форматування SQL через escape (`format('... %L ...', value)`); такі рядки легко поламати неправильним типом, locale-залежним escape або забутим `%L`. Tagged template Bun SQL параметризує значення нативно (`sql\`... ${value} ...\``) і не лишає простору для injection — окремий «форматер» не потрібен.
20
+
19
21
  ## Підключення (singleton + env)
20
22
 
21
23
  Дефолтний експорт `sql` з `'bun'` сам читає змінні середовища (`DATABASE_URL`, `POSTGRES_URL`, `MYSQL_URL`, `PGHOST`/`PGUSER`/... та `MYSQL_HOST`/`MYSQL_USER`/...) і керує пулом — окремий `Pool` як у `pg` створювати не треба.
@@ -142,9 +144,9 @@ function getUser(id) {
142
144
 
143
145
  `new SQL(...)` має створюватись **один раз** на рівні модуля. Bun сам тримає пул (`max`, `idleTimeout`, `maxLifetime`) — окремих `Pool`/`Client` як у `pg` не потрібно.
144
146
 
145
- ### Не лишати `pg` / `mysql2` поряд із Bun SQL
147
+ ### Не лишати `pg` / `pg-format` / `mysql2` поряд із Bun SQL
146
148
 
147
- Якщо в коді з'явився `import { sql } from 'bun'`, то `pg` та `mysql2` мають бути прибрані і з `dependencies`, і з імпортів — щоб не лишалось двох паралельних шляхів до БД.
149
+ Якщо в коді з'явився `import { sql } from 'bun'`, то `pg`, `pg-format` та `mysql2` мають бути прибрані і з `dependencies`, і з імпортів — щоб не лишалось двох паралельних шляхів до БД та ручного форматування поряд із параметризованими template literal.
148
150
 
149
151
  ## Перевірка
150
152
 
package/mdc/js-lint.mdc CHANGED
@@ -26,9 +26,6 @@ version: '1.15'
26
26
  },
27
27
  "devDependencies": {
28
28
  "@nitra/eslint-config": "^3.6.12"
29
- },
30
- "engines": {
31
- "node": ">=24"
32
29
  }
33
30
  }
34
31
  ```
@@ -138,11 +135,12 @@ export default [
138
135
 
139
136
  ## Додаткові js правила
140
137
 
141
- Завжди додавай до package.json що підтримується 24+ версія node:
138
+ Завжди додавай до package.json що підтримується 24+ версія node і Bun 1.3+:
142
139
 
143
140
  ```json title="package.json"
144
141
  "engines": {
145
- "node": ">=24"
142
+ "node": ">=24",
143
+ "bun": ">=1.3"
146
144
  }
147
145
  ```
148
146
 
package/mdc/js-run.mdc ADDED
@@ -0,0 +1,115 @@
1
+ ---
2
+ description: Це правила для backend проектів на JavaScript/Node.js, сюди входять і job і WEB сервери.
3
+ alwaysApply: true
4
+ version: '1.1'
5
+ ---
6
+
7
+ ## Структура проекту
8
+
9
+ Рекомендується використовувати таку структуру проекту:
10
+
11
+ ```
12
+ k8s/ # тут всі файли для деплойменту в Kubernetes, включаючи kustomize
13
+ src/ # тут всі файли необхідні для роботи проекту
14
+ Dockerfile
15
+ package.json
16
+ readme.md
17
+ ```
18
+
19
+
20
+ ## Використання @nitra/pino
21
+
22
+ Проект використовує @nitra/pino для логування.
23
+ Якщо в проекті присутній @nitra/bunyan, то він повинен бути замінений на @nitra/pino — як у `package.json`, так і в коді: усі `import` / `require` / динамічні `import()` з `@nitra/bunyan` (і застарілого `bunyan`) треба замінити на `@nitra/pino` і за потреби адаптувати виклики під його API.
24
+
25
+ В **/k8s/base/configmap.yaml повинен бути заданий OTEL_RESOURCE_ATTRIBUTES: 'service.name=<project_name>,service.namespace=<project_namespace>'
26
+ а в директоріях з kustomize повинні бути перевизначені значення OTEL_RESOURCE_ATTRIBUTES і в них service.namespace повинен відповідати namespace, в якому знаходиться дана директорія.
27
+
28
+ ## Внутрішні аліаси
29
+
30
+ Якщо в проекті є підключення до баз даних, зовнішніх graphql на кшталт:
31
+
32
+ ```js
33
+ import { SQL } from 'bun'
34
+
35
+ // або
36
+
37
+ import sql from 'mssql'
38
+
39
+ // або
40
+
41
+ import { GraphQLClient } from '@nitra/graphql-request'
42
+ ```
43
+
44
+ то ці підключення повинні бути винесені в окремий файл, наприклад `/src/conn/pg.js`, в package.json повинні бути додано аліас:
45
+
46
+ ```json
47
+ {
48
+ "imports": {
49
+ "#conn/*": "./src/conn/*"
50
+ },
51
+ }
52
+
53
+ ```
54
+
55
+ так виглядатиме підключення до PostgreSQL в коді:
56
+ ```javascript title="Приклад підключення до PostgreSQL в /src/conn/pg.js"
57
+ import { checkEnv, env } from '@nitra/check-env'
58
+ import { SQL } from 'bun'
59
+
60
+ checkEnv(['PG_CONN'])
61
+
62
+ export const pool = new SQL({ url: env.PG_CONN })
63
+
64
+ ```
65
+
66
+ а так до GraphQL:
67
+
68
+ ```js
69
+ import { checkEnv } from '@nitra/check-env'
70
+ import { GraphQLClient } from '@nitra/graphql-request'
71
+
72
+ checkEnv(['QL', 'X_HASURA_ADMIN_SECRET'])
73
+
74
+ export { gql } from '@nitra/graphql-request'
75
+
76
+ export const graphQLClientSmart = new GraphQLClient(process.env.QL, {
77
+ headers: {
78
+ 'X-Hasura-Admin-Secret': process.env.X_HASURA_ADMIN_SECRET
79
+ }
80
+ })
81
+ ```
82
+
83
+
84
+ а в коді повинно бути використано:
85
+
86
+ ```js
87
+ import { pool } from '#conn/pg.js'
88
+
89
+ // або
90
+
91
+ import { gql, graphQLClient } from '@nitra/graphql-request'
92
+ ```
93
+
94
+
95
+
96
+ ## CheckEnv
97
+
98
+ Усі змінні оточення, які використовуються в коді, повинні бути перевірені за допомогою `checkEnv` з пакету `@nitra/check-env`. Це гарантує, що всі необхідні змінні оточення встановлені перед запуском програми. (Виключенням можуть бути задані коментарем)
99
+
100
+
101
+ ```javascript title="Приклад підключення до PostgreSQL в /src/conn/pg.js"
102
+ import { checkEnv, env } from '@nitra/check-env'
103
+ import { SQL } from 'bun'
104
+
105
+ checkEnv(['PG_CONN'])
106
+
107
+ export const pool = new SQL({ url: env.PG_CONN })
108
+
109
+ // @nitra/cursor ignore-next-line checkEnv
110
+ console.log(process.env.OPTIONAL_ENV_VAR)
111
+ ```
112
+
113
+ ## Перевірка
114
+
115
+ `npx @nitra/cursor check js-run`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nitra/cursor",
3
- "version": "1.8.153",
3
+ "version": "1.8.155",
4
4
  "description": "CLI для завантаження cursor-правил (префікс n-) у локальний репозиторій",
5
5
  "keywords": [
6
6
  "cli",
@@ -2,7 +2,7 @@
2
2
  * Автовизначення правил і skills для `.n-cursor.json` за умовами з `npm/bin/auto-rules.md`.
3
3
  *
4
4
  * Модуль аналізує дерево проєкту (наявність файлів/директорій, `gql\`...\`` у source,
5
- * залежності `mssql` / `pg` / `mysql2` у `package.json`, імпорт `sql`/`SQL` з `bun`, кореневий
5
+ * залежності `mssql` / `pg` / `pg-format` / `mysql2` у `package.json`, імпорт `sql`/`SQL` з `bun`, кореневий
6
6
  * `package.json`) та повертає ідентифікатори правил і skills, які потрібно автододати.
7
7
  *
8
8
  * Також враховує винятки `disable-rules` і `disable-skills`: елементи з цих списків не
@@ -31,7 +31,7 @@ export const AUTO_RULE_ORDER = Object.freeze([
31
31
  'js-lint',
32
32
  'js-mssql',
33
33
  'js-bun-db',
34
- 'js-pino',
34
+ 'js-run',
35
35
  'k8s',
36
36
  'nginx-default-tpl',
37
37
  'npm-module',
@@ -138,6 +138,68 @@ async function collectDependencyKeysPresentInPackageJsonTree(root, dependencyKey
138
138
  return found
139
139
  }
140
140
 
141
+ /**
142
+ * Перевіряє, чи існує хоча б один вкладений `package.json` (не кореневий),
143
+ * у якому в `devDependencies` відсутня залежність `vite`.
144
+ * @param {string} root абсолютний шлях до кореня репозиторію
145
+ * @returns {Promise<boolean>} true, якщо знайдено вкладений package.json без `vite` у devDependencies
146
+ */
147
+ async function hasNestedPackageJsonWithoutViteDevDependency(root) {
148
+ let result = false
149
+
150
+ /**
151
+ * Перевіряє один package.json: повертає true, якщо в `devDependencies` немає `vite`.
152
+ * @param {string} absPath абсолютний шлях до package.json
153
+ * @returns {Promise<boolean>} true, якщо vite відсутній у devDependencies
154
+ */
155
+ async function packageJsonLacksViteDevDependency(absPath) {
156
+ try {
157
+ const parsed = JSON.parse(await readFile(absPath, 'utf8'))
158
+ const devDeps = parsed?.devDependencies
159
+ if (!devDeps || typeof devDeps !== 'object' || Array.isArray(devDeps)) {
160
+ return true
161
+ }
162
+ return !Object.hasOwn(devDeps, 'vite')
163
+ } catch {
164
+ return false
165
+ }
166
+ }
167
+
168
+ /**
169
+ * Рекурсивний обхід каталогу з пропуском службових директорій.
170
+ * @param {string} dir абсолютний шлях каталогу
171
+ * @returns {Promise<void>}
172
+ */
173
+ async function walk(dir) {
174
+ if (result) return
175
+ let entries
176
+ try {
177
+ entries = await readdir(dir, { withFileTypes: true })
178
+ } catch {
179
+ return
180
+ }
181
+ for (const entry of entries) {
182
+ if (result) return
183
+ const absPath = join(dir, entry.name)
184
+ if (entry.isDirectory()) {
185
+ if (!IGNORED_DIR_NAMES.has(entry.name)) {
186
+ await walk(absPath)
187
+ }
188
+ continue
189
+ }
190
+ if (entry.isFile() && entry.name === 'package.json' && absPath !== join(root, 'package.json')) {
191
+ if (await packageJsonLacksViteDevDependency(absPath)) {
192
+ result = true
193
+ return
194
+ }
195
+ }
196
+ }
197
+ }
198
+
199
+ await walk(root)
200
+ return result
201
+ }
202
+
141
203
  /**
142
204
  * Фіксує ознаки, що залежать лише від імені підкаталогу.
143
205
  * @param {string} dirName імʼя каталогу
@@ -435,9 +497,10 @@ export async function detectAutoRulesAndSkills({
435
497
  )
436
498
  const isAbie = typeof repositoryUrl === 'string' && repositoryUrl.toLowerCase().includes(ABIE_REPOSITORY_URL_MARKER)
437
499
  const isMonorepo = isMonorepoPackage(packageJsonParsed)
438
- const depHits = await collectDependencyKeysPresentInPackageJsonTree(root, ['mssql', 'pg', 'mysql2'])
500
+ const depHits = await collectDependencyKeysPresentInPackageJsonTree(root, ['mssql', 'pg', 'pg-format', 'mysql2'])
439
501
  const hasMssqlDependency = depHits.has('mssql')
440
- const hasJsBunDbSignal = depHits.has('pg') || depHits.has('mysql2') || facts.hasBunSqlImport
502
+ const hasJsBunDbSignal = depHits.has('pg') || depHits.has('pg-format') || depHits.has('mysql2') || facts.hasBunSqlImport
503
+ const hasNestedNodePackage = await hasNestedPackageJsonWithoutViteDevDependency(root)
441
504
 
442
505
  /** @type {string[]} */
443
506
  const detectedRules = []
@@ -478,7 +541,7 @@ export async function detectAutoRulesAndSkills({
478
541
  { enabled: facts.hasJsLikeSource, id: 'js-lint' },
479
542
  { enabled: hasMssqlDependency, id: 'js-mssql' },
480
543
  { enabled: hasJsBunDbSignal, id: 'js-bun-db' },
481
- { enabled: facts.hasJsLikeSource && !(isMonorepo && facts.hasVueSource && facts.hasTempoDir), id: 'js-pino' },
544
+ { enabled: hasNestedNodePackage, id: 'js-run' },
482
545
  { enabled: facts.hasK8sDir, id: 'k8s' },
483
546
  { enabled: facts.hasNginxDefaultTplFile, id: 'nginx-default-tpl' },
484
547
  { enabled: npmDirExists, id: 'npm-module' },
@@ -2,8 +2,10 @@
2
2
  * Перевіряє правило js-bun-db.mdc.
3
3
  *
4
4
  * 1) У жодному `package.json` (включно з workspace-пакетами) у `dependencies` не повинно
5
- * бути `pg` чи `mysql2` — ці бібліотеки треба замінити на Bun native SQL
5
+ * бути `pg`, `pg-format` чи `mysql2` — ці бібліотеки треба замінити на Bun native SQL
6
6
  * (`import { sql, SQL } from 'bun'`, https://bun.com/docs/runtime/sql).
7
+ * `pg-format` — ручне форматування SQL через escape; tagged template Bun SQL
8
+ * параметризує значення нативно і не лишає простору для injection.
7
9
  *
8
10
  * 2) Якщо в коді використовується Bun SQL (імпорт `sql`/`SQL` з `'bun'`), додатково
9
11
  * перевіряє небезпечні патерни:
@@ -28,7 +30,7 @@ import {
28
30
  import { walkDir } from './utils/walkDir.mjs'
29
31
 
30
32
  /** Імена забороненої залежності у будь-якому `package.json`. */
31
- const FORBIDDEN_DEPENDENCIES = Object.freeze(['pg', 'mysql2'])
33
+ const FORBIDDEN_DEPENDENCIES = Object.freeze(['pg', 'pg-format', 'mysql2'])
32
34
 
33
35
  /**
34
36
  * @param {unknown} v parsed JSON
@@ -7,7 +7,7 @@
7
7
  * globals, ignorePatterns. `@nitra/eslint-config` у devDependencies мінімум **3.6.12** (транзитивний
8
8
  * `@e18e/eslint-plugin` для oxlint), `.jscpd.json` (gitignore, exitCode, reporters, minLines), workflow
9
9
  * `lint-js.yml` (checkout@v6, setup-bun-deps, bunx без --fix), без prettier, `engines.node` >= 24,
10
- * `"type": "module"` у кореневому і всіх workspace `package.json`. Дубль перевірки JS у `lint.yml` — заборонено.
10
+ * `engines.bun` >= 1.3, `"type": "module"` у кореневому і всіх workspace `package.json`. Дубль перевірки JS у `lint.yml` — заборонено.
11
11
  */
12
12
  import { existsSync } from 'node:fs'
13
13
  import { readFile } from 'node:fs/promises'
@@ -264,38 +264,62 @@ function checkPackageJsonTypeModule(label, pkg, passFn, failFn) {
264
264
  }
265
265
 
266
266
  /**
267
- * `"type": "module"` у кожного workspace з package.json.
267
+ * `"type": "module"`, `engines.node >= 24` і `engines.bun >= 1.3` у кожному workspace `package.json`.
268
268
  * @param {unknown[]} workspaces поле workspaces з package.json
269
269
  * @param {(msg: string) => void} passFn callback при успішній перевірці
270
270
  * @param {(msg: string) => void} failFn callback при помилці
271
271
  */
272
- async function checkWorkspacePackagesTypeModule(workspaces, passFn, failFn) {
272
+ async function checkWorkspacePackages(workspaces, passFn, failFn) {
273
273
  for (const ws of workspaces) {
274
274
  const wsPkgPath = `${ws}/package.json`
275
275
  if (existsSync(wsPkgPath)) {
276
276
  const wsPkg = JSON.parse(await readFile(wsPkgPath, 'utf8'))
277
277
  checkPackageJsonTypeModule(wsPkgPath, wsPkg, passFn, failFn)
278
+ checkEnginesNode(wsPkgPath, wsPkg, passFn, failFn)
279
+ checkEnginesBun(wsPkgPath, wsPkg, passFn, failFn)
278
280
  }
279
281
  }
280
282
  }
281
283
 
282
284
  /**
283
285
  * engines.node >= 24.
286
+ * @param {string} label шлях або назва пакета для повідомлень
284
287
  * @param {{ engines?: { node?: string } }} pkg розпарсений package.json
285
288
  * @param {(msg: string) => void} passFn callback при успішній перевірці
286
289
  * @param {(msg: string) => void} failFn callback при помилці
287
290
  */
288
- function checkEnginesNode(pkg, passFn, failFn) {
291
+ function checkEnginesNode(label, pkg, passFn, failFn) {
289
292
  const nodeEngine = pkg.engines?.node
290
293
  if (nodeEngine) {
291
294
  const firstNumeric = String(nodeEngine).split(NON_DIGITS_RE).find(Boolean)
292
295
  if (firstNumeric && Number(firstNumeric) >= 24) {
293
- passFn(`engines.node: "${nodeEngine}"`)
296
+ passFn(`${label}: engines.node "${nodeEngine}"`)
297
+ } else {
298
+ failFn(`${label}: engines.node "${nodeEngine}" — має бути >=24`)
299
+ }
300
+ } else {
301
+ failFn(`${label} не містить engines.node — додай: "engines": { "node": ">=24" }`)
302
+ }
303
+ }
304
+
305
+ /**
306
+ * engines.bun >= 1.3.
307
+ * @param {string} label шлях або назва пакета для повідомлень
308
+ * @param {{ engines?: { bun?: string } }} pkg розпарсений package.json
309
+ * @param {(msg: string) => void} passFn callback при успішній перевірці
310
+ * @param {(msg: string) => void} failFn callback при помилці
311
+ */
312
+ function checkEnginesBun(label, pkg, passFn, failFn) {
313
+ const bunEngine = pkg.engines?.bun
314
+ if (bunEngine) {
315
+ const [major, minor] = String(bunEngine).split(NON_DIGITS_RE).filter(Boolean).map(Number)
316
+ if (Number.isFinite(major) && Number.isFinite(minor) && (major > 1 || (major === 1 && minor >= 3))) {
317
+ passFn(`${label}: engines.bun "${bunEngine}"`)
294
318
  } else {
295
- failFn(`engines.node: "${nodeEngine}" — має бути >=24`)
319
+ failFn(`${label}: engines.bun "${bunEngine}" — має бути >=1.3`)
296
320
  }
297
321
  } else {
298
- failFn('package.json не містить engines.node — додай: "engines": { "node": ">=24" }')
322
+ failFn(`${label} не містить engines.bun — додай: "engines": { "bun": ">=1.3" }`)
299
323
  }
300
324
  }
301
325
 
@@ -311,7 +335,7 @@ async function checkPackageJsonJsLint(passFn, failFn) {
311
335
  checkPackageJsonTypeModule('package.json', pkg, passFn, failFn)
312
336
 
313
337
  const workspaces = Array.isArray(pkg.workspaces) ? pkg.workspaces : []
314
- await checkWorkspacePackagesTypeModule(workspaces, passFn, failFn)
338
+ await checkWorkspacePackages(workspaces, passFn, failFn)
315
339
 
316
340
  const lintJs = pkg.scripts?.['lint-js']
317
341
  if (lintJs) {
@@ -328,7 +352,8 @@ async function checkPackageJsonJsLint(passFn, failFn) {
328
352
  }
329
353
 
330
354
  checkPackageJsonLintDeps(pkg, passFn, failFn)
331
- checkEnginesNode(pkg, passFn, failFn)
355
+ checkEnginesNode('package.json', pkg, passFn, failFn)
356
+ checkEnginesBun('package.json', pkg, passFn, failFn)
332
357
  }
333
358
 
334
359
  /**
@@ -0,0 +1,227 @@
1
+ /**
2
+ * Для кожного workspace-пакета перевіряє правило js-run.mdc.
3
+ *
4
+ * Покрито:
5
+ * - заборона `@nitra/bunyan` / `bunyan` як у залежностях `package.json`, так і в коді
6
+ * (`import` / `require` / динамічний `import()`); імпорти сканує AST через `oxc-parser`
7
+ * (див. `utils/bunyan-imports.mjs`);
8
+ * - наявність `OTEL_RESOURCE_ATTRIBUTES` зі значеннями `service.name=` та `service.namespace=`
9
+ * у `k8s/base/configmap.yaml`, якщо такий файл існує (відповідність імені ConfigMap імені
10
+ * Deployment перевіряється в `check-k8s.mjs`);
11
+ * - «Внутрішні аліаси» (`#conn/*`): імпорти `bun#SQL`, будь-який `mssql`, `@nitra/graphql-request#GraphQLClient`
12
+ * дозволені лише у каталозі conn (за замовчуванням `src/conn/`; за наявності
13
+ * `package.json#imports['#conn/*']` — у його цільовому каталозі); поза ним — порушення
14
+ * (див. `utils/conn-imports-scan.mjs`);
15
+ * - «CheckEnv»: кожне `process.env.X` (включно з `process.env['X']` і деструктуризацією
16
+ * `const { X } = process.env`) має бути закрите літеральним викликом `checkEnv(['X', ...])`
17
+ * у тому ж файлі або коментарем `// @nitra/cursor ignore-next-line checkEnv` на попередньому
18
+ * рядку (див. `utils/check-env-scan.mjs`).
19
+ */
20
+ import { existsSync } from 'node:fs'
21
+ import { readFile } from 'node:fs/promises'
22
+ import { join, relative } from 'node:path'
23
+
24
+ import {
25
+ findBunyanImportsInText,
26
+ isBunyanScanSourceFile,
27
+ shouldSkipFileForBunyanScan
28
+ } from './utils/bunyan-imports.mjs'
29
+ import { findUncheckedProcessEnvInText, isCheckEnvScanSourceFile } from './utils/check-env-scan.mjs'
30
+ import { createCheckReporter } from './utils/check-reporter.mjs'
31
+ import {
32
+ findConnFactoryImportsInText,
33
+ isConnImportsScanSourceFile,
34
+ isInsideConnDir,
35
+ resolveConnDirFromPackageJson
36
+ } from './utils/conn-imports-scan.mjs'
37
+ import { walkDir } from './utils/walkDir.mjs'
38
+ import { getMonorepoPackageRootDirs } from './utils/workspaces.mjs'
39
+
40
+ /**
41
+ * Перетворює абсолютний шлях у posix-формі відносно кореня пакета.
42
+ * @param {string} absPackageRoot абсолютний корінь пакета
43
+ * @param {string} absPath абсолютний шлях до файлу
44
+ * @returns {string} відносний posix-шлях
45
+ */
46
+ function relPosix(absPackageRoot, absPath) {
47
+ return relative(absPackageRoot, absPath).split('\\').join('/')
48
+ }
49
+
50
+ /**
51
+ * Сканує джерела пакета на заборонені імпорти `@nitra/bunyan` / `bunyan`.
52
+ * @param {string} absPackageRoot абсолютний шлях до кореня пакета
53
+ * @param {string} label префікс повідомлення `[<pkg>] `
54
+ * @param {(msg: string) => void} fail callback при помилці
55
+ * @returns {Promise<number>} кількість знайдених порушень
56
+ */
57
+ async function checkBunyanImports(absPackageRoot, label, fail) {
58
+ /** @type {string[]} */
59
+ const sourcePaths = []
60
+ await walkDir(absPackageRoot, absPath => {
61
+ const rel = relPosix(absPackageRoot, absPath)
62
+ if (!shouldSkipFileForBunyanScan(rel) && isBunyanScanSourceFile(rel)) {
63
+ sourcePaths.push(absPath)
64
+ }
65
+ })
66
+
67
+ let violations = 0
68
+ for (const absPath of sourcePaths) {
69
+ const rel = relPosix(absPackageRoot, absPath)
70
+ const content = await readFile(absPath, 'utf8')
71
+ for (const v of findBunyanImportsInText(content, rel)) {
72
+ violations++
73
+ fail(`${label}${rel}:${v.line} — заміни '${v.module}' на '@nitra/pino': ${v.snippet}`)
74
+ }
75
+ }
76
+ return violations
77
+ }
78
+
79
+ /**
80
+ * Збирає всі JS/TS-файли пакета (без node_modules, dist тощо).
81
+ * @param {string} absPackageRoot абсолютний шлях до кореня пакета
82
+ * @returns {Promise<string[]>} абсолютні шляхи до файлів
83
+ */
84
+ async function collectSourceFiles(absPackageRoot) {
85
+ /** @type {string[]} */
86
+ const out = []
87
+ await walkDir(absPackageRoot, absPath => {
88
+ const rel = relPosix(absPackageRoot, absPath)
89
+ if (isCheckEnvScanSourceFile(rel)) out.push(absPath)
90
+ })
91
+ return out
92
+ }
93
+
94
+ /**
95
+ * Перевіряє правило «Внутрішні аліаси» для пакета.
96
+ * @param {string} absPackageRoot абсолютний корінь пакета
97
+ * @param {string[]} sourcePaths абсолютні шляхи до файлів
98
+ * @param {unknown} pkgJson розпарсений `package.json` пакета (або null)
99
+ * @param {string} label префікс повідомлення `[<pkg>] `
100
+ * @param {(msg: string) => void} fail callback при помилці
101
+ * @returns {Promise<number>} кількість порушень
102
+ */
103
+ async function checkConnImports(absPackageRoot, sourcePaths, pkgJson, label, fail) {
104
+ const connDir = resolveConnDirFromPackageJson(pkgJson)
105
+ let violations = 0
106
+ for (const absPath of sourcePaths) {
107
+ const rel = relPosix(absPackageRoot, absPath)
108
+ if (!isConnImportsScanSourceFile(rel)) continue
109
+ if (isInsideConnDir(rel, connDir)) continue
110
+ const content = await readFile(absPath, 'utf8')
111
+ for (const v of findConnFactoryImportsInText(content, rel)) {
112
+ violations++
113
+ const target = v.specifier === '*' ? `'${v.module}'` : `{ ${v.specifier} } from '${v.module}'`
114
+ fail(
115
+ `${label}${rel}:${v.line} — імпорт ${target} має бути в '${connDir}/' і реекспортуватися через '#conn/*': ${v.snippet}`
116
+ )
117
+ }
118
+ }
119
+ return violations
120
+ }
121
+
122
+ /**
123
+ * Перевіряє правило «CheckEnv» для пакета.
124
+ * @param {string} absPackageRoot абсолютний корінь пакета
125
+ * @param {string[]} sourcePaths абсолютні шляхи до файлів
126
+ * @param {string} label префікс повідомлення `[<pkg>] `
127
+ * @param {(msg: string) => void} fail callback при помилці
128
+ * @returns {Promise<number>} кількість порушень
129
+ */
130
+ async function checkProcessEnvUsage(absPackageRoot, sourcePaths, label, fail) {
131
+ let violations = 0
132
+ for (const absPath of sourcePaths) {
133
+ const rel = relPosix(absPackageRoot, absPath)
134
+ const content = await readFile(absPath, 'utf8')
135
+ for (const v of findUncheckedProcessEnvInText(content, rel)) {
136
+ violations++
137
+ fail(
138
+ `${label}${rel}:${v.line} — process.env.${v.name} без checkEnv(['${v.name}']) (або '// @nitra/cursor ignore-next-line checkEnv' попереду)`
139
+ )
140
+ }
141
+ }
142
+ return violations
143
+ }
144
+
145
+ /**
146
+ * Перевіряє відповідність правилам js-run.mdc для одного workspace-пакета.
147
+ * @param {string} rootDir відносний шлях workspace (не `'.'`)
148
+ * @param {(msg: string) => void} fail функція зворотного виклику для реєстрації помилки перевірки
149
+ * @param {(msg: string) => void} passFn успішне повідомлення (як у check-reporter)
150
+ * @returns {Promise<void>} завершується після перевірок цього пакета
151
+ */
152
+ async function checkWorkspacePackage(rootDir, fail, passFn) {
153
+ const label = `[${rootDir}] `
154
+ const absPackageRoot = join(process.cwd(), rootDir)
155
+ /** @type {unknown} */
156
+ let pkgJson = null
157
+ const pkgPath = join(rootDir, 'package.json')
158
+ if (existsSync(pkgPath)) {
159
+ pkgJson = JSON.parse(await readFile(pkgPath, 'utf8'))
160
+ const deps = /** @type {Record<string, unknown>} */ (pkgJson).dependencies
161
+ const devDeps = /** @type {Record<string, unknown>} */ (pkgJson).devDependencies
162
+ const allDeps = { ...(deps || {}), ...(devDeps || {}) }
163
+
164
+ if (allDeps['@nitra/bunyan']) {
165
+ fail(`${label}@nitra/bunyan знайдено — замінити на @nitra/pino`)
166
+ }
167
+ if (allDeps.bunyan) {
168
+ fail(`${label}bunyan знайдено — замінити на @nitra/pino`)
169
+ }
170
+ }
171
+
172
+ const importViolations = await checkBunyanImports(absPackageRoot, label, fail)
173
+ if (importViolations === 0) {
174
+ passFn(`${label}немає імпортів '@nitra/bunyan' / 'bunyan' у джерелах`)
175
+ }
176
+
177
+ const sourcePaths = await collectSourceFiles(absPackageRoot)
178
+
179
+ const connViolations = await checkConnImports(absPackageRoot, sourcePaths, pkgJson, label, fail)
180
+ if (connViolations === 0) {
181
+ const connDir = resolveConnDirFromPackageJson(pkgJson)
182
+ passFn(`${label}імпорти підключень (bun#SQL / mssql / @nitra/graphql-request#GraphQLClient) лише в '${connDir}/'`)
183
+ }
184
+
185
+ const envViolations = await checkProcessEnvUsage(absPackageRoot, sourcePaths, label, fail)
186
+ if (envViolations === 0) {
187
+ passFn(`${label}усі process.env.* закриті checkEnv(['…']) або '// @nitra/cursor ignore-next-line checkEnv'`)
188
+ }
189
+
190
+ const configmapPath = join(rootDir, 'k8s', 'base', 'configmap.yaml')
191
+ if (existsSync(configmapPath)) {
192
+ const content = await readFile(configmapPath, 'utf8')
193
+ if (content.includes('OTEL_RESOURCE_ATTRIBUTES')) {
194
+ passFn(`${label}k8s/base/configmap.yaml містить OTEL_RESOURCE_ATTRIBUTES`)
195
+ if (content.includes('service.name=') && content.includes('service.namespace=')) {
196
+ passFn(`${label}OTEL_RESOURCE_ATTRIBUTES містить service.name та service.namespace`)
197
+ } else {
198
+ fail(`${label}OTEL_RESOURCE_ATTRIBUTES має містити service.name=<name>,service.namespace=<namespace>`)
199
+ }
200
+ } else {
201
+ fail(`${label}k8s/base/configmap.yaml не містить OTEL_RESOURCE_ATTRIBUTES`)
202
+ }
203
+ }
204
+ }
205
+
206
+ /**
207
+ * Перевіряє відповідність проєкту правилам js-run.mdc лише для workspace-пакетів (не корінь репо).
208
+ * @returns {Promise<number>} 0 — все OK, 1 — є проблеми
209
+ */
210
+ export async function check() {
211
+ const reporter = createCheckReporter()
212
+ const { pass, fail } = reporter
213
+
214
+ const roots = await getMonorepoPackageRootDirs()
215
+ const workspaceRoots = roots.filter(r => r !== '.')
216
+
217
+ if (workspaceRoots.length === 0) {
218
+ pass('js-run: немає workspace-пакетів у кореневому package.json — перевірку залежностей і k8s у пакетах пропущено')
219
+ return reporter.getExitCode()
220
+ }
221
+
222
+ for (const r of workspaceRoots) {
223
+ await checkWorkspacePackage(r, fail, pass)
224
+ }
225
+
226
+ return reporter.getExitCode()
227
+ }
@@ -2594,7 +2594,7 @@ async function readFirstDocByKindFromFile(filePath, kind) {
2594
2594
  }
2595
2595
 
2596
2596
  /**
2597
- * Знаходить перший документ **Deployment** серед YAML-файлів каталогу (для перевірки імені ConfigMap, js-pino.mdc).
2597
+ * Знаходить перший документ **Deployment** серед YAML-файлів каталогу (для перевірки імені ConfigMap, js-run.mdc).
2598
2598
  * @param {string} dirPath абсолютний шлях до каталогу
2599
2599
  * @returns {Promise<Record<string, unknown> | null>} об'єкт Deployment або null
2600
2600
  */
@@ -2678,7 +2678,7 @@ function collectConfigMapRefsFromVolumes(volumes, names) {
2678
2678
  /**
2679
2679
  * Збирає унікальні імена **ConfigMap**, на які посилається **Deployment**
2680
2680
  * через `spec.template.spec.containers[*].envFrom[*].configMapRef.name`
2681
- * та `spec.template.spec.volumes[*].configMap.name` (для перевірки js-pino.mdc).
2681
+ * та `spec.template.spec.volumes[*].configMap.name` (для перевірки js-run.mdc).
2682
2682
  * @param {Record<string, unknown>} deployment об'єкт Deployment
2683
2683
  * @returns {Set<string>} унікальні імена ConfigMap
2684
2684
  */
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Знаходить імпорти з `@nitra/bunyan` (і застарілого `bunyan`) у джерелах — їх треба замінити
3
- * на `@nitra/pino` згідно з js-pino.mdc.
3
+ * на `@nitra/pino` згідно з js-run.mdc.
4
4
  *
5
5
  * Семантика береться з **oxc-parser** (`module.staticImports`) — без regex по тілу файлу.
6
6
  * Додатково по AST програми ловимо `require('@nitra/bunyan')` і динамічний `import('@nitra/bunyan')`,
@@ -0,0 +1,179 @@
1
+ /**
2
+ * AST-сканер для правила CheckEnv (js-run.mdc).
3
+ *
4
+ * Кожне використання `process.env.X` у JS/TS-коді має бути «закрите» одним з двох способів:
5
+ * - перед використанням у тому ж файлі викликано `checkEnv(['X', ...])` з пакету `@nitra/check-env`;
6
+ * - на рядку безпосередньо перед `process.env.X` стоїть коментар-маркер
7
+ * `// @nitra/cursor ignore-next-line checkEnv` (роздільники пробілів довільні; саме слово
8
+ * `checkEnv` чутливе до регістру, як в усіх прикладах документа).
9
+ *
10
+ * Семантика береться з **oxc-parser** через `parseProgramOrNull`: regex по тілу файлу не
11
+ * використовується, лише сирий текст рядка з коментарем перевіряється на маркер. Якщо
12
+ * файл не парситься — повертаємо порожній результат, спочатку треба полагодити синтаксис.
13
+ *
14
+ * Покриті форми доступу до `process.env`:
15
+ * - `process.env.X` (звичайний MemberExpression);
16
+ * - `process.env['X']` (computed з рядковим літералом);
17
+ * - `const { X, Y } = process.env` (ObjectPattern; ім'я з ключа);
18
+ * - `const { X: alias } = process.env` (ім'я з ключа, не з alias).
19
+ *
20
+ * Якщо ключ обчислюваний (наприклад, `process.env[varName]`) — пропускаємо без помилки,
21
+ * бо за статичним AST неможливо встановити, яка саме змінна оточення використовується.
22
+ */
23
+ import { offsetToLine, parseProgramOrNull, walkAstWithAncestors } from './ast-scan-utils.mjs'
24
+
25
+ const SOURCE_FILE_RE = /\.([cm]?[jt]sx?)$/u
26
+ const IGNORE_DIRECTIVE_RE = /\/\/\s*@nitra\/cursor\s+ignore-next-line\s+checkEnv\b/u
27
+
28
+ /**
29
+ * Чи є цей вузол виразом `process.env`.
30
+ * @param {unknown} node AST вузол
31
+ * @returns {boolean} true, якщо це `MemberExpression` `process.env` (Identifier . Identifier)
32
+ */
33
+ function isProcessEnvAccess(node) {
34
+ if (!node || typeof node !== 'object') return false
35
+ if (node.type !== 'MemberExpression' || node.computed) return false
36
+ const obj = node.object
37
+ const prop = node.property
38
+ return (
39
+ !!obj &&
40
+ obj.type === 'Identifier' &&
41
+ obj.name === 'process' &&
42
+ !!prop &&
43
+ prop.type === 'Identifier' &&
44
+ prop.name === 'env'
45
+ )
46
+ }
47
+
48
+ /**
49
+ * Витягує ім'я ENV з MemberExpression-вузла `process.env.X` або `process.env['X']`.
50
+ * @param {Record<string, unknown>} node MemberExpression, чий object — `process.env`
51
+ * @returns {string | null} ім'я змінної оточення або null, якщо ключ не статичний
52
+ */
53
+ function envNameFromMember(node) {
54
+ const prop = node.property
55
+ if (!prop || typeof prop !== 'object') return null
56
+ if (!node.computed && prop.type === 'Identifier' && typeof prop.name === 'string') return prop.name
57
+ if (node.computed && prop.type === 'Literal' && typeof prop.value === 'string') return prop.value
58
+ return null
59
+ }
60
+
61
+ /**
62
+ * Збирає всі літеральні імена з виклику `checkEnv([...])` у файлі.
63
+ * Якщо callee — Identifier `checkEnv` і перший аргумент — ArrayExpression, додає
64
+ * всі string-літерали до set. Не-літеральні елементи (Identifier, SpreadElement) ігноруються —
65
+ * це робить перевірку «ліберальною»: ми лише ловимо явно неперевірені змінні.
66
+ * @param {unknown} programNode корінь AST
67
+ * @returns {Set<string>} перелік закритих імен ENV
68
+ */
69
+ function collectCheckedEnvNames(programNode) {
70
+ /** @type {Set<string>} */
71
+ const out = new Set()
72
+ walkAstWithAncestors(programNode, [], node => {
73
+ if (node.type !== 'CallExpression') return
74
+ const callee = node.callee
75
+ if (!callee || callee.type !== 'Identifier' || callee.name !== 'checkEnv') return
76
+ const args = node.arguments
77
+ if (!Array.isArray(args) || args.length === 0) return
78
+ const first = args[0]
79
+ if (!first || typeof first !== 'object' || first.type !== 'ArrayExpression') return
80
+ const elements = first.elements
81
+ if (!Array.isArray(elements)) return
82
+ for (const el of elements) {
83
+ if (!el || typeof el !== 'object') continue
84
+ if (el.type === 'Literal' && typeof el.value === 'string') out.add(el.value)
85
+ }
86
+ })
87
+ return out
88
+ }
89
+
90
+ /**
91
+ * Чи закритий рядок ignore-коментарем `// @nitra/cursor ignore-next-line checkEnv`.
92
+ * @param {string[]} lines рядки файлу (split за \n, без CR)
93
+ * @param {number} oneBasedLine 1-based номер рядка з `process.env.X`
94
+ * @returns {boolean} true, якщо попередній рядок містить маркер
95
+ */
96
+ function hasIgnoreDirective(lines, oneBasedLine) {
97
+ if (oneBasedLine <= 1) return false
98
+ const prev = lines[oneBasedLine - 2] ?? ''
99
+ return IGNORE_DIRECTIVE_RE.test(prev)
100
+ }
101
+
102
+ /**
103
+ * Знаходить всі доступи до `process.env.<NAME>`, які не покриті ні літеральним
104
+ * `checkEnv([...])` у тому ж файлі, ні коментарем-маркером безпосередньо перед.
105
+ *
106
+ * @param {string} content вихідний код
107
+ * @param {string} [virtualPath] шлях для вибору `lang` парсера
108
+ * @returns {{ line: number, name: string }[]} список порушень
109
+ */
110
+ export function findUncheckedProcessEnvInText(content, virtualPath = 'scan.ts') {
111
+ const program = parseProgramOrNull(content, virtualPath)
112
+ if (!program) return []
113
+
114
+ const checked = collectCheckedEnvNames(program)
115
+ const lines = content.split('\n').map(s => (s.endsWith('\r') ? s.slice(0, -1) : s))
116
+
117
+ /** @type {{ line: number, name: string }[]} */
118
+ const out = []
119
+ /** @type {Set<string>} */
120
+ const reported = new Set()
121
+
122
+ /**
123
+ * Реєструє порушення з дедуплікацією за «name@line».
124
+ * @param {string} name ім'я ENV
125
+ * @param {number} line 1-based рядок
126
+ */
127
+ function report(name, line) {
128
+ if (checked.has(name)) return
129
+ if (hasIgnoreDirective(lines, line)) return
130
+ const key = `${name}@${line}`
131
+ if (reported.has(key)) return
132
+ reported.add(key)
133
+ out.push({ name, line })
134
+ }
135
+
136
+ walkAstWithAncestors(program, [], (node, ancestors) => {
137
+ if (isProcessEnvAccess(node)) {
138
+ const parent = ancestors[ancestors.length - 1]
139
+ // process.env.X / process.env['X']
140
+ if (parent && typeof parent === 'object' && parent.type === 'MemberExpression' && parent.object === node) {
141
+ const envName = envNameFromMember(parent)
142
+ if (envName) report(envName, offsetToLine(content, parent.start))
143
+ }
144
+ // const { X, Y } = process.env → беремо імена з ObjectPattern
145
+ if (
146
+ parent &&
147
+ typeof parent === 'object' &&
148
+ parent.type === 'VariableDeclarator' &&
149
+ parent.init === node &&
150
+ parent.id &&
151
+ parent.id.type === 'ObjectPattern' &&
152
+ Array.isArray(parent.id.properties)
153
+ ) {
154
+ for (const p of parent.id.properties) {
155
+ if (!p || typeof p !== 'object' || p.type !== 'Property') continue
156
+ if (p.computed) continue
157
+ const key = p.key
158
+ if (!key || typeof key !== 'object') continue
159
+ /** @type {string | null} */
160
+ let name = null
161
+ if (key.type === 'Identifier' && typeof key.name === 'string') name = key.name
162
+ else if (key.type === 'Literal' && typeof key.value === 'string') name = key.value
163
+ if (name) report(name, offsetToLine(content, p.start ?? parent.start))
164
+ }
165
+ }
166
+ }
167
+ })
168
+
169
+ return out
170
+ }
171
+
172
+ /**
173
+ * Чи сканувати цей файл за розширенням (JS/TS-сім'я, без `.d.ts`).
174
+ * @param {string} relativePathPosix відносний шлях (posix)
175
+ * @returns {boolean} true, якщо розширення підходить для AST-скану
176
+ */
177
+ export function isCheckEnvScanSourceFile(relativePathPosix) {
178
+ return SOURCE_FILE_RE.test(relativePathPosix) && !relativePathPosix.endsWith('.d.ts')
179
+ }
@@ -0,0 +1,144 @@
1
+ /**
2
+ * AST-сканер для правила «Внутрішні аліаси» (js-run.mdc).
3
+ *
4
+ * Імпорти, які створюють підключення до БД / зовнішнього GraphQL, мають жити в окремому
5
+ * файлі (за замовчуванням — `src/conn/`), а решта коду повинна споживати їх через
6
+ * pkg-import `#conn/...`. Ловимо такі імпорти в файлах поза каталогом «conn»:
7
+ * - `import { SQL } from 'bun'` (named специфікатор `SQL`);
8
+ * - `import sql from 'mssql'` або будь-який `import ... from 'mssql'`;
9
+ * - `import { GraphQLClient } from '@nitra/graphql-request'` (named `GraphQLClient`).
10
+ *
11
+ * Каталог «conn» визначається з поля `package.json#imports['#conn/*']` (якщо є —
12
+ * відрізаємо `*` і нормалізуємо), інакше дефолт — `src/conn`. Ключ `imports` у
13
+ * package.json — нативний для Node.js, той самий, що й у документі правила.
14
+ *
15
+ * Семантика береться з **oxc-parser** (`module.staticImports`); regex по тілу файлу не
16
+ * використовується. Якщо файл не парситься — повертаємо порожній результат, спочатку
17
+ * треба полагодити синтаксис.
18
+ */
19
+ import { langFromPath, normalizeSnippet, offsetToLine } from './ast-scan-utils.mjs'
20
+ import { parseSync } from 'oxc-parser'
21
+
22
+ const SOURCE_FILE_RE = /\.([cm]?[jt]sx?)$/u
23
+ const TRAILING_SLASH_RE = /\/+$/u
24
+
25
+ /**
26
+ * Нормалізує шлях до posix без хвостових слешів.
27
+ * @param {string} p вхідний шлях (можливо з `./` або зворотними слешами)
28
+ * @returns {string} нормалізований posix-шлях без хвостового `/`
29
+ */
30
+ function toPosixDir(p) {
31
+ let s = String(p).replaceAll('\\', '/').trim()
32
+ if (s.startsWith('./')) s = s.slice(2)
33
+ return s.replace(TRAILING_SLASH_RE, '')
34
+ }
35
+
36
+ /**
37
+ * Визначає каталог «conn» за `package.json#imports['#conn/*']`. Дефолт — `src/conn`.
38
+ * @param {unknown} pkgJson розпарсений package.json (або null)
39
+ * @returns {string} відносний posix-шлях до каталогу conn (без хвостового `/`)
40
+ */
41
+ export function resolveConnDirFromPackageJson(pkgJson) {
42
+ const fallback = 'src/conn'
43
+ if (!pkgJson || typeof pkgJson !== 'object') return fallback
44
+ const imports = /** @type {Record<string, unknown>} */ (pkgJson).imports
45
+ if (!imports || typeof imports !== 'object') return fallback
46
+ const target = /** @type {Record<string, unknown>} */ (imports)['#conn/*']
47
+ /** @type {string | null} */
48
+ let raw = null
49
+ if (typeof target === 'string') raw = target
50
+ else if (target && typeof target === 'object') {
51
+ // умовний експорт: { default: '...', import: '...' }
52
+ const obj = /** @type {Record<string, unknown>} */ (target)
53
+ if (typeof obj.default === 'string') raw = obj.default
54
+ else if (typeof obj.import === 'string') raw = obj.import
55
+ }
56
+ if (!raw) return fallback
57
+ // Прибираємо хвіст `*`, потім слеші
58
+ let s = toPosixDir(raw)
59
+ if (s.endsWith('/*')) s = s.slice(0, -2)
60
+ return s.replace(TRAILING_SLASH_RE, '') || fallback
61
+ }
62
+
63
+ /**
64
+ * Чи перебуває файл у каталозі conn (точно або вкладено).
65
+ * @param {string} relPosix відносний posix-шлях до файлу
66
+ * @param {string} connDir posix-шлях каталогу conn (без хвостового `/`)
67
+ * @returns {boolean} true, якщо файл у каталозі conn
68
+ */
69
+ export function isInsideConnDir(relPosix, connDir) {
70
+ if (!connDir) return false
71
+ return relPosix === connDir || relPosix.startsWith(`${connDir}/`)
72
+ }
73
+
74
+ /**
75
+ * Чи це порушення правила «Внутрішні аліаси» — імпорт зі стороннього модуля, що створює
76
+ * підключення (`bun` зі специфікатором `SQL`, будь-який імпорт з `mssql`, або
77
+ * `@nitra/graphql-request` зі специфікатором `GraphQLClient`).
78
+ * @param {Record<string, unknown>} staticImport елемент `module.staticImports` з oxc-parser
79
+ * @returns {{ module: string, specifier: string } | null} опис порушення або null
80
+ */
81
+ function classifyConnImport(staticImport) {
82
+ const mod = staticImport.moduleRequest?.value
83
+ if (typeof mod !== 'string') return null
84
+ const entries = Array.isArray(staticImport.entries) ? staticImport.entries : []
85
+
86
+ if (mod === 'bun') {
87
+ for (const e of entries) {
88
+ const name = e?.importName?.name
89
+ if (name === 'SQL') return { module: mod, specifier: 'SQL' }
90
+ }
91
+ return null
92
+ }
93
+ if (mod === 'mssql') {
94
+ return { module: mod, specifier: '*' }
95
+ }
96
+ if (mod === '@nitra/graphql-request') {
97
+ for (const e of entries) {
98
+ const name = e?.importName?.name
99
+ if (name === 'GraphQLClient') return { module: mod, specifier: 'GraphQLClient' }
100
+ }
101
+ return null
102
+ }
103
+ return null
104
+ }
105
+
106
+ /**
107
+ * Знаходить імпорти-«фабрики підключень» у тексті файлу.
108
+ * @param {string} content вихідний код
109
+ * @param {string} [virtualPath] шлях для вибору `lang` (наприклад `pkg/src/index.ts`)
110
+ * @returns {{ line: number, snippet: string, module: string, specifier: string }[]} список порушень
111
+ */
112
+ export function findConnFactoryImportsInText(content, virtualPath = 'scan.ts') {
113
+ const lang = langFromPath(virtualPath || 'scan.ts')
114
+ let result
115
+ try {
116
+ result = parseSync(virtualPath || 'scan.ts', content, { lang, sourceType: 'module' })
117
+ } catch {
118
+ return []
119
+ }
120
+ if (result.errors?.length) return []
121
+
122
+ /** @type {{ line: number, snippet: string, module: string, specifier: string }[]} */
123
+ const out = []
124
+ for (const imp of result.module?.staticImports ?? []) {
125
+ const hit = classifyConnImport(imp)
126
+ if (!hit) continue
127
+ out.push({
128
+ line: offsetToLine(content, imp.start),
129
+ snippet: normalizeSnippet(content.slice(imp.start, imp.end)),
130
+ module: hit.module,
131
+ specifier: hit.specifier
132
+ })
133
+ }
134
+ return out
135
+ }
136
+
137
+ /**
138
+ * Чи сканувати цей файл за розширенням (JS/TS-сім'я, без `.d.ts`).
139
+ * @param {string} relativePathPosix відносний шлях (posix)
140
+ * @returns {boolean} true, якщо розширення підходить для AST-скану
141
+ */
142
+ export function isConnImportsScanSourceFile(relativePathPosix) {
143
+ return SOURCE_FILE_RE.test(relativePathPosix) && !relativePathPosix.endsWith('.d.ts')
144
+ }
package/mdc/js-pino.mdc DELETED
@@ -1,15 +0,0 @@
1
- ---
2
- description: Використання @nitra/pino
3
- alwaysApply: true
4
- version: '1.1'
5
- ---
6
-
7
- Проект використовує @nitra/pino для логування.
8
- Якщо в проекті присутній @nitra/bunyan, то він повинен бути замінений на @nitra/pino — як у `package.json`, так і в коді: усі `import` / `require` / динамічні `import()` з `@nitra/bunyan` (і застарілого `bunyan`) треба замінити на `@nitra/pino` і за потреби адаптувати виклики під його API.
9
-
10
- В **/k8s/base/configmap.yaml повинен бути заданий OTEL_RESOURCE_ATTRIBUTES: 'service.name=<project_name>,service.namespace=<project_namespace>'
11
- а в директоріях з kustomize повинні бути перевизначені значення OTEL_RESOURCE_ATTRIBUTES і в них service.namespace повинен відповідати namespace, в якому знаходиться дана директорія.
12
-
13
- ## Перевірка
14
-
15
- `npx @nitra/cursor check js-pino`
@@ -1,120 +0,0 @@
1
- /**
2
- * Для кожного workspace-пакета перевіряє правило js-pino.mdc.
3
- *
4
- * Заборона `@nitra/bunyan` / `bunyan` як у залежностях `package.json`, так і в коді
5
- * (`import` / `require` / динамічний `import()`); наявність `OTEL_RESOURCE_ATTRIBUTES`
6
- * у `k8s/base/configmap.yaml`, якщо такий файл існує.
7
- *
8
- * Перевірка відповідності імені ConfigMap імені Deployment — у `check-k8s.mjs` (k8s.mdc).
9
- *
10
- * Імпорти в джерелах сканує AST через `oxc-parser` (див. `utils/bunyan-imports.mjs`),
11
- * щоб виявити випадки на кшталт `import log from '@nitra/bunyan'`, які лишаються в коді
12
- * після підміни залежності.
13
- */
14
- import { existsSync } from 'node:fs'
15
- import { readFile } from 'node:fs/promises'
16
- import { join, relative } from 'node:path'
17
-
18
- import {
19
- findBunyanImportsInText,
20
- isBunyanScanSourceFile,
21
- shouldSkipFileForBunyanScan
22
- } from './utils/bunyan-imports.mjs'
23
- import { createCheckReporter } from './utils/check-reporter.mjs'
24
- import { walkDir } from './utils/walkDir.mjs'
25
- import { getMonorepoPackageRootDirs } from './utils/workspaces.mjs'
26
-
27
- /**
28
- * Сканує джерела пакета на заборонені імпорти `@nitra/bunyan` / `bunyan`.
29
- * @param {string} absPackageRoot абсолютний шлях до кореня пакета
30
- * @param {string} label префікс повідомлення `[<pkg>] `
31
- * @param {(msg: string) => void} fail callback при помилці
32
- * @returns {Promise<number>} кількість знайдених порушень
33
- */
34
- async function checkBunyanImports(absPackageRoot, label, fail) {
35
- /** @type {string[]} */
36
- const sourcePaths = []
37
- await walkDir(absPackageRoot, absPath => {
38
- const rel = relative(absPackageRoot, absPath).split('\\').join('/')
39
- if (!shouldSkipFileForBunyanScan(rel) && isBunyanScanSourceFile(rel)) {
40
- sourcePaths.push(absPath)
41
- }
42
- })
43
-
44
- let violations = 0
45
- for (const absPath of sourcePaths) {
46
- const rel = relative(absPackageRoot, absPath).split('\\').join('/')
47
- const content = await readFile(absPath, 'utf8')
48
- for (const v of findBunyanImportsInText(content, rel)) {
49
- violations++
50
- fail(`${label}${rel}:${v.line} — заміни '${v.module}' на '@nitra/pino': ${v.snippet}`)
51
- }
52
- }
53
- return violations
54
- }
55
-
56
- /**
57
- * Перевіряє відповідність правилам js-pino.mdc для одного workspace-пакета.
58
- * @param {string} rootDir відносний шлях workspace (не `'.'`)
59
- * @param {(msg: string) => void} fail функція зворотного виклику для реєстрації помилки перевірки
60
- * @param {(msg: string) => void} passFn успішне повідомлення (як у check-reporter)
61
- * @returns {Promise<void>} завершується після перевірок цього пакета
62
- */
63
- async function checkWorkspacePackage(rootDir, fail, passFn) {
64
- const label = `[${rootDir}] `
65
- const pkgPath = join(rootDir, 'package.json')
66
- if (existsSync(pkgPath)) {
67
- const pkg = JSON.parse(await readFile(pkgPath, 'utf8'))
68
- const allDeps = { ...pkg.dependencies, ...pkg.devDependencies }
69
-
70
- if (allDeps['@nitra/bunyan']) {
71
- fail(`${label}@nitra/bunyan знайдено — замінити на @nitra/pino`)
72
- }
73
- if (allDeps.bunyan) {
74
- fail(`${label}bunyan знайдено — замінити на @nitra/pino`)
75
- }
76
- }
77
-
78
- const importViolations = await checkBunyanImports(join(process.cwd(), rootDir), label, fail)
79
- if (importViolations === 0) {
80
- passFn(`${label}немає імпортів '@nitra/bunyan' / 'bunyan' у джерелах`)
81
- }
82
-
83
- const configmapPath = join(rootDir, 'k8s', 'base', 'configmap.yaml')
84
- if (existsSync(configmapPath)) {
85
- const content = await readFile(configmapPath, 'utf8')
86
- if (content.includes('OTEL_RESOURCE_ATTRIBUTES')) {
87
- passFn(`${label}k8s/base/configmap.yaml містить OTEL_RESOURCE_ATTRIBUTES`)
88
- if (content.includes('service.name=') && content.includes('service.namespace=')) {
89
- passFn(`${label}OTEL_RESOURCE_ATTRIBUTES містить service.name та service.namespace`)
90
- } else {
91
- fail(`${label}OTEL_RESOURCE_ATTRIBUTES має містити service.name=<name>,service.namespace=<namespace>`)
92
- }
93
- } else {
94
- fail(`${label}k8s/base/configmap.yaml не містить OTEL_RESOURCE_ATTRIBUTES`)
95
- }
96
- }
97
- }
98
-
99
- /**
100
- * Перевіряє відповідність проєкту правилам js-pino.mdc лише для workspace-пакетів (не корінь репо).
101
- * @returns {Promise<number>} 0 — все OK, 1 — є проблеми
102
- */
103
- export async function check() {
104
- const reporter = createCheckReporter()
105
- const { pass, fail } = reporter
106
-
107
- const roots = await getMonorepoPackageRootDirs()
108
- const workspaceRoots = roots.filter(r => r !== '.')
109
-
110
- if (workspaceRoots.length === 0) {
111
- pass('js-pino: немає workspace-пакетів у кореневому package.json — перевірку залежностей і k8s у пакетах пропущено')
112
- return reporter.getExitCode()
113
- }
114
-
115
- for (const r of workspaceRoots) {
116
- await checkWorkspacePackage(r, fail, pass)
117
- }
118
-
119
- return reporter.getExitCode()
120
- }