@nitra/cursor 1.8.154 → 1.8.156

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,7 +18,7 @@ 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
 
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,133 @@
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, env } 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(env.QL, {
77
+ headers: {
78
+ 'X-Hasura-Admin-Secret': 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
+ ```
110
+
111
+
112
+ ## process.env
113
+
114
+ Прямий доступ до `process.env.X` у коді заборонений — його треба замінити на `env`:
115
+
116
+ - **обов'язкова змінна** — `import { checkEnv, env } from '@nitra/check-env'` плюс `checkEnv(['X'])`
117
+ у тому ж файлі (приклад див. вище в розділі **CheckEnv**);
118
+ - **опційна змінна** — `import { env } from 'node:process'`:
119
+
120
+ ```javascript title="Опційна змінна — env з node:process"
121
+ import { env } from 'node:process'
122
+
123
+ console.log(env.OPTIONAL_ENV_VAR)
124
+ ```
125
+
126
+ Тимчасово приглушити перевірку для конкретного рядка можна коментарем
127
+ `// @nitra/cursor ignore-next-line checkEnv` безпосередньо перед використанням
128
+ (escape-hatch для legacy-коду, не для нових файлів).
129
+
130
+
131
+ ## Перевірка
132
+
133
+ `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.154",
3
+ "version": "1.8.156",
4
4
  "description": "CLI для завантаження cursor-правил (префікс n-) у локальний репозиторій",
5
5
  "keywords": [
6
6
  "cli",
@@ -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 імʼя каталогу
@@ -438,6 +500,7 @@ export async function detectAutoRulesAndSkills({
438
500
  const depHits = await collectDependencyKeysPresentInPackageJsonTree(root, ['mssql', 'pg', 'pg-format', 'mysql2'])
439
501
  const hasMssqlDependency = depHits.has('mssql')
440
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' },
@@ -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,233 @@
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
+ * - «process.env / CheckEnv»: пряме `process.env.X` має бути замінено на `env` —
16
+ * з `@nitra/check-env` (для обов'язкових змінних, із `checkEnv([...])`) або з
17
+ * `node:process` (для опційних). Коли `env` імпортовано з `@nitra/check-env`,
18
+ * кожен `env.X` має бути закритий літеральним викликом `checkEnv(['X', ...])`
19
+ * у тому ж файлі або коментарем `// @nitra/cursor ignore-next-line checkEnv`
20
+ * на попередньому рядку (див. `utils/check-env-scan.mjs`).
21
+ */
22
+ import { existsSync } from 'node:fs'
23
+ import { readFile } from 'node:fs/promises'
24
+ import { join, relative } from 'node:path'
25
+
26
+ import {
27
+ findBunyanImportsInText,
28
+ isBunyanScanSourceFile,
29
+ shouldSkipFileForBunyanScan
30
+ } from './utils/bunyan-imports.mjs'
31
+ import { findUncheckedProcessEnvInText, isCheckEnvScanSourceFile } from './utils/check-env-scan.mjs'
32
+ import { createCheckReporter } from './utils/check-reporter.mjs'
33
+ import {
34
+ findConnFactoryImportsInText,
35
+ isConnImportsScanSourceFile,
36
+ isInsideConnDir,
37
+ resolveConnDirFromPackageJson
38
+ } from './utils/conn-imports-scan.mjs'
39
+ import { walkDir } from './utils/walkDir.mjs'
40
+ import { getMonorepoPackageRootDirs } from './utils/workspaces.mjs'
41
+
42
+ /**
43
+ * Перетворює абсолютний шлях у posix-формі відносно кореня пакета.
44
+ * @param {string} absPackageRoot абсолютний корінь пакета
45
+ * @param {string} absPath абсолютний шлях до файлу
46
+ * @returns {string} відносний posix-шлях
47
+ */
48
+ function relPosix(absPackageRoot, absPath) {
49
+ return relative(absPackageRoot, absPath).split('\\').join('/')
50
+ }
51
+
52
+ /**
53
+ * Сканує джерела пакета на заборонені імпорти `@nitra/bunyan` / `bunyan`.
54
+ * @param {string} absPackageRoot абсолютний шлях до кореня пакета
55
+ * @param {string} label префікс повідомлення `[<pkg>] `
56
+ * @param {(msg: string) => void} fail callback при помилці
57
+ * @returns {Promise<number>} кількість знайдених порушень
58
+ */
59
+ async function checkBunyanImports(absPackageRoot, label, fail) {
60
+ /** @type {string[]} */
61
+ const sourcePaths = []
62
+ await walkDir(absPackageRoot, absPath => {
63
+ const rel = relPosix(absPackageRoot, absPath)
64
+ if (!shouldSkipFileForBunyanScan(rel) && isBunyanScanSourceFile(rel)) {
65
+ sourcePaths.push(absPath)
66
+ }
67
+ })
68
+
69
+ let violations = 0
70
+ for (const absPath of sourcePaths) {
71
+ const rel = relPosix(absPackageRoot, absPath)
72
+ const content = await readFile(absPath, 'utf8')
73
+ for (const v of findBunyanImportsInText(content, rel)) {
74
+ violations++
75
+ fail(`${label}${rel}:${v.line} — заміни '${v.module}' на '@nitra/pino': ${v.snippet}`)
76
+ }
77
+ }
78
+ return violations
79
+ }
80
+
81
+ /**
82
+ * Збирає всі JS/TS-файли пакета (без node_modules, dist тощо).
83
+ * @param {string} absPackageRoot абсолютний шлях до кореня пакета
84
+ * @returns {Promise<string[]>} абсолютні шляхи до файлів
85
+ */
86
+ async function collectSourceFiles(absPackageRoot) {
87
+ /** @type {string[]} */
88
+ const out = []
89
+ await walkDir(absPackageRoot, absPath => {
90
+ const rel = relPosix(absPackageRoot, absPath)
91
+ if (isCheckEnvScanSourceFile(rel)) out.push(absPath)
92
+ })
93
+ return out
94
+ }
95
+
96
+ /**
97
+ * Перевіряє правило «Внутрішні аліаси» для пакета.
98
+ * @param {string} absPackageRoot абсолютний корінь пакета
99
+ * @param {string[]} sourcePaths абсолютні шляхи до файлів
100
+ * @param {unknown} pkgJson розпарсений `package.json` пакета (або null)
101
+ * @param {string} label префікс повідомлення `[<pkg>] `
102
+ * @param {(msg: string) => void} fail callback при помилці
103
+ * @returns {Promise<number>} кількість порушень
104
+ */
105
+ async function checkConnImports(absPackageRoot, sourcePaths, pkgJson, label, fail) {
106
+ const connDir = resolveConnDirFromPackageJson(pkgJson)
107
+ let violations = 0
108
+ for (const absPath of sourcePaths) {
109
+ const rel = relPosix(absPackageRoot, absPath)
110
+ if (!isConnImportsScanSourceFile(rel)) continue
111
+ if (isInsideConnDir(rel, connDir)) continue
112
+ const content = await readFile(absPath, 'utf8')
113
+ for (const v of findConnFactoryImportsInText(content, rel)) {
114
+ violations++
115
+ const target = v.specifier === '*' ? `'${v.module}'` : `{ ${v.specifier} } from '${v.module}'`
116
+ fail(
117
+ `${label}${rel}:${v.line} — імпорт ${target} має бути в '${connDir}/' і реекспортуватися через '#conn/*': ${v.snippet}`
118
+ )
119
+ }
120
+ }
121
+ return violations
122
+ }
123
+
124
+ /**
125
+ * Перевіряє правило «CheckEnv» для пакета.
126
+ * @param {string} absPackageRoot абсолютний корінь пакета
127
+ * @param {string[]} sourcePaths абсолютні шляхи до файлів
128
+ * @param {string} label префікс повідомлення `[<pkg>] `
129
+ * @param {(msg: string) => void} fail callback при помилці
130
+ * @returns {Promise<number>} кількість порушень
131
+ */
132
+ async function checkProcessEnvUsage(absPackageRoot, sourcePaths, label, fail) {
133
+ let violations = 0
134
+ for (const absPath of sourcePaths) {
135
+ const rel = relPosix(absPackageRoot, absPath)
136
+ const content = await readFile(absPath, 'utf8')
137
+ for (const v of findUncheckedProcessEnvInText(content, rel)) {
138
+ violations++
139
+ const message =
140
+ v.kind === 'process-env'
141
+ ? `${label}${rel}:${v.line} — process.env.${v.name}: заміни на env з '@nitra/check-env' (обов'язкова змінна + checkEnv(['${v.name}'])) або з 'node:process' (опційна)`
142
+ : `${label}${rel}:${v.line} — env.${v.name} (з '@nitra/check-env') без checkEnv(['${v.name}']) (або '// @nitra/cursor ignore-next-line checkEnv' попереду)`
143
+ fail(message)
144
+ }
145
+ }
146
+ return violations
147
+ }
148
+
149
+ /**
150
+ * Перевіряє відповідність правилам js-run.mdc для одного workspace-пакета.
151
+ * @param {string} rootDir відносний шлях workspace (не `'.'`)
152
+ * @param {(msg: string) => void} fail функція зворотного виклику для реєстрації помилки перевірки
153
+ * @param {(msg: string) => void} passFn успішне повідомлення (як у check-reporter)
154
+ * @returns {Promise<void>} завершується після перевірок цього пакета
155
+ */
156
+ async function checkWorkspacePackage(rootDir, fail, passFn) {
157
+ const label = `[${rootDir}] `
158
+ const absPackageRoot = join(process.cwd(), rootDir)
159
+ /** @type {unknown} */
160
+ let pkgJson = null
161
+ const pkgPath = join(rootDir, 'package.json')
162
+ if (existsSync(pkgPath)) {
163
+ pkgJson = JSON.parse(await readFile(pkgPath, 'utf8'))
164
+ const deps = /** @type {Record<string, unknown>} */ (pkgJson).dependencies
165
+ const devDeps = /** @type {Record<string, unknown>} */ (pkgJson).devDependencies
166
+ const allDeps = { ...(deps || {}), ...(devDeps || {}) }
167
+
168
+ if (allDeps['@nitra/bunyan']) {
169
+ fail(`${label}@nitra/bunyan знайдено — замінити на @nitra/pino`)
170
+ }
171
+ if (allDeps.bunyan) {
172
+ fail(`${label}bunyan знайдено — замінити на @nitra/pino`)
173
+ }
174
+ }
175
+
176
+ const importViolations = await checkBunyanImports(absPackageRoot, label, fail)
177
+ if (importViolations === 0) {
178
+ passFn(`${label}немає імпортів '@nitra/bunyan' / 'bunyan' у джерелах`)
179
+ }
180
+
181
+ const sourcePaths = await collectSourceFiles(absPackageRoot)
182
+
183
+ const connViolations = await checkConnImports(absPackageRoot, sourcePaths, pkgJson, label, fail)
184
+ if (connViolations === 0) {
185
+ const connDir = resolveConnDirFromPackageJson(pkgJson)
186
+ passFn(`${label}імпорти підключень (bun#SQL / mssql / @nitra/graphql-request#GraphQLClient) лише в '${connDir}/'`)
187
+ }
188
+
189
+ const envViolations = await checkProcessEnvUsage(absPackageRoot, sourcePaths, label, fail)
190
+ if (envViolations === 0) {
191
+ passFn(
192
+ `${label}немає прямого process.env.*; усі env.* з '@nitra/check-env' закриті checkEnv(['…']) (або '// @nitra/cursor ignore-next-line checkEnv')`
193
+ )
194
+ }
195
+
196
+ const configmapPath = join(rootDir, 'k8s', 'base', 'configmap.yaml')
197
+ if (existsSync(configmapPath)) {
198
+ const content = await readFile(configmapPath, 'utf8')
199
+ if (content.includes('OTEL_RESOURCE_ATTRIBUTES')) {
200
+ passFn(`${label}k8s/base/configmap.yaml містить OTEL_RESOURCE_ATTRIBUTES`)
201
+ if (content.includes('service.name=') && content.includes('service.namespace=')) {
202
+ passFn(`${label}OTEL_RESOURCE_ATTRIBUTES містить service.name та service.namespace`)
203
+ } else {
204
+ fail(`${label}OTEL_RESOURCE_ATTRIBUTES має містити service.name=<name>,service.namespace=<namespace>`)
205
+ }
206
+ } else {
207
+ fail(`${label}k8s/base/configmap.yaml не містить OTEL_RESOURCE_ATTRIBUTES`)
208
+ }
209
+ }
210
+ }
211
+
212
+ /**
213
+ * Перевіряє відповідність проєкту правилам js-run.mdc лише для workspace-пакетів (не корінь репо).
214
+ * @returns {Promise<number>} 0 — все OK, 1 — є проблеми
215
+ */
216
+ export async function check() {
217
+ const reporter = createCheckReporter()
218
+ const { pass, fail } = reporter
219
+
220
+ const roots = await getMonorepoPackageRootDirs()
221
+ const workspaceRoots = roots.filter(r => r !== '.')
222
+
223
+ if (workspaceRoots.length === 0) {
224
+ pass('js-run: немає workspace-пакетів у кореневому package.json — перевірку залежностей і k8s у пакетах пропущено')
225
+ return reporter.getExitCode()
226
+ }
227
+
228
+ for (const r of workspaceRoots) {
229
+ await checkWorkspacePackage(r, fail, pass)
230
+ }
231
+
232
+ return reporter.getExitCode()
233
+ }
@@ -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,292 @@
1
+ /**
2
+ * AST-сканер правила «process.env / CheckEnv» (js-run.mdc).
3
+ *
4
+ * Правило в .mdc формулює два контракти:
5
+ * 1. Прямий доступ до `process.env.X` має бути замінено на `env` — з пакета
6
+ * `@nitra/check-env` (для обов'язкових змінних, із викликом `checkEnv([...])`)
7
+ * або з `node:process` (для опційних). Тому будь-яке `process.env.X` сканер
8
+ * завжди реєструє як порушення з порадою про конкретну заміну.
9
+ * 2. Якщо у файл імпортовано `env` саме з `@nitra/check-env`, то кожне `env.X`
10
+ * має бути закрите літеральним викликом `checkEnv(['X', ...])` у тому ж файлі
11
+ * (порядок не важливий, кілька викликів зливаються в один список).
12
+ *
13
+ * Обидва контракти можна точково «приглушити» коментарем-маркером
14
+ * `// @nitra/cursor ignore-next-line checkEnv` на рядку безпосередньо перед
15
+ * порушенням — це залишається сумісним escape-hatch для legacy-коду.
16
+ *
17
+ * Семантика береться з **oxc-parser** через `parseProgramOrNull`: regex по тілу
18
+ * файлу не використовується, лише сирий текст рядка з коментарем перевіряється
19
+ * на маркер. Якщо файл не парситься — повертаємо порожній результат, спочатку
20
+ * треба полагодити синтаксис.
21
+ *
22
+ * Покриті форми доступу:
23
+ * - `process.env.X` / `process.env['X']` (як MemberExpression);
24
+ * - `const { X, Y } = process.env` (ObjectPattern; ім'я з ключа, не з alias);
25
+ * - аналогічно для `env.X` / `env['X']` / `const { X } = env`,
26
+ * де `env` має бути імпортований з `@nitra/check-env` (інакше ігноруємо —
27
+ * це може бути локальна змінна чи `env` з `node:process`).
28
+ *
29
+ * Якщо ключ обчислюваний (`process.env[varName]`) — пропускаємо без помилки,
30
+ * бо за статичним AST неможливо встановити, яка саме змінна оточення використовується.
31
+ */
32
+ import { offsetToLine, parseProgramOrNull, walkAstWithAncestors } from './ast-scan-utils.mjs'
33
+
34
+ const SOURCE_FILE_RE = /\.([cm]?[jt]sx?)$/u
35
+ const IGNORE_DIRECTIVE_RE = /\/\/\s*@nitra\/cursor\s+ignore-next-line\s+checkEnv\b/u
36
+
37
+ const CHECK_ENV_PACKAGE = '@nitra/check-env'
38
+
39
+ /**
40
+ * Чи є цей вузол виразом `process.env`.
41
+ * @param {unknown} node AST вузол
42
+ * @returns {boolean} true, якщо це `MemberExpression` `process.env` (Identifier . Identifier)
43
+ */
44
+ function isProcessEnvAccess(node) {
45
+ if (!node || typeof node !== 'object') return false
46
+ if (node.type !== 'MemberExpression' || node.computed) return false
47
+ const obj = node.object
48
+ const prop = node.property
49
+ return (
50
+ !!obj &&
51
+ obj.type === 'Identifier' &&
52
+ obj.name === 'process' &&
53
+ !!prop &&
54
+ prop.type === 'Identifier' &&
55
+ prop.name === 'env'
56
+ )
57
+ }
58
+
59
+ /**
60
+ * Витягує ім'я ENV з MemberExpression `obj.X` або `obj['X']`.
61
+ * @param {Record<string, unknown>} node MemberExpression, чий object — `process.env` або `env`
62
+ * @returns {string | null} ім'я змінної оточення або null, якщо ключ не статичний
63
+ */
64
+ function envNameFromMember(node) {
65
+ const prop = node.property
66
+ if (!prop || typeof prop !== 'object') return null
67
+ if (!node.computed && prop.type === 'Identifier' && typeof prop.name === 'string') return prop.name
68
+ if (node.computed && prop.type === 'Literal' && typeof prop.value === 'string') return prop.value
69
+ return null
70
+ }
71
+
72
+ /**
73
+ * Збирає всі літеральні імена з виклику `checkEnv([...])` у файлі.
74
+ * Якщо callee — Identifier `checkEnv` і перший аргумент — ArrayExpression, додає
75
+ * всі string-літерали до set. Не-літеральні елементи (Identifier, SpreadElement) ігноруються —
76
+ * це робить перевірку «ліберальною»: ми лише ловимо явно неперевірені змінні.
77
+ * @param {unknown} programNode корінь AST
78
+ * @returns {Set<string>} перелік закритих імен ENV
79
+ */
80
+ function collectCheckedEnvNames(programNode) {
81
+ /** @type {Set<string>} */
82
+ const out = new Set()
83
+ walkAstWithAncestors(programNode, [], node => {
84
+ if (node.type !== 'CallExpression') return
85
+ const callee = node.callee
86
+ if (!callee || callee.type !== 'Identifier' || callee.name !== 'checkEnv') return
87
+ const args = node.arguments
88
+ if (!Array.isArray(args) || args.length === 0) return
89
+ const first = args[0]
90
+ if (!first || typeof first !== 'object' || first.type !== 'ArrayExpression') return
91
+ const elements = first.elements
92
+ if (!Array.isArray(elements)) return
93
+ for (const el of elements) {
94
+ if (!el || typeof el !== 'object') continue
95
+ if (el.type === 'Literal' && typeof el.value === 'string') out.add(el.value)
96
+ }
97
+ })
98
+ return out
99
+ }
100
+
101
+ /**
102
+ * Чи імпортовано локальний ідентифікатор `env` саме з `@nitra/check-env`.
103
+ * Перевіряє ImportDeclaration на specifier {imported.name === 'env', local.name === 'env'}.
104
+ * Aliased-варіанти (`{ env as x }`) свідомо не підтримуються — у наших правилах
105
+ * приклади завжди використовують канонічне ім'я `env`.
106
+ * @param {unknown} programNode корінь AST
107
+ * @returns {boolean} true, якщо у файлі є `import { env } from '@nitra/check-env'`
108
+ */
109
+ function hasCheckEnvImport(programNode) {
110
+ let found = false
111
+ walkAstWithAncestors(programNode, [], node => {
112
+ if (found) return
113
+ if (node.type !== 'ImportDeclaration') return
114
+ const source = node.source
115
+ if (!source || typeof source !== 'object' || source.value !== CHECK_ENV_PACKAGE) return
116
+ const specifiers = node.specifiers
117
+ if (!Array.isArray(specifiers)) return
118
+ for (const s of specifiers) {
119
+ if (!s || typeof s !== 'object' || s.type !== 'ImportSpecifier') continue
120
+ const imported = s.imported
121
+ const local = s.local
122
+ if (!imported || imported.name !== 'env') continue
123
+ if (!local || local.name !== 'env') continue
124
+ found = true
125
+ return
126
+ }
127
+ })
128
+ return found
129
+ }
130
+
131
+ /**
132
+ * Чи закритий рядок ignore-коментарем `// @nitra/cursor ignore-next-line checkEnv`.
133
+ * @param {string[]} lines рядки файлу (split за \n, без CR)
134
+ * @param {number} oneBasedLine 1-based номер рядка з порушенням
135
+ * @returns {boolean} true, якщо попередній рядок містить маркер
136
+ */
137
+ function hasIgnoreDirective(lines, oneBasedLine) {
138
+ if (oneBasedLine <= 1) return false
139
+ const prev = lines[oneBasedLine - 2] ?? ''
140
+ return IGNORE_DIRECTIVE_RE.test(prev)
141
+ }
142
+
143
+ /**
144
+ * Чи є вузол MemberExpression виду `env.X` / `env['X']`, де `env` — Identifier
145
+ * (в AST oxc-parser globals і локальні імпорти не розрізняються — фільтр джерела
146
+ * робиться на рівні `hasCheckEnvImport`).
147
+ * @param {unknown} node AST вузол
148
+ * @returns {boolean} true, якщо це `env.<...>`
149
+ */
150
+ function isEnvIdentifierMember(node) {
151
+ if (!node || typeof node !== 'object' || node.type !== 'MemberExpression') return false
152
+ const obj = node.object
153
+ return !!obj && obj.type === 'Identifier' && obj.name === 'env'
154
+ }
155
+
156
+ /**
157
+ * @typedef {{
158
+ * line: number,
159
+ * name: string,
160
+ * kind: 'process-env' | 'check-env-missing-checkEnv'
161
+ * }} EnvViolation
162
+ */
163
+
164
+ /**
165
+ * Перебирає AST і для кожного знайденого доступу до `process.env` чи `env`
166
+ * (де `env` — імпорт з `@nitra/check-env`) реєструє порушення відповідного типу.
167
+ * @param {unknown} program корінь AST
168
+ * @param {string} content вихідний код (для offset → line)
169
+ * @param {string[]} lines split-рядки content (для ignore-маркера)
170
+ * @param {Set<string>} checkedNames імена, закриті літеральним `checkEnv([...])`
171
+ * @param {boolean} envFromCheckEnv чи імпортовано `env` саме з `@nitra/check-env`
172
+ * @returns {EnvViolation[]} список порушень (відсортований за порядком зустрічі в AST)
173
+ */
174
+ function collectViolations(program, content, lines, checkedNames, envFromCheckEnv) {
175
+ /** @type {EnvViolation[]} */
176
+ const out = []
177
+ /** @type {Set<string>} */
178
+ const reported = new Set()
179
+
180
+ /**
181
+ * Реєструє порушення з дедуплікацією за «kind|name|line» і урахуванням ignore-маркера.
182
+ * @param {'process-env' | 'check-env-missing-checkEnv'} kind тип порушення
183
+ * @param {string} name ім'я ENV
184
+ * @param {number} line 1-based рядок
185
+ */
186
+ function report(kind, name, line) {
187
+ if (hasIgnoreDirective(lines, line)) return
188
+ const key = `${kind}|${name}|${line}`
189
+ if (reported.has(key)) return
190
+ reported.add(key)
191
+ out.push({ kind, name, line })
192
+ }
193
+
194
+ walkAstWithAncestors(program, [], (node, ancestors) => {
195
+ // 1. process.env.X — завжди порушення (рекомендуємо замінити на env)
196
+ if (isProcessEnvAccess(node)) {
197
+ const parent = ancestors[ancestors.length - 1]
198
+ if (parent && typeof parent === 'object' && parent.type === 'MemberExpression' && parent.object === node) {
199
+ const envName = envNameFromMember(parent)
200
+ if (envName) report('process-env', envName, offsetToLine(content, parent.start))
201
+ }
202
+ if (
203
+ parent &&
204
+ typeof parent === 'object' &&
205
+ parent.type === 'VariableDeclarator' &&
206
+ parent.init === node &&
207
+ parent.id &&
208
+ parent.id.type === 'ObjectPattern' &&
209
+ Array.isArray(parent.id.properties)
210
+ ) {
211
+ for (const p of parent.id.properties) {
212
+ const name = staticPropertyName(p)
213
+ if (name) report('process-env', name, offsetToLine(content, p.start ?? parent.start))
214
+ }
215
+ }
216
+ return
217
+ }
218
+
219
+ // 2. env.X — порушення лише якщо env імпортовано з @nitra/check-env і немає checkEnv
220
+ if (!envFromCheckEnv) return
221
+
222
+ if (isEnvIdentifierMember(node)) {
223
+ const envName = envNameFromMember(node)
224
+ if (envName && !checkedNames.has(envName)) {
225
+ report('check-env-missing-checkEnv', envName, offsetToLine(content, node.start))
226
+ }
227
+ return
228
+ }
229
+
230
+ // const { X, Y } = env — теж потребує checkEnv для кожного імені
231
+ if (
232
+ node.type === 'VariableDeclarator' &&
233
+ node.init &&
234
+ node.init.type === 'Identifier' &&
235
+ node.init.name === 'env' &&
236
+ node.id &&
237
+ node.id.type === 'ObjectPattern' &&
238
+ Array.isArray(node.id.properties)
239
+ ) {
240
+ for (const p of node.id.properties) {
241
+ const name = staticPropertyName(p)
242
+ if (name && !checkedNames.has(name)) {
243
+ report('check-env-missing-checkEnv', name, offsetToLine(content, p.start ?? node.start))
244
+ }
245
+ }
246
+ }
247
+ })
248
+
249
+ return out
250
+ }
251
+
252
+ /**
253
+ * Витягує статичне ім'я з вузла Property у ObjectPattern.
254
+ * @param {unknown} property AST-вузол ObjectPattern.properties[i]
255
+ * @returns {string | null} ім'я ключа або null
256
+ */
257
+ function staticPropertyName(property) {
258
+ if (!property || typeof property !== 'object' || property.type !== 'Property') return null
259
+ if (property.computed) return null
260
+ const key = property.key
261
+ if (!key || typeof key !== 'object') return null
262
+ if (key.type === 'Identifier' && typeof key.name === 'string') return key.name
263
+ if (key.type === 'Literal' && typeof key.value === 'string') return key.value
264
+ return null
265
+ }
266
+
267
+ /**
268
+ * Знаходить порушення правила «process.env / CheckEnv» у файлі.
269
+ *
270
+ * @param {string} content вихідний код
271
+ * @param {string} [virtualPath] шлях для вибору `lang` парсера
272
+ * @returns {EnvViolation[]} список порушень із типом, іменем змінної та рядком
273
+ */
274
+ export function findUncheckedProcessEnvInText(content, virtualPath = 'scan.ts') {
275
+ const program = parseProgramOrNull(content, virtualPath)
276
+ if (!program) return []
277
+
278
+ const checked = collectCheckedEnvNames(program)
279
+ const envFromCheckEnv = hasCheckEnvImport(program)
280
+ const lines = content.split('\n').map(s => (s.endsWith('\r') ? s.slice(0, -1) : s))
281
+
282
+ return collectViolations(program, content, lines, checked, envFromCheckEnv)
283
+ }
284
+
285
+ /**
286
+ * Чи сканувати цей файл за розширенням (JS/TS-сім'я, без `.d.ts`).
287
+ * @param {string} relativePathPosix відносний шлях (posix)
288
+ * @returns {boolean} true, якщо розширення підходить для AST-скану
289
+ */
290
+ export function isCheckEnvScanSourceFile(relativePathPosix) {
291
+ return SOURCE_FILE_RE.test(relativePathPosix) && !relativePathPosix.endsWith('.d.ts')
292
+ }
@@ -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
- }