@nitra/cursor 1.8.112 → 1.8.114

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/n-cursor.js CHANGED
@@ -752,6 +752,76 @@ async function removeOrphanManagedCommandFiles(commandsDir, configSkills) {
752
752
  return removed.toSorted((a, b) => a.localeCompare(b))
753
753
  }
754
754
 
755
+ /**
756
+ * Синхронізує .claude/commands/{dirName}.md для всіх локальних скілів з .cursor/skills/
757
+ * що не керуються пакетом (відсутні в configSkills).
758
+ * @param {string[]} configSkills id керованих skills (вже оброблені syncCommands)
759
+ * @returns {Promise<{ success: number, fail: number }>} лічильники успішних і невдалих записів
760
+ */
761
+ async function syncLocalOnlySkillCommands(configSkills) {
762
+ const skillsRoot = join(cwd(), SKILLS_DIR)
763
+ if (!existsSync(skillsRoot)) return { success: 0, fail: 0 }
764
+
765
+ const commandsDir = join(cwd(), COMMANDS_DIR)
766
+ await mkdir(commandsDir, { recursive: true })
767
+
768
+ const managedDirNames = new Set(configSkills.map(s => managedSkillDirName(s)))
769
+ const allDirNames = await listProjectSkillDirNames()
770
+ const localOnly = allDirNames.filter(d => !managedDirNames.has(d))
771
+
772
+ let success = 0
773
+ let fail = 0
774
+
775
+ for (const dirName of localOnly) {
776
+ const skillMdPath = join(skillsRoot, dirName, 'SKILL.md')
777
+ const destFile = join(commandsDir, `${dirName}.md`)
778
+
779
+ process.stdout.write(` ⬇ ${dirName} → ${COMMANDS_DIR}/${dirName}.md ... `)
780
+ try {
781
+ let desc = ''
782
+ if (existsSync(skillMdPath)) {
783
+ const raw = await readFile(skillMdPath, 'utf8')
784
+ const parsed = extractSkillDescription(raw)
785
+ if (parsed) desc = skillDescriptionSafeForMarkdownInline(parsed)
786
+ }
787
+ const header = desc ? `# ${dirName} — ${desc}\n\n` : ''
788
+ const body = `${header}Виконай інструкції зі скілу \`${SKILLS_DIR}/${dirName}/SKILL.md\`.\n`
789
+ await writeFile(destFile, body, 'utf8')
790
+ console.log(`✅`)
791
+ success++
792
+ } catch (error) {
793
+ console.log(`❌`)
794
+ console.error(` Помилка: ${errorMessage(error)}`)
795
+ fail++
796
+ }
797
+ }
798
+ return { success, fail }
799
+ }
800
+
801
+ /**
802
+ * Видаляє .claude/commands/{dirName}.md файли локальних скілів, яких більше немає в .cursor/skills/
803
+ * @param {string} commandsDir абсолютний шлях до .claude/commands
804
+ * @param {string[]} configSkills id керованих skills
805
+ * @returns {Promise<string[]>} імена видалених файлів
806
+ */
807
+ async function removeOrphanLocalSkillCommandFiles(commandsDir, configSkills) {
808
+ if (!existsSync(commandsDir)) return []
809
+
810
+ const managedDirNames = new Set(configSkills.map(s => managedSkillDirName(s)))
811
+ const allDirNames = new Set(await listProjectSkillDirNames())
812
+ const names = await readdir(commandsDir)
813
+ const removed = []
814
+
815
+ for (const name of names.filter(n => n.endsWith('.md') && !n.startsWith(RULE_PREFIX))) {
816
+ const dirName = name.slice(0, -3)
817
+ if (!managedDirNames.has(dirName) && !allDirNames.has(dirName)) {
818
+ await unlink(join(commandsDir, name))
819
+ removed.push(name)
820
+ }
821
+ }
822
+ return removed.toSorted((a, b) => a.localeCompare(b))
823
+ }
824
+
755
825
  /**
756
826
  * Людинозрозумілий текст винятку для логів.
757
827
  * @param {unknown} error виняток із catch
@@ -1031,13 +1101,19 @@ async function runSync() {
1031
1101
 
1032
1102
  await runSyncStep('❌ Commands: ', async () => {
1033
1103
  const { success: cmdOk, fail: cmdFail } = await syncCommands(skills, bundledSkillsDir)
1034
- if (skills.length > 0) {
1035
- console.log(`\n⌨️ Commands: ${cmdOk} скопійовано, ${cmdFail} з помилками`)
1104
+ const { success: localOk, fail: localFail } = await syncLocalOnlySkillCommands(skills)
1105
+ const totalOk = cmdOk + localOk
1106
+ const totalFail = cmdFail + localFail
1107
+ if (totalOk + totalFail > 0) {
1108
+ console.log(`\n⌨️ Commands: ${totalOk} скопійовано, ${totalFail} з помилками`)
1036
1109
  }
1037
- const removedCmds = await removeOrphanManagedCommandFiles(join(cwd(), COMMANDS_DIR), skills)
1110
+ const commandsDir = join(cwd(), COMMANDS_DIR)
1111
+ const removedCmds = await removeOrphanManagedCommandFiles(commandsDir, skills)
1038
1112
  logRemovedManagedItems('commands', COMMANDS_DIR, removedCmds)
1039
- if (cmdFail > 0) {
1040
- throw new Error(`Не вдалося скопіювати ${cmdFail} з ${skills.length} commands`)
1113
+ const removedLocalCmds = await removeOrphanLocalSkillCommandFiles(commandsDir, skills)
1114
+ logRemovedManagedItems('commands (local)', COMMANDS_DIR, removedLocalCmds)
1115
+ if (totalFail > 0) {
1116
+ throw new Error(`Не вдалося скопіювати ${totalFail} commands`)
1041
1117
  }
1042
1118
  })
1043
1119
 
package/mdc/ga.mdc CHANGED
@@ -4,7 +4,7 @@ alwaysApply: true
4
4
  version: '1.3'
5
5
  ---
6
6
 
7
- У `.github/workflows/` лише **`.yml`**. Мають бути **`clean-ga-workflows.yml`**, **`clean-merged-branch.yml`**, **`lint-ga.yml`**. Якщо є **`apply-k8s.yml`** / **`apply-nats-consumer.yml`** — paths у тригері як у фрагментах.
7
+ У `.github/workflows/` лише **`.yml`**. Мають бути **`clean-ga-workflows.yml`**, **`clean-merged-branch.yml`**, **`lint-ga.yml`**, **`git-ai.yml`**. Якщо є **`apply-k8s.yml`** / **`apply-nats-consumer.yml`** — paths у тригері як у фрагментах.
8
8
 
9
9
  Повинен бути файл .github/workflows/clean-ga-workflows.yml, зі змістом:
10
10
 
@@ -110,6 +110,37 @@ jobs:
110
110
  run: bun run lint-ga
111
111
  ```
112
112
 
113
+ Повинен бути файл .github/workflows/git-ai.yml, зі змістом:
114
+
115
+ ```yaml
116
+ name: Git AI
117
+
118
+ on:
119
+ pull_request:
120
+ types: [closed]
121
+
122
+ jobs:
123
+ git-ai:
124
+ if: github.event.pull_request.merged == true
125
+ runs-on: ubuntu-latest
126
+ permissions:
127
+ contents: write
128
+
129
+ steps:
130
+ - name: Install git-ai
131
+ run: |
132
+ curl -fsSL https://usegitai.com/install.sh | bash
133
+ echo "$HOME/.git-ai/bin" >> $GITHUB_PATH
134
+ - name: Run git-ai
135
+ id: run-git-ai
136
+ env:
137
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
138
+ run: |
139
+ git config --global user.name "github-actions[bot]"
140
+ git config --global user.email "github-actions[bot]@users.noreply.github.com"
141
+ git-ai ci github run
142
+ ```
143
+
113
144
  **Локальний composite** (`uses: ./.github/actions/setup-bun-deps` або `./npm/github-actions/setup-bun-deps`): **спочатку** обов’язковий крок **`actions/checkout@v6`** (`persist-credentials: false`), інакше runner не знайде `action.yml`. Сам composite: **`actions/setup-node@v6`** (**Node 24**), **Bun**, **`actions/cache@v5`**, **`bun install --frozen-lockfile`**.
114
145
 
115
146
  ```json title=".vscode/extensions.json"
package/mdc/js-pino.mdc CHANGED
@@ -10,8 +10,6 @@ version: '1.1'
10
10
  В **/k8s/base/configmap.yaml повинен бути заданий OTEL_RESOURCE_ATTRIBUTES: 'service.name=<project_name>,service.namespace=<project_namespace>'
11
11
  а в директоріях з kustomize повинні бути перевизначені значення OTEL_RESOURCE_ATTRIBUTES і в них service.namespace повинен відповідати namespace, в якому знаходиться дана директорія.
12
12
 
13
- Configmap повинен бути з тією самою назвою, що і deployment, якщо у deployment тільки один configmap.
14
-
15
13
  ## Перевірка
16
14
 
17
15
  `npx @nitra/cursor check js-pino`
package/mdc/k8s.mdc CHANGED
@@ -210,6 +210,19 @@ spec:
210
210
 
211
211
  **Точні умови та повідомлення `fail`** — верхній JSDoc **`npm/scripts/check-k8s.mjs`**.
212
212
 
213
+ ## ConfigMap: ім'я збігається з Deployment
214
+
215
+ Якщо в `k8s/base/` є **`configmap.yaml`** і **Deployment**, і цей Deployment посилається рівно на **один** ConfigMap — `metadata.name` ConfigMap має збігатися з `metadata.name` Deployment. Точні умови перевірки — **`check-k8s.mjs`**.
216
+
217
+ ## ConfigMap для Hasura-Deployment
218
+
219
+ Якщо в `k8s/base/` поруч із **`configmap.yaml`** є **Deployment** з образом **`hasura/graphql-engine`**, у `data` ConfigMap **обов'язково** має бути ключ **`HASURA_GRAPHQL_ENABLE_REMOTE_SCHEMA_PERMISSIONS`** зі значенням **`"true"`**. Точні умови перевірки — **`check-k8s.mjs`**.
220
+
221
+ ```yaml
222
+ data:
223
+ HASURA_GRAPHQL_ENABLE_REMOTE_SCHEMA_PERMISSIONS: 'true'
224
+ ```
225
+
213
226
  ## Kustomize: структура каталогів (`base` / overlays)
214
227
 
215
228
  Трансформуй дерева **`**/k8s`**, щоб **винести спільне** через [Kustomize](https://kustomize.io/): один канонічний **`base`** і тонкі **overlays** для інших середовищ.
@@ -3,6 +3,7 @@ description: Правила nginx для статичних файлів
3
3
  version: '1.2'
4
4
  ---
5
5
 
6
+ > **Автоміграція:** `npx @nitra/cursor check nginx-default-tpl` автоматично перейменовує `default.tpl.conf` → `default.conf.template` (або перезаписує вміст, якщо обидва файли існують). Якщо шаблон відсутній — перевірка пропускається.
6
7
 
7
8
  default.conf.template повинен виглядати так:
8
9
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nitra/cursor",
3
- "version": "1.8.112",
3
+ "version": "1.8.114",
4
4
  "description": "CLI для завантаження cursor-правил (префікс n-) у локальний репозиторій",
5
5
  "keywords": [
6
6
  "cli",
@@ -256,7 +256,7 @@ function checkGaWorkflowFiles(wfDir, files, pass, fail) {
256
256
  pass('Всі workflows мають розширення .yml')
257
257
  }
258
258
 
259
- for (const f of ['clean-ga-workflows.yml', 'clean-merged-branch.yml', 'lint-ga.yml']) {
259
+ for (const f of ['clean-ga-workflows.yml', 'clean-merged-branch.yml', 'lint-ga.yml', 'git-ai.yml']) {
260
260
  if (files.includes(f)) {
261
261
  pass(`${f} існує`)
262
262
  } else {
@@ -265,6 +265,66 @@ function checkGaWorkflowFiles(wfDir, files, pass, fail) {
265
265
  }
266
266
  }
267
267
 
268
+ /**
269
+ * Перевіряє git-ai.yml: тригер pull_request з types: [closed], умова merged у job, виклик git-ai.
270
+ * @param {string} wfDir директорія workflows
271
+ * @param {(msg: string) => void} passFn callback при успішній перевірці
272
+ * @param {(msg: string) => void} failFn callback при помилці
273
+ */
274
+ async function checkGitAiWorkflow(wfDir, passFn, failFn) {
275
+ const gitAiWf = join(wfDir, 'git-ai.yml')
276
+ if (!existsSync(gitAiWf)) return
277
+ const content = await readFile(gitAiWf, 'utf8')
278
+ const root = parseWorkflowYaml(content)
279
+
280
+ if (root) {
281
+ // on.pull_request.types має містити 'closed'
282
+ const on = root.on
283
+ let hasPrClosed = false
284
+ if (on && typeof on === 'object') {
285
+ const pr = /** @type {Record<string, unknown>} */ (on)['pull_request']
286
+ if (pr && typeof pr === 'object') {
287
+ const types = /** @type {Record<string, unknown>} */ (pr).types
288
+ hasPrClosed = Array.isArray(types) && types.includes('closed')
289
+ }
290
+ }
291
+ if (hasPrClosed) {
292
+ passFn('git-ai.yml: on.pull_request.types містить closed')
293
+ } else {
294
+ failFn('git-ai.yml: on.pull_request.types має містити closed (ga.mdc)')
295
+ }
296
+
297
+ // Job if-умова: запускати лише при злитті PR
298
+ const jobs = root.jobs
299
+ let hasMergedCondition = false
300
+ if (jobs && typeof jobs === 'object') {
301
+ for (const job of Object.values(jobs)) {
302
+ if (job && typeof job === 'object') {
303
+ const ifCond = String(/** @type {Record<string, unknown>} */ (job).if ?? '')
304
+ if (ifCond.includes('merged')) {
305
+ hasMergedCondition = true
306
+ }
307
+ }
308
+ }
309
+ }
310
+ if (hasMergedCondition) {
311
+ passFn('git-ai.yml: job має умову merged')
312
+ } else {
313
+ failFn('git-ai.yml: job має містити if: github.event.pull_request.merged == true (ga.mdc)')
314
+ }
315
+ }
316
+
317
+ // Крок викликає git-ai ci github run
318
+ const hasGitAiRun = root
319
+ ? anyRunStepIncludes(root, 'git-ai ci github run')
320
+ : content.includes('git-ai ci github run')
321
+ if (hasGitAiRun) {
322
+ passFn('git-ai.yml: крок виконує git-ai ci github run')
323
+ } else {
324
+ failFn('git-ai.yml: крок має містити git-ai ci github run (ga.mdc)')
325
+ }
326
+ }
327
+
268
328
  /**
269
329
  * Перевіряє відповідність проєкту правилам ga.mdc
270
330
  * @returns {Promise<number>} 0 — все OK, 1 — є проблеми
@@ -318,6 +378,7 @@ export async function check() {
318
378
  await checkZizmor(pass, fail)
319
379
  await checkLintGaScript(pass, fail)
320
380
  await checkLintGaWorkflow(wfDir, pass, fail)
381
+ await checkGitAiWorkflow(wfDir, pass, fail)
321
382
 
322
383
  return reporter.getExitCode()
323
384
  }
@@ -5,6 +5,8 @@
5
5
  * (`import` / `require` / динамічний `import()`); наявність `OTEL_RESOURCE_ATTRIBUTES`
6
6
  * у `k8s/base/configmap.yaml`, якщо такий файл існує.
7
7
  *
8
+ * Перевірка відповідності імені ConfigMap імені Deployment — у `check-k8s.mjs` (k8s.mdc).
9
+ *
8
10
  * Імпорти в джерелах сканує AST через `oxc-parser` (див. `utils/bunyan-imports.mjs`),
9
11
  * щоб виявити випадки на кшталт `import log from '@nitra/bunyan'`, які лишаються в коді
10
12
  * після підміни залежності.
@@ -78,7 +80,7 @@ async function checkWorkspacePackage(rootDir, fail, passFn) {
78
80
  passFn(`${label}немає імпортів '@nitra/bunyan' / 'bunyan' у джерелах`)
79
81
  }
80
82
 
81
- const configmapPath = join(rootDir, 'k8s/base/configmap.yaml')
83
+ const configmapPath = join(rootDir, 'k8s', 'base', 'configmap.yaml')
82
84
  if (existsSync(configmapPath)) {
83
85
  const content = await readFile(configmapPath, 'utf8')
84
86
  if (content.includes('OTEL_RESOURCE_ATTRIBUTES')) {
@@ -56,9 +56,14 @@
56
56
  * Dockerfile — правило docker.mdc, скрипт check-docker.mjs.
57
57
  *
58
58
  * **Структура `HTTPRoute` для Hasura-Deployment:** звіряється канон 4 правил у **`spec.rules`** (редиректи **`<prefix>/ql`** і **`<prefix>/ql/`** на **`<prefix>/ql/console`** 302, **`PathPrefix <prefix>/ql`** + **URLRewrite** на **`/`**, окреме WebSocket-правило з **`RequestHeaderModifier`** remove **`Authorization`**). **Префікс параметризовано** (рядок перед **`/ql`** у першому Hasura-правилі). **Прив'язка** — за **`metadata.name`** у тому ж каталозі, що й **Deployment** з образом **`hasura/graphql-engine`** (див. k8s.mdc). **Додаткові правила** поверх канону дозволені.
59
+ *
60
+ * **ConfigMap для Hasura-Deployment:** якщо в `k8s/base/` є `configmap.yaml` і поруч Deployment з образом
61
+ * **`hasura/graphql-engine`**, то в `data` ConfigMap обов'язково має бути ключ
62
+ * **`HASURA_GRAPHQL_ENABLE_REMOTE_SCHEMA_PERMISSIONS`** зі значенням **`"true"`** (приймається булеве `true`
63
+ * або рядок `"true"`, без регістрової залежності).
59
64
  */
60
65
  import { existsSync } from 'node:fs'
61
- import { readFile, stat, unlink } from 'node:fs/promises'
66
+ import { readFile, readdir, stat, unlink } from 'node:fs/promises'
62
67
  import { basename, dirname, join, relative, resolve } from 'node:path'
63
68
 
64
69
  import { parseAllDocuments } from 'yaml'
@@ -1959,6 +1964,131 @@ export function isHasuraDeploymentManifest(manifest) {
1959
1964
  return containerListHasHasuraImage(p.containers) || containerListHasHasuraImage(p.initContainers)
1960
1965
  }
1961
1966
 
1967
+ /**
1968
+ * Обов'язковий ключ у **`data`** ConfigMap для Hasura-Deployment (узгоджено з k8s.mdc).
1969
+ */
1970
+ export const HASURA_REMOTE_SCHEMA_PERMISSIONS_KEY = 'HASURA_GRAPHQL_ENABLE_REMOTE_SCHEMA_PERMISSIONS'
1971
+
1972
+ /**
1973
+ * Чи значення поля `data.<key>` у ConfigMap читається як логічне **true**.
1974
+ * ConfigMap у Kubernetes тримає значення як рядки, але в YAML часто пишуть без лапок —
1975
+ * тому приймаємо і булевий **true**, і рядок **"true"** (без регістрової залежності).
1976
+ * @param {unknown} v значення з `data[HASURA_REMOTE_SCHEMA_PERMISSIONS_KEY]`
1977
+ * @returns {boolean} true, якщо значення — `true` або рядок `'true'`
1978
+ */
1979
+ function isConfigMapValueTrue(v) {
1980
+ if (v === true) return true
1981
+ if (typeof v === 'string' && v.trim().toLowerCase() === 'true') return true
1982
+ return false
1983
+ }
1984
+
1985
+ /**
1986
+ * Чи порушує ConfigMap вимогу щодо **`HASURA_GRAPHQL_ENABLE_REMOTE_SCHEMA_PERMISSIONS: "true"`** (k8s.mdc).
1987
+ * Перевірка застосовна, коли в тому ж каталозі є Hasura-Deployment (див. `isHasuraDeploymentManifest`).
1988
+ * @param {unknown} manifest корінь YAML-документа ConfigMap
1989
+ * @returns {string | null} текст порушення або null, якщо не ConfigMap / ключ є і значення `true`
1990
+ */
1991
+ export function hasuraConfigMapRemoteSchemaPermissionsViolation(manifest) {
1992
+ if (manifest === null || manifest === undefined || typeof manifest !== 'object' || Array.isArray(manifest))
1993
+ return null
1994
+ const rec = /** @type {Record<string, unknown>} */ (manifest)
1995
+ if (rec.kind !== 'ConfigMap') return null
1996
+ const data = rec.data
1997
+ if (data === null || data === undefined || typeof data !== 'object' || Array.isArray(data)) {
1998
+ return `data.${HASURA_REMOTE_SCHEMA_PERMISSIONS_KEY}: додай ключ зі значенням "true" (Deployment з hasura/graphql-engine — див. k8s.mdc)`
1999
+ }
2000
+ const d = /** @type {Record<string, unknown>} */ (data)
2001
+ if (!Object.hasOwn(d, HASURA_REMOTE_SCHEMA_PERMISSIONS_KEY)) {
2002
+ return `data.${HASURA_REMOTE_SCHEMA_PERMISSIONS_KEY}: додай ключ зі значенням "true" (Deployment з hasura/graphql-engine — див. k8s.mdc)`
2003
+ }
2004
+ if (!isConfigMapValueTrue(d[HASURA_REMOTE_SCHEMA_PERMISSIONS_KEY])) {
2005
+ return `data.${HASURA_REMOTE_SCHEMA_PERMISSIONS_KEY}: значення має бути "true" (зараз: ${JSON.stringify(d[HASURA_REMOTE_SCHEMA_PERMISSIONS_KEY])}) (див. k8s.mdc)`
2006
+ }
2007
+ return null
2008
+ }
2009
+
2010
+ const K8S_YAML_EXT_RE = /\.ya?ml$/iu
2011
+
2012
+ /**
2013
+ * Знаходить перший документ **Deployment** серед YAML-файлів каталогу (для перевірки імені ConfigMap, js-pino.mdc).
2014
+ * @param {string} dirPath абсолютний шлях до каталогу
2015
+ * @returns {Promise<Record<string, unknown> | null>} об'єкт Deployment або null
2016
+ */
2017
+ export async function findDeploymentDocInDir(dirPath) {
2018
+ let entries
2019
+ try {
2020
+ entries = await readdir(dirPath)
2021
+ } catch {
2022
+ return null
2023
+ }
2024
+ for (const entry of entries) {
2025
+ if (!K8S_YAML_EXT_RE.test(entry)) continue
2026
+ let raw
2027
+ try {
2028
+ raw = await readFile(join(dirPath, entry), 'utf8')
2029
+ } catch {
2030
+ continue
2031
+ }
2032
+ let docs
2033
+ try {
2034
+ docs = parseAllDocuments(raw)
2035
+ } catch {
2036
+ continue
2037
+ }
2038
+ for (const doc of docs) {
2039
+ if (doc.errors.length > 0) continue
2040
+ const obj = doc.toJSON()
2041
+ if (obj !== null && typeof obj === 'object' && !Array.isArray(obj)) {
2042
+ const rec = /** @type {Record<string, unknown>} */ (obj)
2043
+ if (rec.kind === 'Deployment') return rec
2044
+ }
2045
+ }
2046
+ }
2047
+ return null
2048
+ }
2049
+
2050
+ /**
2051
+ * Збирає унікальні імена **ConfigMap**, на які посилається **Deployment**
2052
+ * через `spec.template.spec.containers[*].envFrom[*].configMapRef.name`
2053
+ * та `spec.template.spec.volumes[*].configMap.name` (для перевірки js-pino.mdc).
2054
+ * @param {Record<string, unknown>} deployment об'єкт Deployment
2055
+ * @returns {Set<string>} унікальні імена ConfigMap
2056
+ */
2057
+ export function collectDeploymentConfigMapRefs(deployment) {
2058
+ /** @type {Set<string>} */
2059
+ const names = new Set()
2060
+ const spec = deployment.spec
2061
+ if (spec === null || typeof spec !== 'object' || Array.isArray(spec)) return names
2062
+ const template = /** @type {Record<string, unknown>} */ (spec).template
2063
+ if (template === null || typeof template !== 'object' || Array.isArray(template)) return names
2064
+ const podSpec = /** @type {Record<string, unknown>} */ (template).spec
2065
+ if (podSpec === null || typeof podSpec !== 'object' || Array.isArray(podSpec)) return names
2066
+ const ps = /** @type {Record<string, unknown>} */ (podSpec)
2067
+ for (const c of Array.isArray(ps.containers) ? /** @type {unknown[]} */ (ps.containers) : []) {
2068
+ if (c === null || typeof c !== 'object' || Array.isArray(c)) continue
2069
+ const envFrom = /** @type {Record<string, unknown>} */ (c).envFrom
2070
+ for (const ef of Array.isArray(envFrom) ? /** @type {unknown[]} */ (envFrom) : []) {
2071
+ if (ef !== null && typeof ef === 'object' && !Array.isArray(ef)) {
2072
+ const cmr = /** @type {Record<string, unknown>} */ (ef).configMapRef
2073
+ if (cmr !== null && typeof cmr === 'object' && !Array.isArray(cmr)) {
2074
+ const n = /** @type {Record<string, unknown>} */ (cmr).name
2075
+ if (typeof n === 'string' && n.trim() !== '') names.add(n)
2076
+ }
2077
+ }
2078
+ }
2079
+ }
2080
+ for (const v of Array.isArray(ps.volumes) ? /** @type {unknown[]} */ (ps.volumes) : []) {
2081
+ if (v !== null && typeof v === 'object' && !Array.isArray(v)) {
2082
+ const cm = /** @type {Record<string, unknown>} */ (v).configMap
2083
+ if (cm !== null && typeof cm === 'object' && !Array.isArray(cm)) {
2084
+ const n = /** @type {Record<string, unknown>} */ (cm).name
2085
+ if (typeof n === 'string' && n.trim() !== '') names.add(n)
2086
+ }
2087
+ }
2088
+ }
2089
+ return names
2090
+ }
2091
+
1962
2092
  /**
1963
2093
  * Чи **Service** містить заборонені анотації GKE у **`metadata.annotations`** (k8s.mdc).
1964
2094
  * @param {unknown} manifest корінь YAML-документа
@@ -3160,6 +3290,126 @@ async function ensureBaseKustomizationHasNamespace(root, yamlFiles, fail) {
3160
3290
  }
3161
3291
  }
3162
3292
 
3293
+ const CONFIGMAP_BASE_PATH_RE = /\/k8s\/base\/configmap\.yaml$/u
3294
+
3295
+ /**
3296
+ * Якщо в `k8s/base/` є `configmap.yaml` і Deployment посилається рівно на один ConfigMap —
3297
+ * `metadata.name` ConfigMap має збігатися з `metadata.name` Deployment (k8s.mdc).
3298
+ * @param {string} root корінь репозиторію
3299
+ * @param {string[]} yamlFilesAbs yaml під k8s
3300
+ * @param {(msg: string) => void} fail callback при помилці
3301
+ * @param {(msg: string) => void} passFn callback при успіху
3302
+ */
3303
+ async function validateConfigMapNameMatchesDeployment(root, yamlFilesAbs, fail, passFn) {
3304
+ const cmFiles = yamlFilesAbs.filter(abs => {
3305
+ const rel = relative(root, abs).replaceAll('\\', '/')
3306
+ return CONFIGMAP_BASE_PATH_RE.test(`/${rel}`) || rel === 'k8s/base/configmap.yaml'
3307
+ })
3308
+ for (const cmAbs of cmFiles) {
3309
+ const rel = relative(root, cmAbs).replaceAll('\\', '/') || cmAbs
3310
+ let raw
3311
+ try {
3312
+ raw = await readFile(cmAbs, 'utf8')
3313
+ } catch {
3314
+ continue
3315
+ }
3316
+ let cmName = null
3317
+ try {
3318
+ for (const doc of parseAllDocuments(raw)) {
3319
+ if (doc.errors.length > 0) continue
3320
+ const obj = doc.toJSON()
3321
+ if (obj !== null && typeof obj === 'object' && !Array.isArray(obj)) {
3322
+ const rec = /** @type {Record<string, unknown>} */ (obj)
3323
+ if (rec.kind === 'ConfigMap') {
3324
+ const meta = rec.metadata
3325
+ if (meta !== null && typeof meta === 'object' && !Array.isArray(meta)) {
3326
+ const n = /** @type {Record<string, unknown>} */ (meta).name
3327
+ if (typeof n === 'string' && n.trim() !== '') cmName = n
3328
+ }
3329
+ break
3330
+ }
3331
+ }
3332
+ }
3333
+ } catch {
3334
+ continue
3335
+ }
3336
+ if (cmName === null) continue
3337
+ const deployment = await findDeploymentDocInDir(dirname(cmAbs))
3338
+ if (deployment === null) continue
3339
+ const meta = deployment.metadata
3340
+ const deployName =
3341
+ meta !== null && typeof meta === 'object' && !Array.isArray(meta)
3342
+ ? /** @type {Record<string, unknown>} */ (meta).name
3343
+ : null
3344
+ const cmRefs = collectDeploymentConfigMapRefs(deployment)
3345
+ if (cmRefs.size !== 1 || typeof deployName !== 'string') continue
3346
+ if (cmName === deployName) {
3347
+ passFn(`${rel}: metadata.name '${cmName}' збігається з Deployment (k8s.mdc)`)
3348
+ } else {
3349
+ fail(
3350
+ `${rel}: metadata.name '${cmName}' має збігатися з назвою Deployment '${deployName}' — Deployment посилається рівно на один ConfigMap (k8s.mdc)`
3351
+ )
3352
+ }
3353
+ }
3354
+ }
3355
+
3356
+ /**
3357
+ * Знаходить перший документ **ConfigMap** у файлі (з `metadata.name`).
3358
+ * @param {string} absPath абсолютний шлях до YAML-файлу
3359
+ * @returns {Promise<Record<string, unknown> | null>} об'єкт ConfigMap або null
3360
+ */
3361
+ async function readFirstConfigMapDoc(absPath) {
3362
+ let raw
3363
+ try {
3364
+ raw = await readFile(absPath, 'utf8')
3365
+ } catch {
3366
+ return null
3367
+ }
3368
+ let docs
3369
+ try {
3370
+ docs = parseAllDocuments(raw)
3371
+ } catch {
3372
+ return null
3373
+ }
3374
+ for (const doc of docs) {
3375
+ if (doc.errors.length > 0) continue
3376
+ const obj = doc.toJSON()
3377
+ if (obj !== null && typeof obj === 'object' && !Array.isArray(obj)) {
3378
+ const rec = /** @type {Record<string, unknown>} */ (obj)
3379
+ if (rec.kind === 'ConfigMap') return rec
3380
+ }
3381
+ }
3382
+ return null
3383
+ }
3384
+
3385
+ /**
3386
+ * Для кожного `k8s/base/configmap.yaml`, у каталозі якого поруч є Hasura-Deployment,
3387
+ * вимагає у `data` ключ **`HASURA_GRAPHQL_ENABLE_REMOTE_SCHEMA_PERMISSIONS`** зі значенням **`"true"`** (k8s.mdc).
3388
+ * @param {string} root корінь репозиторію
3389
+ * @param {string[]} yamlFilesAbs yaml під k8s
3390
+ * @param {(msg: string) => void} fail callback при помилці
3391
+ * @param {(msg: string) => void} passFn callback при успіху
3392
+ */
3393
+ async function validateHasuraConfigMapRemoteSchemaPermissions(root, yamlFilesAbs, fail, passFn) {
3394
+ const cmFiles = yamlFilesAbs.filter(abs => {
3395
+ const rel = relative(root, abs).replaceAll('\\', '/')
3396
+ return CONFIGMAP_BASE_PATH_RE.test(`/${rel}`) || rel === 'k8s/base/configmap.yaml'
3397
+ })
3398
+ for (const cmAbs of cmFiles) {
3399
+ const rel = relative(root, cmAbs).replaceAll('\\', '/') || cmAbs
3400
+ const deployment = await findDeploymentDocInDir(dirname(cmAbs))
3401
+ if (deployment === null || !isHasuraDeploymentManifest(deployment)) continue
3402
+ const cm = await readFirstConfigMapDoc(cmAbs)
3403
+ if (cm === null) continue
3404
+ const violation = hasuraConfigMapRemoteSchemaPermissionsViolation(cm)
3405
+ if (violation !== null) {
3406
+ fail(`${rel}: ${violation}`)
3407
+ } else {
3408
+ passFn(`${rel}: ${HASURA_REMOTE_SCHEMA_PERMISSIONS_KEY}="true" для Hasura-Deployment (k8s.mdc)`)
3409
+ }
3410
+ }
3411
+ }
3412
+
3163
3413
  /**
3164
3414
  * Перевіряє відповідність проєкту правилам k8s.mdc.
3165
3415
  * @returns {Promise<number>} 0 — все OK, 1 — є проблеми
@@ -3201,5 +3451,9 @@ export async function check() {
3201
3451
 
3202
3452
  await ensureBaseKustomizationHasNamespace(root, yamlFiles, fail)
3203
3453
 
3454
+ await validateConfigMapNameMatchesDeployment(root, yamlFiles, fail, pass)
3455
+
3456
+ await validateHasuraConfigMapRemoteSchemaPermissions(root, yamlFiles, fail, pass)
3457
+
3204
3458
  return reporter.getExitCode()
3205
3459
  }