@nitra/cursor 1.8.156 → 1.8.157

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,18 @@
1
+ # Changelog
2
+
3
+ Усі помітні зміни цього модуля документуються тут.
4
+
5
+ Формат — [Keep a Changelog](https://keepachangelog.com/uk/1.1.0/), нумерація — [SemVer](https://semver.org/lang/uk/).
6
+
7
+ ## [1.8.157] - 2026-04-30
8
+
9
+ ### Added
10
+
11
+ - Правило `npm-module.mdc`: секція **CHANGELOG** — разом із bump build-версії в `npm/package.json` обовʼязково оновлювати `npm/CHANGELOG.md` (Keep a Changelog).
12
+ - `check-npm-module.mjs`: перевірка наявності `npm/CHANGELOG.md`, наявності в `files` у `npm/package.json` і запису для поточної версії.
13
+ - `check-hasura.mjs`: перевірка `HASURA_GRAPHQL_ENDPOINT` у `*.env` для проєктів **nitra** і **abie** — має бути внутрішнім кластерним URL виду `http://<service>.<namespace>.svc.<cluster>.internal:<port>`; за наявності `hasura/k8s/base/svc-hl.yaml` та `hasura/k8s/base/namespace.yaml` додатково звіряється `<service>` і `<namespace>`.
14
+
15
+ ### Changed
16
+
17
+ - `npm/package.json`: `CHANGELOG.md` додано в масив `files`, щоб публікувався разом із пакетом.
18
+ - `hasura.mdc`: текст правила переформульовано як людинозрозумілий з прикладом і посиланням на `check-hasura.mjs`.
package/bin/auto-rules.md CHANGED
@@ -16,6 +16,8 @@ ga - якщо присутня директорія .github/workflows
16
16
 
17
17
  graphql - якщо хоч в одному js або vue файлі присутній gql` темплейт літерал
18
18
 
19
+ hasura - якщо в директорії присутній config.yaml, який містить рядок `metadata_directory: metadata`
20
+
19
21
  js-lint - якщо присутній хоч один js файл
20
22
 
21
23
  js-run - якщо це вкладена директорія з package.json (не в корені) та в devDependencies немає vite
package/mdc/hasura.mdc ADDED
@@ -0,0 +1,31 @@
1
+ ---
2
+ description: Правила для директорії з hasura graphql-engine
3
+ alwaysApply: true
4
+ version: '1.0'
5
+ ---
6
+
7
+ ## Підключення для оновлення метаданих у CI (Nitra та Abinbevefes)
8
+
9
+ У `*.env` для атрибута `HASURA_GRAPHQL_ENDPOINT` підключення має бути **усередині кластера**, а не через публічний домен — інакше CI кладе метадані через зовнішній бекенд і ламає перевипуск/міграції.
10
+
11
+ Приклад **неправильного** значення:
12
+
13
+ ```env
14
+ HASURA_GRAPHQL_ENDPOINT=https://napitkivmeste.tech/contract/ql
15
+ ```
16
+
17
+ Правильне значення:
18
+
19
+ ```env
20
+ HASURA_GRAPHQL_ENDPOINT=http://contract-h.ua-contract.svc.abie-ua.internal:8080
21
+ ```
22
+
23
+ де `contract-h` — це `metadata.name` сервісу з `hasura/k8s/base/svc-hl.yaml`, а `ua-contract` — `metadata.name` namespace з `hasura/k8s/base/namespace.yaml`.
24
+
25
+ Правило застосовується для проєктів **nitra** (у кореневому `package.json` `"repository": "https://github.com/nitra/*"`) і **abie** (`"repository": "https://github.com/abinbevefes/*"`); для інших репозиторіїв перевірка пропускається.
26
+
27
+ ## Перевірка
28
+
29
+ `npx @nitra/cursor check hasura`
30
+
31
+ Деталі алгоритму — у `check-hasura.mjs`.
package/mdc/js-lint.mdc CHANGED
@@ -1,10 +1,10 @@
1
1
  ---
2
2
  description: Перевірка JavaScript коду
3
3
  alwaysApply: true
4
- version: '1.15'
4
+ version: '1.16'
5
5
  ---
6
6
 
7
- **oxlint**, **ESLint**, **jscpd**. У скрипті **`lint-js`** і в CI — **`bunx oxlint`**, **`bunx eslint`**, **`bunx jscpd`** (у CI без **`--fix`** для oxlint/eslint — див. приклад workflow нижче). Без **prettier** і **@nitra/prettier-config**. У **`devDependencies`** має бути **`@nitra/eslint-config` мінімум `^3.6.12`** (з ним транзитивно йде **`@e18e/eslint-plugin`** для oxlint); пакет **`@e18e/eslint-plugin`** окремо не додавай. Пакети oxlint/eslint/jscpd не додавай без потреби монорепо.
7
+ **oxlint**, **ESLint**, **jscpd**. У скрипті **`lint-js`** і в CI — **`bunx oxlint`**, **`bunx eslint`**, **`bunx jscpd`** (у CI без **`--fix`** для oxlint/eslint — див. приклад workflow нижче). Без **prettier** і **@nitra/prettier-config**. У **`devDependencies`** має бути **`@nitra/eslint-config` мінімум `^3.8.0`** (з цієї версії правило `no-restricted-syntax` забороняє `for...in`; також транзитивно йде **`@e18e/eslint-plugin`** для oxlint); пакет **`@e18e/eslint-plugin`** окремо не додавай. Пакети oxlint/eslint/jscpd не додавай без потреби монорепо.
8
8
 
9
9
  ```json title=".vscode/extensions.json"
10
10
  {
@@ -25,12 +25,12 @@ version: '1.15'
25
25
  "lint-js": "bunx oxlint --fix && bunx eslint --fix . && bunx jscpd ."
26
26
  },
27
27
  "devDependencies": {
28
- "@nitra/eslint-config": "^3.6.12"
28
+ "@nitra/eslint-config": "^3.8.0"
29
29
  }
30
30
  }
31
31
  ```
32
32
 
33
- У корені має бути **`.oxlintrc.json`**, який **збігається з каноном** oxlint з пакета **`@nitra/cursor`**: файл **`npm/scripts/utils/oxlint-canonical.json`** (plugins, jsPlugins з **`@e18e/eslint-plugin`**, categories, повний набір **rules** із канону — додаткові записи в **`rules`** дозволені; також **`settings`**, **`env`**, **`globals`**, **`ignorePatterns`**). Оновити можна з репозиторію пакета або скопіювавши файл після **`bun ./scripts/utils/rebuild-oxlint-canonical.mjs`** (джерело правил — **`oxlint-rules.tsv`** + скелет **`oxlint-canonical-skeleton.json`**). Модуль **`@e18e/eslint-plugin`** не оголошуй окремо в **`package.json`** — він уже в залежностях **`@nitra/eslint-config`** (з **3.6.12**), oxlint підвантажує його з **`node_modules`**.
33
+ У корені має бути **`.oxlintrc.json`**, який **збігається з каноном** oxlint з пакета **`@nitra/cursor`**: файл **`npm/scripts/utils/oxlint-canonical.json`** (plugins, jsPlugins з **`@e18e/eslint-plugin`**, categories, повний набір **rules** із канону — додаткові записи в **`rules`** дозволені; також **`settings`**, **`env`**, **`globals`**, **`ignorePatterns`**). Оновити можна з репозиторію пакета або скопіювавши файл після **`bun ./scripts/utils/rebuild-oxlint-canonical.mjs`** (джерело правил — **`oxlint-rules.tsv`** + скелет **`oxlint-canonical-skeleton.json`**). Модуль **`@e18e/eslint-plugin`** не оголошуй окремо в **`package.json`** — він уже в залежностях **`@nitra/eslint-config`** (з **3.8.0**), oxlint підвантажує його з **`node_modules`**.
34
34
 
35
35
  Мінімум для розуміння структури (реальний корінь конфігу має збігатися з каноном повністю):
36
36
 
@@ -144,6 +144,31 @@ export default [
144
144
  }
145
145
  ```
146
146
 
147
+ ## `for...in` заборонено — рефакторити на `for...of`
148
+
149
+ Конструкція `for (const k in obj)` обходить успадковані ключі прототипу, тому майже завжди тягне `Object.hasOwn(obj, k)`-guard. Заборонена у `@nitra/eslint-config` через `no-restricted-syntax` для `ForInStatement` (з версії **3.8.0**). У каноні oxlint лишається `guard-for-in` як часткова страховка (oxlint не підтримує `no-restricted-syntax`). Замість цього обходь масив напряму, а обʼєкт — через `Object.entries` / `Object.keys` / `Object.values`. У такому коді guard за `Object.hasOwn` стає непотрібним і має зникнути разом із `for...in`.
150
+
151
+ ```javascript title="❌ до"
152
+ for (const k in obj) {
153
+ if (!Object.hasOwn(obj, k)) continue
154
+ use(k, obj[k])
155
+ }
156
+
157
+ for (const i in arr) {
158
+ use(arr[i])
159
+ }
160
+ ```
161
+
162
+ ```javascript title="✅ після"
163
+ for (const [k, v] of Object.entries(obj)) {
164
+ use(k, v)
165
+ }
166
+
167
+ for (const item of arr) {
168
+ use(item)
169
+ }
170
+ ```
171
+
147
172
  ## Тести
148
173
 
149
174
  Проєкт має бути покритий unit-тестами (**Bun test**). Код: синтаксис Node **24+**, **top level await** (узгоджено з `engines.node` у `package.json`).
package/mdc/js-mssql.mdc CHANGED
@@ -26,7 +26,7 @@ let poolPromise;
26
26
 
27
27
  export function getPool() {
28
28
  if (!poolPromise) {
29
- const pool = new sql.ConnectionPool(config);
29
+ const db = new SQL.ConnectionPool(config);
30
30
  poolPromise = pool.connect().catch(err => {
31
31
  poolPromise = undefined; // дозволити повторну спробу
32
32
  throw err;
package/mdc/js-run.mdc CHANGED
@@ -16,7 +16,6 @@ package.json
16
16
  readme.md
17
17
  ```
18
18
 
19
-
20
19
  ## Використання @nitra/pino
21
20
 
22
21
  Проект використовує @nitra/pino для логування.
@@ -53,13 +52,14 @@ import { GraphQLClient } from '@nitra/graphql-request'
53
52
  ```
54
53
 
55
54
  так виглядатиме підключення до PostgreSQL в коді:
55
+
56
56
  ```javascript title="Приклад підключення до PostgreSQL в /src/conn/pg.js"
57
57
  import { checkEnv, env } from '@nitra/check-env'
58
58
  import { SQL } from 'bun'
59
59
 
60
60
  checkEnv(['PG_CONN'])
61
61
 
62
- export const pool = new SQL({ url: env.PG_CONN })
62
+ export const db = new SQL({ url: env.PG_CONN })
63
63
 
64
64
  ```
65
65
 
@@ -80,7 +80,6 @@ export const graphQLClientSmart = new GraphQLClient(env.QL, {
80
80
  })
81
81
  ```
82
82
 
83
-
84
83
  а в коді повинно бути використано:
85
84
 
86
85
  ```js
@@ -91,24 +90,20 @@ import { pool } from '#conn/pg.js'
91
90
  import { gql, graphQLClient } from '@nitra/graphql-request'
92
91
  ```
93
92
 
94
-
95
-
96
93
  ## CheckEnv
97
94
 
98
95
  Усі змінні оточення, які використовуються в коді, повинні бути перевірені за допомогою `checkEnv` з пакету `@nitra/check-env`. Це гарантує, що всі необхідні змінні оточення встановлені перед запуском програми.
99
96
 
100
-
101
97
  ```javascript title="Приклад підключення до PostgreSQL в /src/conn/pg.js"
102
98
  import { checkEnv, env } from '@nitra/check-env'
103
99
  import { SQL } from 'bun'
104
100
 
105
101
  checkEnv(['PG_CONN'])
106
102
 
107
- export const pool = new SQL({ url: env.PG_CONN })
103
+ export const db = new SQL({ url: env.PG_CONN })
108
104
 
109
105
  ```
110
106
 
111
-
112
107
  ## process.env
113
108
 
114
109
  Прямий доступ до `process.env.X` у коді заборонений — його треба замінити на `env`:
@@ -127,7 +122,6 @@ console.log(env.OPTIONAL_ENV_VAR)
127
122
  `// @nitra/cursor ignore-next-line checkEnv` безпосередньо перед використанням
128
123
  (escape-hatch для legacy-коду, не для нових файлів).
129
124
 
130
-
131
125
  ## Перевірка
132
126
 
133
127
  `npx @nitra/cursor check js-run`
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  description: Оформлення репозиторію для npm модуля
3
3
  alwaysApply: true
4
- version: '1.7'
4
+ version: '1.8'
5
5
  ---
6
6
 
7
7
  Bun monorepo: workspace **`npm/`**, кореневий **`package.json`**, **`.github/workflows/`**; опційно **`demo/`**.
@@ -49,6 +49,14 @@ bunx -p typescript tsc src/**/*.js --declaration --allowJs --emitDeclarationOnly
49
49
 
50
50
  **Підказка:** щоб не дублювати bump і бачити різницю зі збереженим деревом, перевір `git status npm/package.json` або `git diff HEAD -- npm/package.json` перед другим підвищенням у тій самій гілці / наборі змін.
51
51
 
52
+ ## CHANGELOG
53
+
54
+ Разом із підвищенням **build**-версії в **`npm/package.json`** оновлюй **`npm/CHANGELOG.md`** — додавай новий запис із номером версії та коротким описом змін у модулі. Без оновлення `CHANGELOG.md` зміни в `npm/` зливати в `main` не можна.
55
+
56
+ Формат — [Keep a Changelog](https://keepachangelog.com/uk/1.1.0/) (новіші версії зверху, мова — українська). Кожен запис починається з рядка `## [версія] - YYYY-MM-DD` і має одну або кілька секцій: `### Added`, `### Changed`, `### Fixed`, `### Removed`.
57
+
58
+ Файл **`CHANGELOG.md`** має бути в масиві **`files`** у **`npm/package.json`**, щоб публікувався разом із пакетом.
59
+
52
60
  ## npm publish
53
61
 
54
62
  **`npm-publish.yml`:** push у **`main`**, **`on.push.paths`** з **`npm/**`**, **`JS-DevTools/npm-publish@v4.1.5`**, **`with.package: npm/package.json`**, **`permissions.id-token: write`** (OIDC на npm).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nitra/cursor",
3
- "version": "1.8.156",
3
+ "version": "1.8.157",
4
4
  "description": "CLI для завантаження cursor-правил (префікс n-) у локальний репозиторій",
5
5
  "keywords": [
6
6
  "cli",
@@ -30,7 +30,8 @@
30
30
  "schemas",
31
31
  "scripts",
32
32
  "skills",
33
- "AGENTS.template.md"
33
+ "AGENTS.template.md",
34
+ "CHANGELOG.md"
34
35
  ],
35
36
  "type": "module",
36
37
  "types": "./types/bin/n-cursor.d.ts",
@@ -3,7 +3,8 @@
3
3
  *
4
4
  * Модуль аналізує дерево проєкту (наявність файлів/директорій, `gql\`...\`` у source,
5
5
  * залежності `mssql` / `pg` / `pg-format` / `mysql2` у `package.json`, імпорт `sql`/`SQL` з `bun`, кореневий
6
- * `package.json`) та повертає ідентифікатори правил і skills, які потрібно автододати.
6
+ * `package.json`, `config.yaml` з рядком `metadata_directory: metadata` для hasura)
7
+ * та повертає ідентифікатори правил і skills, які потрібно автододати.
7
8
  *
8
9
  * Також враховує винятки `disable-rules` і `disable-skills`: елементи з цих списків не
9
10
  * додаються автоматично.
@@ -28,6 +29,7 @@ export const AUTO_RULE_ORDER = Object.freeze([
28
29
  'docker',
29
30
  'ga',
30
31
  'graphql',
32
+ 'hasura',
31
33
  'js-lint',
32
34
  'js-mssql',
33
35
  'js-bun-db',
@@ -45,6 +47,7 @@ export const AUTO_RULE_ORDER = Object.freeze([
45
47
  export const AUTO_SKILL_ORDER = Object.freeze(['abie-kustomize', 'fix', 'lint'])
46
48
 
47
49
  const ABIE_REPOSITORY_URL_MARKER = 'https://github.com/abinbevefes/'
50
+ const HASURA_CONFIG_MARKER = 'metadata_directory: metadata'
48
51
  const JS_LIKE_RE = /\.(?:mjs|cjs|js|jsx|ts|tsx)$/iu
49
52
  const STYLE_RE = /\.(?:css|vue)$/iu
50
53
  const VUE_RE = /\.vue$/iu
@@ -138,6 +141,24 @@ async function collectDependencyKeysPresentInPackageJsonTree(root, dependencyKey
138
141
  return found
139
142
  }
140
143
 
144
+ /**
145
+ * Перевіряє один package.json: повертає true, якщо в `devDependencies` немає `vite`.
146
+ * @param {string} absPath абсолютний шлях до package.json
147
+ * @returns {Promise<boolean>} true, якщо vite відсутній у devDependencies
148
+ */
149
+ async function packageJsonLacksViteDevDependency(absPath) {
150
+ try {
151
+ const parsed = JSON.parse(await readFile(absPath, 'utf8'))
152
+ const devDeps = parsed?.devDependencies
153
+ if (!devDeps || typeof devDeps !== 'object' || Array.isArray(devDeps)) {
154
+ return true
155
+ }
156
+ return !Object.hasOwn(devDeps, 'vite')
157
+ } catch {
158
+ return false
159
+ }
160
+ }
161
+
141
162
  /**
142
163
  * Перевіряє, чи існує хоча б один вкладений `package.json` (не кореневий),
143
164
  * у якому в `devDependencies` відсутня залежність `vite`.
@@ -147,28 +168,10 @@ async function collectDependencyKeysPresentInPackageJsonTree(root, dependencyKey
147
168
  async function hasNestedPackageJsonWithoutViteDevDependency(root) {
148
169
  let result = false
149
170
 
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
171
  /**
169
172
  * Рекурсивний обхід каталогу з пропуском службових директорій.
170
173
  * @param {string} dir абсолютний шлях каталогу
171
- * @returns {Promise<void>}
174
+ * @returns {Promise<void>} завершується після обходу всього піддерева або встановлення `result`
172
175
  */
173
176
  async function walk(dir) {
174
177
  if (result) return
@@ -187,11 +190,14 @@ async function hasNestedPackageJsonWithoutViteDevDependency(root) {
187
190
  }
188
191
  continue
189
192
  }
190
- if (entry.isFile() && entry.name === 'package.json' && absPath !== join(root, 'package.json')) {
191
- if (await packageJsonLacksViteDevDependency(absPath)) {
192
- result = true
193
- return
194
- }
193
+ if (
194
+ entry.isFile() &&
195
+ entry.name === 'package.json' &&
196
+ absPath !== join(root, 'package.json') &&
197
+ (await packageJsonLacksViteDevDependency(absPath))
198
+ ) {
199
+ result = true
200
+ return
195
201
  }
196
202
  }
197
203
  }
@@ -313,6 +319,26 @@ async function updateBunSqlFactFromFile(absPath, relPath, facts) {
313
319
  }
314
320
  }
315
321
 
322
+ /**
323
+ * Оновлює ознаку `hasHasuraConfig`, якщо файл — `config.yaml` із рядком
324
+ * `metadata_directory: metadata` (маркер hasura graphql-engine).
325
+ * @param {string} absPath абсолютний шлях до файлу
326
+ * @param {string} fileName базове імʼя файлу
327
+ * @param {{ hasHasuraConfig: boolean }} facts агреговані факти
328
+ * @returns {Promise<void>}
329
+ */
330
+ async function updateHasuraFactFromFile(absPath, fileName, facts) {
331
+ if (facts.hasHasuraConfig || fileName !== 'config.yaml') return
332
+ try {
333
+ const content = await readFile(absPath, 'utf8')
334
+ if (content.includes(HASURA_CONFIG_MARKER)) {
335
+ facts.hasHasuraConfig = true
336
+ }
337
+ } catch {
338
+ /* ігноруємо пошкоджені/недоступні файли */
339
+ }
340
+ }
341
+
316
342
  /**
317
343
  * Обробляє файл під час обходу дерева.
318
344
  * @param {string} absPath абсолютний шлях до файлу
@@ -322,6 +348,7 @@ async function updateBunSqlFactFromFile(absPath, relPath, facts) {
322
348
  * hasCapacitorConfig: boolean,
323
349
  * hasDockerfile: boolean,
324
350
  * hasGqlTaggedTemplates: boolean,
351
+ * hasHasuraConfig: boolean,
325
352
  * hasJsLikeSource: boolean,
326
353
  * hasNginxDefaultTplFile: boolean,
327
354
  * hasVueOrCssSource: boolean,
@@ -339,6 +366,7 @@ async function processFileEntry(absPath, root, facts) {
339
366
  if (shouldScanFileForBunSql(rel, facts)) {
340
367
  await updateBunSqlFactFromFile(absPath, rel, facts)
341
368
  }
369
+ await updateHasuraFactFromFile(absPath, fileName, facts)
342
370
  }
343
371
 
344
372
  /**
@@ -407,6 +435,7 @@ export function isMonorepoPackage(packageJson) {
407
435
  * hasGaWorkflowsDir: boolean,
408
436
  * hasBunSqlImport: boolean,
409
437
  * hasGqlTaggedTemplates: boolean,
438
+ * hasHasuraConfig: boolean,
410
439
  * hasJsLikeSource: boolean,
411
440
  * hasK8sDir: boolean,
412
441
  * hasNginxDefaultTplFile: boolean,
@@ -423,6 +452,7 @@ export async function collectAutoRuleFacts(root) {
423
452
  hasDockerfile: false,
424
453
  hasGaWorkflowsDir: existsSync(join(root, '.github', 'workflows')),
425
454
  hasGqlTaggedTemplates: false,
455
+ hasHasuraConfig: false,
426
456
  hasJsLikeSource: false,
427
457
  hasK8sDir: false,
428
458
  hasNginxDefaultTplFile: false,
@@ -496,10 +526,10 @@ export async function detectAutoRulesAndSkills({
496
526
  : null
497
527
  )
498
528
  const isAbie = typeof repositoryUrl === 'string' && repositoryUrl.toLowerCase().includes(ABIE_REPOSITORY_URL_MARKER)
499
- const isMonorepo = isMonorepoPackage(packageJsonParsed)
500
529
  const depHits = await collectDependencyKeysPresentInPackageJsonTree(root, ['mssql', 'pg', 'pg-format', 'mysql2'])
501
530
  const hasMssqlDependency = depHits.has('mssql')
502
- const hasJsBunDbSignal = depHits.has('pg') || depHits.has('pg-format') || depHits.has('mysql2') || facts.hasBunSqlImport
531
+ const hasJsBunDbSignal =
532
+ depHits.has('pg') || depHits.has('pg-format') || depHits.has('mysql2') || facts.hasBunSqlImport
503
533
  const hasNestedNodePackage = await hasNestedPackageJsonWithoutViteDevDependency(root)
504
534
 
505
535
  /** @type {string[]} */
@@ -538,6 +568,7 @@ export async function detectAutoRulesAndSkills({
538
568
  { enabled: facts.hasDockerfile, id: 'docker' },
539
569
  { enabled: facts.hasGaWorkflowsDir, id: 'ga' },
540
570
  { enabled: facts.hasGqlTaggedTemplates, id: 'graphql' },
571
+ { enabled: facts.hasHasuraConfig, id: 'hasura' },
541
572
  { enabled: facts.hasJsLikeSource, id: 'js-lint' },
542
573
  { enabled: hasMssqlDependency, id: 'js-mssql' },
543
574
  { enabled: hasJsBunDbSignal, id: 'js-bun-db' },
@@ -57,9 +57,7 @@ async function readPackageScripts(projectRoot) {
57
57
  */
58
58
  export async function buildAgentsCommandBulletItems(projectRoot) {
59
59
  const scripts = await readPackageScripts(projectRoot)
60
- const items = /** @type {{ name: string }[]} */ ([])
61
-
62
- items.push({ name: `- **Залежності**: \`bun i\`` })
60
+ const items = /** @type {{ name: string }[]} */ ([{ name: `- **Залежності**: \`bun i\`` }])
63
61
 
64
62
  const added = new Set()
65
63
 
@@ -79,10 +77,12 @@ export async function buildAgentsCommandBulletItems(projectRoot) {
79
77
  added.add(key)
80
78
  }
81
79
 
82
- items.push({
83
- name: `- **Оновити правила та ${AGENTS_MD}** (після змін у правилах/шаблоні CLI): \`npx ${PACKAGE_NAME}\``
84
- })
85
- items.push({ name: `- **Перевірки правил (programmatic)**: \`npx ${PACKAGE_NAME} check\`` })
80
+ items.push(
81
+ {
82
+ name: `- **Оновити правила та ${AGENTS_MD}** (після змін у правилах/шаблоні CLI): \`npx ${PACKAGE_NAME}\``
83
+ },
84
+ { name: `- **Перевірки правил (programmatic)**: \`npx ${PACKAGE_NAME} check\`` }
85
+ )
86
86
 
87
87
  return items
88
88
  }
@@ -0,0 +1,219 @@
1
+ /**
2
+ * Перевіряє правило hasura.mdc для проєктів **nitra** і **abie**: значення
3
+ * `HASURA_GRAPHQL_ENDPOINT` у `*.env` має бути **внутрішнім** кластерним URL,
4
+ * а не публічним доменом.
5
+ *
6
+ * Запускається лише якщо в кореневому `package.json` поле `repository`
7
+ * вказує на `https://github.com/nitra/...` або `https://github.com/abinbevefes/...`
8
+ * (інші репозиторії пропускаються без помилок — як у check-abie).
9
+ *
10
+ * Очікуваний формат URL:
11
+ * `http://<service>.<namespace>.svc.<cluster>.internal:<port>`
12
+ *
13
+ * приклад: `http://contract-h.ua-contract.svc.abie-ua.internal:8080`
14
+ *
15
+ * Сегменти беруться з `hasura/k8s/base/svc-hl.yaml` (`metadata.name` —
16
+ * має закінчуватись на `-h`, headless-сервіс) і `hasura/k8s/base/namespace.yaml`
17
+ * (`metadata.name` — namespace). Якщо ці YAML є в репозиторії, у URL додатково
18
+ * звіряються конкретні `<service>` і `<namespace>` з ними.
19
+ *
20
+ * Скануються всі файли `*.env` (наприклад `dev.env`, `production.env`); файл
21
+ * `.env` без префікса також враховується. Пропускаються `node_modules`,
22
+ * `.git`, `dist`, `coverage`, `.turbo`, `.next` (як у `walkDir`).
23
+ */
24
+ import { existsSync } from 'node:fs'
25
+ import { readFile } from 'node:fs/promises'
26
+ import { basename, join, relative } from 'node:path'
27
+
28
+ import { parseAllDocuments } from 'yaml'
29
+
30
+ import { getRepositoryUrl } from './auto-rules.mjs'
31
+ import { createCheckReporter } from './utils/check-reporter.mjs'
32
+ import { walkDir } from './utils/walkDir.mjs'
33
+
34
+ const NITRA_REPOSITORY_URL_MARKER = 'https://github.com/nitra/'
35
+ const ABIE_REPOSITORY_URL_MARKER = 'https://github.com/abinbevefes/'
36
+
37
+ const HASURA_BASE_DIR = 'hasura/k8s/base'
38
+ const HASURA_SVC_HL_FILE = `${HASURA_BASE_DIR}/svc-hl.yaml`
39
+ const HASURA_NAMESPACE_FILE = `${HASURA_BASE_DIR}/namespace.yaml`
40
+
41
+ const ENV_FILE_RE = /\.env$/u
42
+ const HASURA_ENDPOINT_LINE_RE = /^[ \t]*(?:export[ \t]+)?HASURA_GRAPHQL_ENDPOINT[ \t]*=[ \t]*['"]?([^'"\r\n#]+)/mu
43
+
44
+ /**
45
+ * Розбір значення `HASURA_GRAPHQL_ENDPOINT` як внутрішнього кластерного URL.
46
+ * Дозволяє лише `http://` (TLS усередині кластера зайвий), вимагає сегментів
47
+ * `<service>.<namespace>.svc.<cluster>.internal` та явного порту.
48
+ * @param {string} url значення з `.env` (без огорнутих лапок)
49
+ * @returns {{ ok: true, service: string, namespace: string, cluster: string, port: string } | { ok: false }}
50
+ * деталі URL або фейл, якщо формат не відповідає внутрішньому кластерному URL
51
+ */
52
+ export function parseInternalHasuraEndpoint(url) {
53
+ const trimmed = url.trim()
54
+ const m = trimmed.match(/^http:\/\/([^./]+)\.([^./]+)\.svc\.([^./]+)\.internal:(\d+)\/?$/u)
55
+ if (!m) {
56
+ return { ok: false }
57
+ }
58
+ return { ok: true, service: m[1], namespace: m[2], cluster: m[3], port: m[4] }
59
+ }
60
+
61
+ /**
62
+ * Зчитує `metadata.name` з першого документа YAML, який має заданий `kind`.
63
+ * @param {string} absPath абсолютний шлях до YAML
64
+ * @param {string} kind очікуваний `kind` (наприклад `Service`, `Namespace`)
65
+ * @returns {Promise<string | null>} ім'я ресурсу або null, якщо файл/документ відсутній
66
+ */
67
+ async function readYamlMetadataName(absPath, kind) {
68
+ if (!existsSync(absPath)) {
69
+ return null
70
+ }
71
+ let docs
72
+ try {
73
+ docs = parseAllDocuments(await readFile(absPath, 'utf8'))
74
+ } catch {
75
+ return null
76
+ }
77
+ for (const doc of docs) {
78
+ const obj = doc.toJS()
79
+ if (obj && typeof obj === 'object' && obj.kind === kind && obj.metadata?.name) {
80
+ return String(obj.metadata.name)
81
+ }
82
+ }
83
+ return null
84
+ }
85
+
86
+ /**
87
+ * Чи відносний шлях вказує на `*.env` (включно з `.env`).
88
+ * @param {string} relPath posix-шлях відносно кореня
89
+ * @returns {boolean} true для файлів виду `.env`, `dev.env`, `nitra.env`
90
+ */
91
+ export function isEnvFile(relPath) {
92
+ return ENV_FILE_RE.test(relPath)
93
+ }
94
+
95
+ /**
96
+ * Збирає всі `*.env` файли в дереві, окрім службових каталогів.
97
+ * @param {string} root абсолютний шлях кореня
98
+ * @returns {Promise<string[]>} відсортовані posix-шляхи відносно кореня
99
+ */
100
+ async function collectEnvFiles(root) {
101
+ /** @type {string[]} */
102
+ const out = []
103
+ await walkDir(root, absPath => {
104
+ const rel = relative(root, absPath).split('\\').join('/')
105
+ if (isEnvFile(rel)) {
106
+ out.push(rel)
107
+ }
108
+ })
109
+ return out.toSorted((a, b) => a.localeCompare(b))
110
+ }
111
+
112
+ /**
113
+ * Перевіряє один `.env` файл на коректність `HASURA_GRAPHQL_ENDPOINT`.
114
+ * Якщо в файлі немає змінної — вважаємо OK.
115
+ * @param {string} relPath відносний шлях файла
116
+ * @param {{ service: string | null, namespace: string | null }} expected очікувані сегменти з YAML
117
+ * @param {{ pass: (msg: string) => void, fail: (msg: string) => void }} reporter репортер
118
+ * @returns {Promise<void>}
119
+ */
120
+ async function checkEnvFile(relPath, expected, reporter) {
121
+ const { pass, fail } = reporter
122
+ const content = await readFile(relPath, 'utf8')
123
+ const m = content.match(HASURA_ENDPOINT_LINE_RE)
124
+ if (!m) {
125
+ return
126
+ }
127
+ const value = m[1].trim()
128
+ const parsed = parseInternalHasuraEndpoint(value)
129
+ if (!parsed.ok) {
130
+ fail(
131
+ `${relPath}: HASURA_GRAPHQL_ENDPOINT="${value}" — потрібен внутрішній кластерний URL виду ` +
132
+ `https://<service>.<namespace>.svc.<cluster>.internal:<port> (hasura.mdc)`
133
+ )
134
+ return
135
+ }
136
+ if (expected.service && parsed.service !== expected.service) {
137
+ fail(
138
+ `${relPath}: HASURA_GRAPHQL_ENDPOINT — сервіс "${parsed.service}" не збігається з ` +
139
+ `metadata.name "${expected.service}" із ${HASURA_SVC_HL_FILE} (hasura.mdc)`
140
+ )
141
+ return
142
+ }
143
+ if (expected.namespace && parsed.namespace !== expected.namespace) {
144
+ fail(
145
+ `${relPath}: HASURA_GRAPHQL_ENDPOINT — namespace "${parsed.namespace}" не збігається з ` +
146
+ `metadata.name "${expected.namespace}" із ${HASURA_NAMESPACE_FILE} (hasura.mdc)`
147
+ )
148
+ return
149
+ }
150
+ pass(`${relPath}: HASURA_GRAPHQL_ENDPOINT — внутрішній кластерний URL`)
151
+ }
152
+
153
+ /**
154
+ * Зчитує URL репозиторію з кореневого `package.json` (або null, якщо файла немає / не валідний).
155
+ * @returns {Promise<string | null>} URL з поля `repository`
156
+ */
157
+ async function readRootRepositoryUrl() {
158
+ if (!existsSync('package.json')) {
159
+ return null
160
+ }
161
+ try {
162
+ const pkg = JSON.parse(await readFile('package.json', 'utf8'))
163
+ return getRepositoryUrl(pkg?.repository)
164
+ } catch {
165
+ return null
166
+ }
167
+ }
168
+
169
+ /**
170
+ * Чи URL репозиторію вказує на nitra або abie (за маркерами hasura.mdc).
171
+ * @param {string | null} url значення з `package.json` `repository`
172
+ * @returns {boolean} true для nitra/abie проєктів
173
+ */
174
+ export function isNitraOrAbieRepository(url) {
175
+ if (typeof url !== 'string') {
176
+ return false
177
+ }
178
+ const lc = url.toLowerCase()
179
+ return lc.includes(NITRA_REPOSITORY_URL_MARKER) || lc.includes(ABIE_REPOSITORY_URL_MARKER)
180
+ }
181
+
182
+ /**
183
+ * Перевіряє hasura.mdc для поточного робочого каталогу.
184
+ * @returns {Promise<number>} 0 — OK / правило не застосовується, 1 — порушення
185
+ */
186
+ export async function check() {
187
+ const reporter = createCheckReporter()
188
+ const { pass } = reporter
189
+
190
+ const repositoryUrl = await readRootRepositoryUrl()
191
+ if (!isNitraOrAbieRepository(repositoryUrl)) {
192
+ pass('Пропущено: репозиторій не nitra і не abie (hasura.mdc застосовується лише до них)')
193
+ return reporter.getExitCode()
194
+ }
195
+
196
+ const root = process.cwd()
197
+ const expected = {
198
+ service: await readYamlMetadataName(join(root, HASURA_SVC_HL_FILE), 'Service'),
199
+ namespace: await readYamlMetadataName(join(root, HASURA_NAMESPACE_FILE), 'Namespace')
200
+ }
201
+
202
+ const envFiles = await collectEnvFiles(root)
203
+ if (envFiles.length === 0) {
204
+ pass('Не знайдено жодного *.env файла — нічого перевіряти')
205
+ return reporter.getExitCode()
206
+ }
207
+
208
+ for (const rel of envFiles) {
209
+ await checkEnvFile(rel, expected, reporter)
210
+ }
211
+
212
+ // Якщо у файлах не було жодної згадки HASURA_GRAPHQL_ENDPOINT — повідом про це.
213
+ const exit = reporter.getExitCode()
214
+ if (exit === 0) {
215
+ const names = envFiles.map(p => basename(p)).join(', ')
216
+ pass(`Перевірено ${envFiles.length} *.env файл(ів): ${names}`)
217
+ }
218
+ return exit
219
+ }