@nitra/cursor 1.8.128 → 1.8.130

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/mdc/abie.mdc CHANGED
@@ -36,7 +36,7 @@ spec:
36
36
 
37
37
  ## k8s: overlay **HTTPRoute** (**ua** / **ru**)
38
38
 
39
- За наявності **Deployment** під **k8s** і наявності **Vite** (**`vite.config.js`**, **`vite.config.mjs`** або **`vite.config.ts`** у каталозі пакета) у **`ua/kustomization.yaml`** та **`ru/kustomization.yaml`** цього пакета потрібні **inline JSON6902** у **`patches`**: **target** **`kind: HTTPRoute`**, **непорожній `name`** (як у маніфесті маршруту). Мають бути зміни **`/spec/hostnames`** (домени abie — у скрипті) та **`/spec/parentRefs/0/namespace`** (**`ua`** / **`ru`**). Для **ru** — анотація **`gwin.yandex.cloud/rules.http.upgradeTypes: websocket`** лише якщо в **тому ж** **`ru/kustomization.yaml`** є згадка **`HASURA_GRAPHQL_JWT_SECRET`** (типово patch на **ConfigMap** Hasura). Як обирати **`op`** (**add** / **replace** тощо) у patch — **k8s.mdc** (розділ про JSON patch у kustomization).
39
+ За наявності **Deployment** під **k8s** і наявності **Vite** (**`vite.config.js`**, **`vite.config.mjs`** або **`vite.config.ts`** у каталозі пакета) у **`ua/kustomization.yaml`** та **`ru/kustomization.yaml`** цього пакета потрібні **inline JSON6902** у **`patches`**: **target** **`kind: HTTPRoute`**, **непорожній `name`** (як у маніфесті маршруту). Мають бути зміни **`/spec/hostnames`** (домени abie — у скрипті) та **`/spec/parentRefs/0/namespace`** (**`ua`** / **`ru`**, також дозволені префікси **`ua-*`** / **`ru-*`**, наприклад **`ua-b2b`**, **`ru-b2b`**). Для **ru** — анотація **`gwin.yandex.cloud/rules.http.upgradeTypes: websocket`** лише якщо в **тому ж** **`ru/kustomization.yaml`** є згадка **`HASURA_GRAPHQL_JWT_SECRET`** (типово patch на **ConfigMap** Hasura). Як обирати **`op`** (**add** / **replace** тощо) у patch — **k8s.mdc** (розділ про JSON patch у kustomization).
40
40
 
41
41
  ### HTTPRoute: спільні сервіси **`auth-run-hl`**, **`file-link-hl`**
42
42
 
@@ -60,7 +60,7 @@ spec:
60
60
  port: 8080
61
61
  ```
62
62
 
63
- У **`ua/kustomization.yaml`** та **`ru/kustomization.yaml`** додай до того самого **inline** patch на **`HTTPRoute`** (той самий **`target.name`**) операції **JSON6902** з **`path`**: **`/spec/rules/<i>/backendRefs/<j>/namespace`**, де **`<i>`** / **`<j>`** — індекси відповідно до порядку **`spec.rules`** та **`backendRefs`** у base-файлі; **`value`**: **`ua`** або **`ru`**. Якщо кілька таких **`backendRefs`**, потрібна окрема операція для кожного.
63
+ У **`ua/kustomization.yaml`** та **`ru/kustomization.yaml`** додай до того самого **inline** patch на **`HTTPRoute`** (той самий **`target.name`**) операції **JSON6902** з **`path`**: **`/spec/rules/<i>/backendRefs/<j>/namespace`**, де **`<i>`** / **`<j>`** — індекси відповідно до порядку **`spec.rules`** та **`backendRefs`** у base-файлі; **`value`**: **`ua`** / **`ru`** (також дозволені **`ua-*`** / **`ru-*`**). Якщо кілька таких **`backendRefs`**, потрібна окрема операція для кожного.
64
64
 
65
65
  ```yaml title="…/ua/kustomization.yaml (фрагмент)"
66
66
  - target:
package/mdc/ga.mdc CHANGED
@@ -161,6 +161,37 @@ jobs:
161
161
 
162
162
  **Кроки `run`:** не розбивай команду shell-продовженням через зворотний сліш у кінці рядка (`… \` у `run: |`). Замість багаторядкового буквального блока з `\\` оформ довгу одну shell-команду як **folded block** `>-` (рядки з’єднаються в один рядок із пробілами).
163
163
 
164
+ **Читабельність `run: >-` з циклам/умовами:** оскільки `>-` **згортає рядки в один shell-рядок**, відступи потрібні **лише для читабельності** й не впливають на виконання. Але для конструкцій bash на кшталт `while …; do … done` та `if …; then … fi` **обовʼязково**:
165
+
166
+ - став явні роздільники команд **`;`** або **`&&`** там, де при згортанні рядків інакше “злипнуться” токени (`do`/`then`/`fi`/`done` з наступною командою);
167
+ - тримай `do` і `then` в одному логічному рядку як `…; do` / `…; then`, щоб після згортання це гарантовано лишалось валідним bash;
168
+ - додавай відступи всередині `do/then/else` блоків, навіть якщо це один рядок після згортання — так workflow лишається читабельним у diff.
169
+
170
+ ### Приклад (ПРАВИЛЬНО — читабельно, `run: >-`, з `while`/`if`)
171
+
172
+ ```yaml
173
+ - name: Apply changes
174
+ shell: bash
175
+ run: >-
176
+ echo "$FILES" |
177
+ while read -r FILE; do
178
+ [ -z "$FILE" ] && continue;
179
+ dirname "$FILE";
180
+ done |
181
+ sort -u |
182
+ while read -r DIR; do
183
+ (
184
+ if [ -f "$DIR/kustomization.yaml" ]; then
185
+ printf 'Applying %s\n' "$DIR" &&
186
+ cd "$DIR" &&
187
+ kubectl apply -k .;
188
+ else
189
+ echo "Skip $DIR - no kustomization.yaml";
190
+ fi
191
+ );
192
+ done
193
+ ```
194
+
164
195
  ### Приклад run (НЕПРАВИЛЬНО — `\\` на кінцях)
165
196
 
166
197
  ```yaml
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nitra/cursor",
3
- "version": "1.8.128",
3
+ "version": "1.8.130",
4
4
  "description": "CLI для завантаження cursor-правил (префікс n-) у локальний репозиторій",
5
5
  "keywords": [
6
6
  "cli",
@@ -86,10 +86,11 @@ const PATCH_PREEM_FALSE_RE = /\bpreem:\s*['"]?false['"]?\b/u
86
86
  const PATCH_YANDEX_PREEMPTIBLE_FALSE_RE = /yandex\.cloud\/preemptible:\s*['"]?false['"]?/u
87
87
  const TRAILING_SLASH_RE = /\/$/u
88
88
  const PATCH_HOSTNAMES_PATH_RE = /path:\s*\/spec\/hostnames\b/mu
89
+ // Overlay namespaces: allow ua/ru and ua-*/ru-* (e.g. ua-b2b, ru-b2b).
89
90
  const PATCH_PARENT_REF_NS_UA_RE =
90
- /path:\s*\/spec\/parentRefs\/0\/namespace\b[\s\S]{0,200}?value:\s*['"]?ua['"]?(?:\s|$)/mu
91
+ /path:\s*\/spec\/parentRefs\/0\/namespace\b[\s\S]{0,200}?value:\s*['"]?ua(?:-[a-z0-9][a-z0-9-]*)?['"]?(?:\s|$)/imu
91
92
  const PATCH_PARENT_REF_NS_RU_RE =
92
- /path:\s*\/spec\/parentRefs\/0\/namespace\b[\s\S]{0,200}?value:\s*['"]?ru['"]?(?:\s|$)/mu
93
+ /path:\s*\/spec\/parentRefs\/0\/namespace\b[\s\S]{0,200}?value:\s*['"]?ru(?:-[a-z0-9][a-z0-9-]*)?['"]?(?:\s|$)/imu
93
94
  const WEBSOCKET_ANNOTATION_RE = /gwin\.yandex\.cloud\/rules\.http\.upgradeTypes:\s*['"]?websocket['"]?/mu
94
95
  const LEADING_EMPTY_LINE_RE = /^\s*\n/u
95
96
  const REMOVE_CLUSTER_IP_AFTER_OP_RE = /op:\s*remove\b[\s\S]{0,200}?path:\s*\/spec\/clusterIP\b/mu
@@ -1145,8 +1146,8 @@ export async function analyzeAbieSharedBackendRefsInPackageK8s(root, pkgAbs, yam
1145
1146
  function countAbieHttpRouteBackendRefNamespacePatchesInCombined(combined, mode) {
1146
1147
  const re =
1147
1148
  mode === 'ua'
1148
- ? /path:\s*\/spec\/rules\/\d+\/backendRefs\/\d+\/namespace\b[\s\S]{0,200}?value:\s*['"]?ua['"]?(?:\s|$)/gmu
1149
- : /path:\s*\/spec\/rules\/\d+\/backendRefs\/\d+\/namespace\b[\s\S]{0,200}?value:\s*['"]?ru['"]?(?:\s|$)/gmu
1149
+ ? /path:\s*\/spec\/rules\/\d+\/backendRefs\/\d+\/namespace\b[\s\S]{0,200}?value:\s*['"]?ua(?:-[a-z0-9][a-z0-9-]*)?['"]?(?:\s|$)/gimu
1150
+ : /path:\s*\/spec\/rules\/\d+\/backendRefs\/\d+\/namespace\b[\s\S]{0,200}?value:\s*['"]?ru(?:-[a-z0-9][a-z0-9-]*)?['"]?(?:\s|$)/gimu
1150
1151
  return [...combined.matchAll(re)].length
1151
1152
  }
1152
1153
 
@@ -7,6 +7,10 @@
7
7
  * `\.vscode/settings.json` — `editor.defaultFormatter` **oxc** для `[github-actions-workflow]`,
8
8
  * перед `uses: ./…/setup-bun-deps` у workflow — `actions/checkout` (runner інакше не бачить локальний action).
9
9
  *
10
+ * Також перевіряє, що ключові workflow (`clean-ga-workflows.yml`, `clean-merged-branch.yml`, `lint-ga.yml`, `git-ai.yml`)
11
+ * мають структуру й значення, узгоджені з `npm/mdc/ga.mdc`. Для цих файлів перевірка виконується структурно
12
+ * (після YAML parse), щоб не залежати від форматування/відступів.
13
+ *
10
14
  * Заборонено дублювати кроки встановлення Bun та кешування безпосередньо у workflow файлах
11
15
  * (oven-sh/setup-bun, actions/cache, bun install). Перевірки `uses`/`run` виконуються після **YAML parse**
12
16
  * (`yaml`), щоб не спрацьовувати на випадкові збіги в коментарях або поза кроками.
@@ -25,6 +29,9 @@ import {
25
29
  findRunStepsWithShellLineContinuationBackslash,
26
30
  hasAnyStepUsesContaining,
27
31
  hasCheckoutBeforeLocalSetupBunDeps,
32
+ flattenWorkflowSteps,
33
+ getStepRun,
34
+ getStepUses,
28
35
  parseWorkflowYaml
29
36
  } from './utils/gha-workflow.mjs'
30
37
 
@@ -44,6 +51,341 @@ const FORBIDDEN_BUN_PATTERNS = [
44
51
  { pattern: 'bun install', msg: 'використовуй .github/actions/setup-bun-deps замість bun install' }
45
52
  ]
46
53
 
54
+ /** Обовʼязкові workflow-файли (ga.mdc). */
55
+ const REQUIRED_WORKFLOWS = ['clean-ga-workflows.yml', 'clean-merged-branch.yml', 'lint-ga.yml', 'git-ai.yml']
56
+
57
+ /**
58
+ * Безпечний доступ до вкладеного поля (лише для обʼєктів).
59
+ * @param {unknown} obj значення-кандидат на обʼєкт
60
+ * @param {string} key ключ
61
+ * @returns {unknown} значення поля або undefined
62
+ */
63
+ function getObjKey(obj, key) {
64
+ if (!obj || typeof obj !== 'object' || Array.isArray(obj)) return undefined
65
+ return /** @type {Record<string, unknown>} */ (obj)[key]
66
+ }
67
+
68
+ /**
69
+ * Очікує, що значення є рядком рівно `expected`.
70
+ * @param {unknown} v значення
71
+ * @param {string} expected очікуваний рядок
72
+ * @returns {boolean} true, якщо збігається
73
+ */
74
+ function isExactString(v, expected) {
75
+ return typeof v === 'string' && v === expected
76
+ }
77
+
78
+ /**
79
+ * Перевіряє структуру workflow `clean-ga-workflows.yml` (ga.mdc).
80
+ * @param {Record<string, unknown> | null} root parsed YAML
81
+ * @param {(msg: string) => void} passFn pass
82
+ * @param {(msg: string) => void} failFn fail
83
+ */
84
+ function validateCleanGaWorkflows(root, passFn, failFn) {
85
+ if (!root) {
86
+ failFn('clean-ga-workflows.yml: YAML не вдалося розібрати (ga.mdc)')
87
+ return
88
+ }
89
+
90
+ if (!isExactString(root.name, 'Clean action for removing completed workflow runs')) {
91
+ failFn('clean-ga-workflows.yml: name має бути "Clean action for removing completed workflow runs" (ga.mdc)')
92
+ } else {
93
+ passFn('clean-ga-workflows.yml: name OK')
94
+ }
95
+
96
+ const on = root.on
97
+ const schedule = getObjKey(on, 'schedule')
98
+ const wfDispatch = getObjKey(on, 'workflow_dispatch')
99
+
100
+ const hasCron =
101
+ Array.isArray(schedule) &&
102
+ schedule.some(v => v && typeof v === 'object' && /** @type {Record<string, unknown>} */ (v).cron === '0 1 16 * *')
103
+
104
+ if (!hasCron) {
105
+ failFn("clean-ga-workflows.yml: on.schedule має містити cron: '0 1 16 * *' (ga.mdc)")
106
+ } else {
107
+ passFn('clean-ga-workflows.yml: cron OK')
108
+ }
109
+
110
+ if (!wfDispatch || typeof wfDispatch !== 'object') {
111
+ failFn('clean-ga-workflows.yml: має бути workflow_dispatch: {} (ga.mdc)')
112
+ } else {
113
+ passFn('clean-ga-workflows.yml: workflow_dispatch OK')
114
+ }
115
+
116
+ const jobs = getObjKey(root, 'jobs')
117
+ const job = getObjKey(jobs, 'cleanup_old_workflows')
118
+ if (!job) {
119
+ failFn('clean-ga-workflows.yml: jobs.cleanup_old_workflows відсутній (ga.mdc)')
120
+ return
121
+ }
122
+
123
+ if (!isExactString(getObjKey(job, 'runs-on'), 'ubuntu-latest')) {
124
+ failFn('clean-ga-workflows.yml: runs-on має бути ubuntu-latest (ga.mdc)')
125
+ }
126
+
127
+ const perm = getObjKey(job, 'permissions')
128
+ if (!(getObjKey(perm, 'actions') === 'write' && getObjKey(perm, 'contents') === 'read')) {
129
+ failFn('clean-ga-workflows.yml: permissions мають бути actions: write, contents: read (ga.mdc)')
130
+ }
131
+
132
+ const steps = getObjKey(job, 'steps')
133
+ const step0 = Array.isArray(steps) ? steps[0] : null
134
+ if (!step0 || typeof step0 !== 'object') {
135
+ failFn('clean-ga-workflows.yml: steps має містити крок з dmvict/clean-workflow-runs@v1 (ga.mdc)')
136
+ return
137
+ }
138
+
139
+ if (!isExactString(getObjKey(step0, 'name'), 'Delete workflow runs')) {
140
+ failFn('clean-ga-workflows.yml: перший крок має мати name: Delete workflow runs (ga.mdc)')
141
+ }
142
+ if (!isExactString(getObjKey(step0, 'uses'), 'dmvict/clean-workflow-runs@v1')) {
143
+ failFn('clean-ga-workflows.yml: перший крок має uses: dmvict/clean-workflow-runs@v1 (ga.mdc)')
144
+ }
145
+ const withObj = getObjKey(step0, 'with')
146
+ if (
147
+ !(getObjKey(withObj, 'token') === '${{ github.token }}' &&
148
+ getObjKey(withObj, 'save_period') === 31 &&
149
+ getObjKey(withObj, 'save_min_runs_number') === 0)
150
+ ) {
151
+ failFn('clean-ga-workflows.yml: with має містити token/save_period/save_min_runs_number як у ga.mdc')
152
+ } else {
153
+ passFn('clean-ga-workflows.yml: jobs/steps OK')
154
+ }
155
+ }
156
+
157
+ /**
158
+ * Перевіряє структуру workflow `clean-merged-branch.yml` (ga.mdc).
159
+ * @param {Record<string, unknown> | null} root parsed YAML
160
+ * @param {(msg: string) => void} passFn pass
161
+ * @param {(msg: string) => void} failFn fail
162
+ */
163
+ function validateCleanMergedBranch(root, passFn, failFn) {
164
+ if (!root) {
165
+ failFn('clean-merged-branch.yml: YAML не вдалося розібрати (ga.mdc)')
166
+ return
167
+ }
168
+
169
+ if (!isExactString(root.name, 'Clean abandoned branches')) {
170
+ failFn('clean-merged-branch.yml: name має бути "Clean abandoned branches" (ga.mdc)')
171
+ } else {
172
+ passFn('clean-merged-branch.yml: name OK')
173
+ }
174
+
175
+ const on = root.on
176
+ const schedule = getObjKey(on, 'schedule')
177
+ const wfDispatch = getObjKey(on, 'workflow_dispatch')
178
+ const hasCron =
179
+ Array.isArray(schedule) &&
180
+ schedule.some(v => v && typeof v === 'object' && /** @type {Record<string, unknown>} */ (v).cron === '0 1 15 * *')
181
+
182
+ if (!hasCron) {
183
+ failFn("clean-merged-branch.yml: on.schedule має містити cron: '0 1 15 * *' (ga.mdc)")
184
+ } else {
185
+ passFn('clean-merged-branch.yml: cron OK')
186
+ }
187
+
188
+ if (!wfDispatch || typeof wfDispatch !== 'object') {
189
+ failFn('clean-merged-branch.yml: має бути workflow_dispatch: {} (ga.mdc)')
190
+ }
191
+
192
+ const jobs = getObjKey(root, 'jobs')
193
+ const job = getObjKey(jobs, 'cleanup_old_branches')
194
+ if (!job) {
195
+ failFn('clean-merged-branch.yml: jobs.cleanup_old_branches відсутній (ga.mdc)')
196
+ return
197
+ }
198
+
199
+ const perm = getObjKey(job, 'permissions')
200
+ if (!(getObjKey(perm, 'contents') === 'write')) {
201
+ failFn('clean-merged-branch.yml: permissions мають бути contents: write (ga.mdc)')
202
+ }
203
+
204
+ const steps = getObjKey(job, 'steps')
205
+ if (!Array.isArray(steps) || steps.length < 2) {
206
+ failFn('clean-merged-branch.yml: steps має містити 2 кроки як у ga.mdc')
207
+ return
208
+ }
209
+
210
+ const step0 = steps[0]
211
+ if (!step0 || typeof step0 !== 'object') {
212
+ failFn('clean-merged-branch.yml: перший крок невалідний (ga.mdc)')
213
+ return
214
+ }
215
+
216
+ if (!isExactString(getObjKey(step0, 'id'), 'delete_stuff')) {
217
+ failFn('clean-merged-branch.yml: перший крок має id: delete_stuff (ga.mdc)')
218
+ }
219
+ if (!isExactString(getObjKey(step0, 'uses'), 'phpdocker-io/github-actions-delete-abandoned-branches@v2.0.3')) {
220
+ failFn('clean-merged-branch.yml: перший крок має uses як у ga.mdc')
221
+ }
222
+ const withObj = getObjKey(step0, 'with')
223
+ if (getObjKey(withObj, 'github_token') !== '${{ github.token }}') {
224
+ failFn('clean-merged-branch.yml: with.github_token має бути ${{ github.token }} (ga.mdc)')
225
+ }
226
+ if (getObjKey(withObj, 'last_commit_age_days') !== 90) {
227
+ failFn('clean-merged-branch.yml: with.last_commit_age_days має бути 90 (ga.mdc)')
228
+ }
229
+
230
+ const ignoreBranches = String(getObjKey(withObj, 'ignore_branches') ?? '')
231
+ if (!(ignoreBranches.includes('main') && ignoreBranches.includes('dev'))) {
232
+ failFn('clean-merged-branch.yml: with.ignore_branches має містити main,dev (ga.mdc)')
233
+ }
234
+
235
+ if (getObjKey(withObj, 'dry_run') !== 'no') {
236
+ failFn('clean-merged-branch.yml: with.dry_run має бути no (ga.mdc)')
237
+ }
238
+
239
+ const step1 = steps[1]
240
+ if (!step1 || typeof step1 !== 'object') {
241
+ failFn('clean-merged-branch.yml: другий крок невалідний (ga.mdc)')
242
+ return
243
+ }
244
+
245
+ if (!isExactString(getObjKey(step1, 'name'), 'Get output')) {
246
+ failFn('clean-merged-branch.yml: другий крок має name: Get output (ga.mdc)')
247
+ }
248
+ const env = getObjKey(step1, 'env')
249
+ if (getObjKey(env, 'DELETED_BRANCHES') !== '${{ steps.delete_stuff.outputs.deleted_branches }}') {
250
+ failFn('clean-merged-branch.yml: env.DELETED_BRANCHES має бути як у ga.mdc')
251
+ }
252
+ if (!String(getObjKey(step1, 'run') ?? '').includes('echo "Deleted branches: ${DELETED_BRANCHES}"')) {
253
+ failFn('clean-merged-branch.yml: run має echo Deleted branches як у ga.mdc')
254
+ } else {
255
+ passFn('clean-merged-branch.yml: jobs/steps OK')
256
+ }
257
+ }
258
+
259
+ /**
260
+ * Перевіряє структуру workflow `lint-ga.yml` (ga.mdc).
261
+ * @param {Record<string, unknown> | null} root parsed YAML
262
+ * @param {(msg: string) => void} passFn pass
263
+ * @param {(msg: string) => void} failFn fail
264
+ */
265
+ function validateLintGaWorkflowStructure(root, passFn, failFn) {
266
+ if (!root) {
267
+ failFn('lint-ga.yml: YAML не вдалося розібрати (ga.mdc)')
268
+ return
269
+ }
270
+
271
+ if (!isExactString(root.name, 'Lint GA')) {
272
+ failFn('lint-ga.yml: name має бути "Lint GA" (ga.mdc)')
273
+ }
274
+
275
+ const on = root.on
276
+ const push = getObjKey(on, 'push')
277
+ const pr = getObjKey(on, 'pull_request')
278
+ const pushBranches = getObjKey(push, 'branches')
279
+ const pushPaths = getObjKey(push, 'paths')
280
+ const prBranches = getObjKey(pr, 'branches')
281
+
282
+ if (!Array.isArray(pushBranches) || !(pushBranches.includes('dev') && pushBranches.includes('main'))) {
283
+ failFn('lint-ga.yml: on.push.branches має містити dev і main (ga.mdc)')
284
+ }
285
+ if (!Array.isArray(prBranches) || !(prBranches.includes('dev') && prBranches.includes('main'))) {
286
+ failFn('lint-ga.yml: on.pull_request.branches має містити dev і main (ga.mdc)')
287
+ }
288
+ if (!Array.isArray(pushPaths) || !(pushPaths.includes('.github/actions/**') && pushPaths.includes('.github/workflows/**'))) {
289
+ failFn('lint-ga.yml: on.push.paths має містити .github/actions/** і .github/workflows/** (ga.mdc)')
290
+ }
291
+
292
+ const conc = getObjKey(root, 'concurrency')
293
+ if (!(getObjKey(conc, 'cancel-in-progress') === true)) {
294
+ failFn('lint-ga.yml: concurrency.cancel-in-progress має бути true (ga.mdc)')
295
+ }
296
+
297
+ const jobs = getObjKey(root, 'jobs')
298
+ const job = getObjKey(jobs, 'lint-ga')
299
+ if (!job) {
300
+ failFn('lint-ga.yml: jobs.lint-ga відсутній (ga.mdc)')
301
+ return
302
+ }
303
+
304
+ if (!isExactString(getObjKey(job, 'runs-on'), 'ubuntu-latest')) {
305
+ failFn('lint-ga.yml: runs-on має бути ubuntu-latest (ga.mdc)')
306
+ }
307
+ const perm = getObjKey(job, 'permissions')
308
+ if (!(getObjKey(perm, 'contents') === 'read')) {
309
+ failFn('lint-ga.yml: permissions мають бути contents: read (ga.mdc)')
310
+ }
311
+
312
+ const steps = getObjKey(job, 'steps')
313
+ if (!Array.isArray(steps) || steps.length === 0) {
314
+ failFn('lint-ga.yml: jobs.lint-ga.steps відсутні (ga.mdc)')
315
+ return
316
+ }
317
+
318
+ const flat = flattenWorkflowSteps(root)
319
+ const usesList = flat.map(s => getStepUses(s.step))
320
+ const runBlob = flat.map(s => getStepRun(s.step)).join('\n')
321
+
322
+ if (!usesList.includes('actions/checkout@v6')) {
323
+ failFn('lint-ga.yml: має бути uses: actions/checkout@v6 (ga.mdc)')
324
+ }
325
+ if (!usesList.includes('./.github/actions/setup-bun-deps')) {
326
+ failFn('lint-ga.yml: має бути uses: ./.github/actions/setup-bun-deps (ga.mdc)')
327
+ }
328
+ if (!usesList.includes('astral-sh/setup-uv@v8.0.0')) {
329
+ failFn('lint-ga.yml: має бути uses: astral-sh/setup-uv@v8.0.0 (ga.mdc)')
330
+ }
331
+ if (!runBlob.includes('bun run lint-ga')) {
332
+ failFn('lint-ga.yml: має бути крок run: bun run lint-ga (ga.mdc)')
333
+ } else {
334
+ passFn('lint-ga.yml: структура jobs/steps OK')
335
+ }
336
+ }
337
+
338
+ /**
339
+ * Перевіряє структуру workflow `git-ai.yml` (ga.mdc).
340
+ * @param {Record<string, unknown> | null} root parsed YAML
341
+ * @param {(msg: string) => void} passFn pass
342
+ * @param {(msg: string) => void} failFn fail
343
+ */
344
+ function validateGitAiWorkflowStructure(root, passFn, failFn) {
345
+ if (!root) {
346
+ failFn('git-ai.yml: YAML не вдалося розібрати (ga.mdc)')
347
+ return
348
+ }
349
+
350
+ if (!isExactString(root.name, 'Git AI')) {
351
+ failFn('git-ai.yml: name має бути "Git AI" (ga.mdc)')
352
+ }
353
+
354
+ const on = root.on
355
+ const pr = getObjKey(on, 'pull_request')
356
+ const types = getObjKey(pr, 'types')
357
+ if (!Array.isArray(types) || !types.includes('closed')) {
358
+ failFn('git-ai.yml: on.pull_request.types має містити closed (ga.mdc)')
359
+ }
360
+
361
+ const jobs = getObjKey(root, 'jobs')
362
+ const job = getObjKey(jobs, 'git-ai')
363
+ if (!job) {
364
+ failFn('git-ai.yml: jobs.git-ai відсутній (ga.mdc)')
365
+ return
366
+ }
367
+
368
+ if (!String(getObjKey(job, 'if') ?? '').includes('github.event.pull_request.merged == true')) {
369
+ failFn('git-ai.yml: job має містити if: github.event.pull_request.merged == true (ga.mdc)')
370
+ }
371
+
372
+ const perm = getObjKey(job, 'permissions')
373
+ if (!(getObjKey(perm, 'contents') === 'write')) {
374
+ failFn('git-ai.yml: permissions мають бути contents: write (ga.mdc)')
375
+ }
376
+
377
+ const flat = flattenWorkflowSteps(root)
378
+ const runBlob = flat.map(s => getStepRun(s.step)).join('\n')
379
+ if (!runBlob.includes('curl -fsSL https://usegitai.com/install.sh | bash')) {
380
+ failFn('git-ai.yml: має встановлювати git-ai через curl | bash (ga.mdc)')
381
+ }
382
+ if (!runBlob.includes('git-ai ci github run')) {
383
+ failFn('git-ai.yml: має виконувати git-ai ci github run (ga.mdc)')
384
+ } else {
385
+ passFn('git-ai.yml: структура jobs/steps OK')
386
+ }
387
+ }
388
+
47
389
  /**
48
390
  * Якщо workflow викликає локальний setup-bun-deps, раніше у файлі має бути `actions/checkout@v…` (ga.mdc).
49
391
  * Fallback: сирий текст, якщо YAML не вдається розібрати.
@@ -323,7 +665,14 @@ function checkGaWorkflowFiles(wfDir, files, pass, fail) {
323
665
  pass('Всі workflows мають розширення .yml')
324
666
  }
325
667
 
326
- for (const f of ['clean-ga-workflows.yml', 'clean-merged-branch.yml', 'lint-ga.yml', 'git-ai.yml']) {
668
+ const notYmlFiles = files.filter(f => !f.endsWith('.yml'))
669
+ if (notYmlFiles.length > 0) {
670
+ for (const f of notYmlFiles) {
671
+ fail(`Workflow має бути з розширенням .yml: ${wfDir}/${f} (ga.mdc)`)
672
+ }
673
+ }
674
+
675
+ for (const f of REQUIRED_WORKFLOWS) {
327
676
  if (files.includes(f)) {
328
677
  pass(`${f} існує`)
329
678
  } else {
@@ -405,6 +754,38 @@ async function checkGitAiWorkflow(wfDir, passFn, failFn) {
405
754
  }
406
755
  }
407
756
 
757
+ /**
758
+ * Перевіряє, що “канонічні” workflows відповідають ga.mdc (структура і значення).
759
+ * @param {string} wfDir директорія workflows
760
+ * @param {(msg: string) => void} passFn pass
761
+ * @param {(msg: string) => void} failFn fail
762
+ */
763
+ async function checkCanonicalWorkflowsMatchRule(wfDir, passFn, failFn) {
764
+ const paths = {
765
+ cleanGa: join(wfDir, 'clean-ga-workflows.yml'),
766
+ cleanMerged: join(wfDir, 'clean-merged-branch.yml'),
767
+ lintGa: join(wfDir, 'lint-ga.yml'),
768
+ gitAi: join(wfDir, 'git-ai.yml')
769
+ }
770
+
771
+ if (existsSync(paths.cleanGa)) {
772
+ const c = await readFile(paths.cleanGa, 'utf8')
773
+ validateCleanGaWorkflows(parseWorkflowYaml(c), passFn, failFn)
774
+ }
775
+ if (existsSync(paths.cleanMerged)) {
776
+ const c = await readFile(paths.cleanMerged, 'utf8')
777
+ validateCleanMergedBranch(parseWorkflowYaml(c), passFn, failFn)
778
+ }
779
+ if (existsSync(paths.lintGa)) {
780
+ const c = await readFile(paths.lintGa, 'utf8')
781
+ validateLintGaWorkflowStructure(parseWorkflowYaml(c), passFn, failFn)
782
+ }
783
+ if (existsSync(paths.gitAi)) {
784
+ const c = await readFile(paths.gitAi, 'utf8')
785
+ validateGitAiWorkflowStructure(parseWorkflowYaml(c), passFn, failFn)
786
+ }
787
+ }
788
+
408
789
  /**
409
790
  * Перевіряє відповідність проєкту правилам ga.mdc
410
791
  * @returns {Promise<number>} 0 — все OK, 1 — є проблеми
@@ -458,6 +839,8 @@ export async function check() {
458
839
  verifyNoRunShellLineContinuationBackslash(`${wfDir}/${f}`, content, fail, pass)
459
840
  }
460
841
 
842
+ await checkCanonicalWorkflowsMatchRule(wfDir, pass, fail)
843
+
461
844
  await checkZizmor(pass, fail)
462
845
  await checkLintGaScript(pass, fail)
463
846
  await checkLintGaWorkflow(wfDir, pass, fail)