@nitra/cursor 1.8.206 → 1.8.208

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.
Files changed (57) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/mdc/js-run.mdc +49 -2
  3. package/package.json +1 -1
  4. package/policy/abie/health_check_policy/health_check_policy.rego +73 -0
  5. package/policy/abie/http_route_base/http_route_base.rego +45 -0
  6. package/policy/adr/settings_json/settings_json.rego +31 -0
  7. package/policy/adr/settings_local_json/settings_local_json.rego +28 -0
  8. package/policy/bun/bunfig/bunfig.rego +33 -0
  9. package/policy/bun/package_json/package_json.rego +94 -0
  10. package/policy/capacitor/package_json/package_json.rego +45 -0
  11. package/policy/ga/clean_ga_workflows/clean_ga_workflows.rego +0 -26
  12. package/policy/ga/clean_merged_branch/clean_merged_branch.rego +0 -25
  13. package/policy/ga/git_ai/git_ai.rego +0 -26
  14. package/policy/ga/lint_ga/lint_ga.rego +0 -26
  15. package/policy/ga/workflow_common/workflow_common.rego +161 -0
  16. package/policy/graphql/package_json/package_json.rego +35 -0
  17. package/policy/hasura/svc_hl/svc_hl.rego +27 -0
  18. package/policy/image_compress/package_json/package_json.rego +94 -0
  19. package/policy/js_bun_db/package_json/package_json.rego +28 -0
  20. package/policy/js_lint/lint_js_yml/lint_js_yml.rego +98 -0
  21. package/policy/js_lint/package_json/package_json.rego +137 -0
  22. package/policy/js_mssql/package_json/package_json.rego +57 -0
  23. package/policy/js_run/configmap/configmap.rego +45 -0
  24. package/policy/js_run/jsconfig/jsconfig.rego +66 -0
  25. package/policy/js_run/package_json/package_json.rego +31 -0
  26. package/policy/k8s/manifest/manifest.rego +130 -0
  27. package/policy/npm_module/emit_types_config/emit_types_config.rego +37 -0
  28. package/policy/npm_module/npm_package_json/npm_package_json.rego +55 -0
  29. package/policy/npm_module/npm_publish_yml/npm_publish_yml.rego +79 -0
  30. package/policy/npm_module/root_package_json/root_package_json.rego +28 -0
  31. package/policy/php/lint_php_yml/lint_php_yml.rego +32 -0
  32. package/policy/php/package_json/package_json.rego +19 -0
  33. package/policy/style_lint/lint_style_yml/lint_style_yml.rego +35 -0
  34. package/policy/style_lint/package_json/package_json.rego +49 -0
  35. package/policy/text/cspell/cspell.rego +91 -0
  36. package/policy/text/markdownlint/markdownlint.rego +21 -0
  37. package/policy/text/oxfmtrc/oxfmtrc.rego +90 -0
  38. package/policy/text/package_json/package_json.rego +88 -0
  39. package/policy/vue/package_json/package_json.rego +54 -0
  40. package/scripts/check-adr.mjs +3 -2
  41. package/scripts/check-bun.mjs +21 -117
  42. package/scripts/check-graphql.mjs +6 -45
  43. package/scripts/check-hasura.mjs +2 -3
  44. package/scripts/check-image-avif.mjs +3 -3
  45. package/scripts/check-image-compress.mjs +25 -132
  46. package/scripts/check-js-bun-db.mjs +3 -50
  47. package/scripts/check-js-run.mjs +84 -86
  48. package/scripts/check-k8s.mjs +4 -4
  49. package/scripts/check-npm-module.mjs +17 -8
  50. package/scripts/check-php.mjs +16 -51
  51. package/scripts/check-style-lint.mjs +28 -52
  52. package/scripts/check-text.mjs +47 -219
  53. package/scripts/check-vue.mjs +3 -16
  54. package/scripts/lint-conftest.mjs +351 -0
  55. package/scripts/lint-ga.mjs +39 -2
  56. package/scripts/run-shellcheck-text.mjs +2 -2
  57. package/scripts/utils/conn-file-rules.mjs +170 -0
@@ -32,7 +32,6 @@ import { loadCursorIgnorePaths } from './utils/load-cursor-config.mjs'
32
32
  import { walkDir } from './utils/walkDir.mjs'
33
33
  import { getMonorepoPackageRootDirs } from './utils/workspaces.mjs'
34
34
 
35
- const MAJOR_VERSION_RE = /(\d+)/
36
35
  const ESBUILD_RE = /\besbuild\b/
37
36
 
38
37
  /** Регулярний вираз для triple-slash `reference types="vite/client"` у `src/vite-env.d.ts`. */
@@ -245,20 +244,6 @@ async function checkViteClientEnvAndEditorConfig(rootDir, prefix, passFn, fail)
245
244
  passFn(`${prefix}jsconfig.json присутній`)
246
245
  }
247
246
 
248
- function checkViteVersion(devDeps, prefix, passFn, fail) {
249
- const v = devDeps.vite
250
- if (!v) {
251
- fail(`${prefix}vite відсутній в devDependencies`)
252
- return
253
- }
254
- const match = v.match(MAJOR_VERSION_RE)
255
- if (match && Number(match[1]) >= 8) {
256
- passFn(`${prefix}vite >= 8: ${v}`)
257
- } else {
258
- fail(`${prefix}vite має бути >= 8, знайдено: ${v}`)
259
- }
260
- }
261
-
262
247
  /**
263
248
  * Витягує текст аргументів першого виклику `AutoImport(` з vite.config зі збалансованими дужками.
264
249
  * Повертає `null`, якщо виклик не знайдено або дужки не збалансовані (тоді перевірка `'vue'`
@@ -458,7 +443,9 @@ async function checkVuePackage(rootDir, ignorePaths, fail, passFn) {
458
443
  }
459
444
 
460
445
  checkRequiredDep(deps, 'vue', prefix, passFn, fail, 'vue відсутній в dependencies')
461
- checkViteVersion(devDeps, prefix, passFn, fail)
446
+ // `vite >= 8` перенесено в Rego (`npm/policy/vue/package_json/`); запуск
447
+ // через `bun run lint-conftest`. Залишені вимоги — present-/missing-deps
448
+ // (vue-macros, unplugin-auto-import, тощо) — у самому JS-чекері.
462
449
  checkRequiredDep(
463
450
  devDeps,
464
451
  '@vitejs/plugin-vue',
@@ -0,0 +1,351 @@
1
+ /**
2
+ * Прогоняє `conftest test` по всіх Rego-полісі з `npm/policy/` (окрім `ga/*`,
3
+ * які вже виконуються через `lint-ga.mjs`).
4
+ *
5
+ * Кожна полісі має свій namespace, опційний `rule` (id у `.n-cursor.json:rules`,
6
+ * інакше таргет пропускається — як гейтинг у `check-*.mjs`), і список цільових
7
+ * файлів — single-file або walk-предикат для дерева. Якщо цільових файлів немає
8
+ * або правило не активне — таргет мовчки пропускається.
9
+ *
10
+ * Поведінка fallback:
11
+ * - якщо `conftest` не в `PATH` — друкуємо `ℹ` повідомлення з підказкою
12
+ * встановлення і повертаємо 0 (структурні JS-перевірки в `check-*.mjs`
13
+ * лишаються паралельно). Те саме рішення — у `lint-ga.mjs`.
14
+ * - якщо `npm/policy/` не існує (нетипова інсталяція) — також `ℹ` skip.
15
+ *
16
+ * Перший ненульовий exit-код conftest — повертаємо як результат, але всі
17
+ * наступні таргети все одно виконуємо, щоб одразу побачити повний список
18
+ * порушень (а не виправляти по одному).
19
+ *
20
+ * Експортовано окремо `runLintConftestCli` — використовується з
21
+ * `bin/n-cursor.js` як підкоманда `lint-conftest`.
22
+ */
23
+ import { existsSync, readdirSync, readFileSync } from 'node:fs'
24
+ import { spawnSync } from 'node:child_process'
25
+ import { dirname, join, sep } from 'node:path'
26
+ import { fileURLToPath } from 'node:url'
27
+
28
+ import { resolveCmd } from './utils/resolve-cmd.mjs'
29
+
30
+ /** Каталог пакету `@nitra/cursor`, від якого ресолвимо вшиту директорію policy/. */
31
+ const PACKAGE_ROOT = dirname(dirname(fileURLToPath(import.meta.url)))
32
+
33
+ /** Шлях до кореня Rego-полісі. У npm-tarball публікується через `files: ["policy"]`. */
34
+ const POLICY_DIR = join(PACKAGE_ROOT, 'policy')
35
+
36
+ /**
37
+ * Опис одного таргета: namespace + спосіб розвʼязати цільові файли.
38
+ *
39
+ * `single` — конкретний файл відносно cwd, перевіряється `existsSync`-ом.
40
+ * `walk` — рекурсивний обхід від cwd із простим суфікс-предикатом
41
+ * (наприклад `name === 'package.json'`). Глибокі ігнори — як у `walkDir`
42
+ * в інших скриптах: `node_modules`, `.git`, `dist`, `coverage`, `build`,
43
+ * `.turbo`, `.next`. Не використовуємо bun Glob, щоб не плодити залежності
44
+ * за межами `node:fs`.
45
+ *
46
+ * @typedef {{
47
+ * namespace: string,
48
+ * policyDir: string,
49
+ * rule?: string,
50
+ * single?: string,
51
+ * walk?: { match: (relPosix: string) => boolean }
52
+ * }} ConftestTarget
53
+ */
54
+
55
+ /**
56
+ * Зчитує `rules` з `.n-cursor.json` у cwd. Повертає множину рядків — або `null`,
57
+ * якщо файлу немає чи поле некоректне (тоді гейтинг вимикаємо — як у `check-bun.mjs`).
58
+ * @param {string} cwd корінь репо
59
+ * @returns {Set<string> | null} множина активних правил або null
60
+ */
61
+ function loadActiveCursorRules(cwd) {
62
+ const path = join(cwd, '.n-cursor.json')
63
+ if (!existsSync(path)) return null
64
+ try {
65
+ const raw = JSON.parse(readFileSync(path, 'utf8'))
66
+ if (!Array.isArray(raw?.rules)) return null
67
+ return new Set(raw.rules.map(String))
68
+ } catch {
69
+ return null
70
+ }
71
+ }
72
+
73
+ const SKIP_DIR_NAMES = new Set(['node_modules', '.git', 'dist', 'coverage', 'build', '.turbo', '.next'])
74
+
75
+ /** @type {ConftestTarget[]} */
76
+ const TARGETS = [
77
+ // ── bun ─────────────────────────────────────────────────────────────────
78
+ { namespace: 'bun.bunfig', policyDir: 'bun', rule: 'bun', single: 'bunfig.toml' },
79
+ { namespace: 'bun.package_json', policyDir: 'bun', rule: 'bun', single: 'package.json' },
80
+
81
+ // ── text ────────────────────────────────────────────────────────────────
82
+ { namespace: 'text.oxfmtrc', policyDir: 'text', rule: 'text', single: '.oxfmtrc.json' },
83
+ { namespace: 'text.cspell', policyDir: 'text', rule: 'text', single: '.cspell.json' },
84
+ { namespace: 'text.markdownlint', policyDir: 'text', rule: 'text', single: '.markdownlint-cli2.jsonc' },
85
+ { namespace: 'text.package_json', policyDir: 'text', rule: 'text', single: 'package.json' },
86
+
87
+ // ── style-lint ──────────────────────────────────────────────────────────
88
+ { namespace: 'style_lint.package_json', policyDir: 'style_lint', rule: 'style-lint', single: 'package.json' },
89
+ {
90
+ namespace: 'style_lint.lint_style_yml',
91
+ policyDir: 'style_lint',
92
+ rule: 'style-lint',
93
+ single: '.github/workflows/lint-style.yml'
94
+ },
95
+
96
+ // ── php ─────────────────────────────────────────────────────────────────
97
+ { namespace: 'php.package_json', policyDir: 'php', rule: 'php', single: 'package.json' },
98
+ {
99
+ namespace: 'php.lint_php_yml',
100
+ policyDir: 'php',
101
+ rule: 'php',
102
+ single: '.github/workflows/lint-php.yml'
103
+ },
104
+
105
+ // ── npm-module ──────────────────────────────────────────────────────────
106
+ {
107
+ namespace: 'npm_module.root_package_json',
108
+ policyDir: 'npm_module',
109
+ rule: 'npm-module',
110
+ single: 'package.json'
111
+ },
112
+ {
113
+ namespace: 'npm_module.npm_package_json',
114
+ policyDir: 'npm_module',
115
+ rule: 'npm-module',
116
+ single: 'npm/package.json'
117
+ },
118
+ {
119
+ namespace: 'npm_module.emit_types_config',
120
+ policyDir: 'npm_module',
121
+ rule: 'npm-module',
122
+ single: 'npm/tsconfig.emit-types.json'
123
+ },
124
+ {
125
+ namespace: 'npm_module.npm_publish_yml',
126
+ policyDir: 'npm_module',
127
+ rule: 'npm-module',
128
+ single: '.github/workflows/npm-publish.yml'
129
+ },
130
+
131
+ // ── js-lint ─────────────────────────────────────────────────────────────
132
+ { namespace: 'js_lint.package_json', policyDir: 'js_lint', rule: 'js-lint', single: 'package.json' },
133
+ {
134
+ namespace: 'js_lint.lint_js_yml',
135
+ policyDir: 'js_lint',
136
+ rule: 'js-lint',
137
+ single: '.github/workflows/lint-js.yml'
138
+ },
139
+
140
+ // ── graphql / image-compress / capacitor ────────────────────────────────
141
+ { namespace: 'graphql.package_json', policyDir: 'graphql', rule: 'graphql', single: 'package.json' },
142
+ {
143
+ namespace: 'image_compress.package_json',
144
+ policyDir: 'image_compress',
145
+ rule: 'image-compress',
146
+ single: 'package.json'
147
+ },
148
+ {
149
+ namespace: 'capacitor.package_json',
150
+ policyDir: 'capacitor',
151
+ rule: 'capacitor',
152
+ single: 'package.json'
153
+ },
154
+
155
+ // ── hasura ──────────────────────────────────────────────────────────────
156
+ {
157
+ namespace: 'hasura.svc_hl',
158
+ policyDir: 'hasura',
159
+ rule: 'hasura',
160
+ single: 'hasura/k8s/base/svc-hl.yaml'
161
+ },
162
+
163
+ // ── adr ─────────────────────────────────────────────────────────────────
164
+ { namespace: 'adr.settings_json', policyDir: 'adr', rule: 'adr', single: '.claude/settings.json' },
165
+ {
166
+ namespace: 'adr.settings_local_json',
167
+ policyDir: 'adr',
168
+ rule: 'adr',
169
+ single: '.claude/settings.local.json'
170
+ },
171
+
172
+ // ── multi-file (walk) ───────────────────────────────────────────────────
173
+ // Усі `package.json` у дереві (включно з workspace-пакетами).
174
+ {
175
+ namespace: 'js_mssql.package_json',
176
+ policyDir: 'js_mssql',
177
+ rule: 'js-mssql',
178
+ walk: { match: rel => rel.endsWith('/package.json') || rel === 'package.json' }
179
+ },
180
+ {
181
+ namespace: 'js_bun_db.package_json',
182
+ policyDir: 'js_bun_db',
183
+ rule: 'js-bun-db',
184
+ walk: { match: rel => rel.endsWith('/package.json') || rel === 'package.json' }
185
+ },
186
+ {
187
+ namespace: 'js_run.package_json',
188
+ policyDir: 'js_run',
189
+ rule: 'js-run',
190
+ walk: { match: rel => rel.endsWith('/package.json') || rel === 'package.json' }
191
+ },
192
+ {
193
+ namespace: 'vue.package_json',
194
+ policyDir: 'vue',
195
+ rule: 'vue',
196
+ walk: { match: rel => rel.endsWith('/package.json') || rel === 'package.json' }
197
+ },
198
+
199
+ // ConfigMap у `…/k8s/base/configmap.yaml` будь-де у дереві.
200
+ {
201
+ namespace: 'js_run.configmap',
202
+ policyDir: 'js_run',
203
+ rule: 'js-run',
204
+ walk: { match: rel => /(^|\/)k8s\/[^/]+\/configmap\.yaml$/.test(rel) }
205
+ },
206
+
207
+ // Усі YAML у дереві з сегментом `k8s` — пер-документні структурні правила.
208
+ {
209
+ namespace: 'k8s.manifest',
210
+ policyDir: 'k8s',
211
+ rule: 'k8s',
212
+ walk: { match: rel => /(^|\/)k8s\//.test(rel) && (rel.endsWith('.yaml') || rel.endsWith('.yml')) }
213
+ },
214
+
215
+ // abie HealthCheckPolicy: `hc.yaml` у дереві k8s.
216
+ {
217
+ namespace: 'abie.health_check_policy',
218
+ policyDir: 'abie',
219
+ rule: 'abie',
220
+ walk: { match: rel => /(^|\/)k8s\/.+\/hc\.yaml$/.test(rel) }
221
+ },
222
+
223
+ // abie HTTPRoute у `base/`.
224
+ {
225
+ namespace: 'abie.http_route_base',
226
+ policyDir: 'abie',
227
+ rule: 'abie',
228
+ walk: { match: rel => /(^|\/)k8s\/.*base\/.*hr\.yaml$/.test(rel) }
229
+ }
230
+ ]
231
+
232
+ /**
233
+ * Рекурсивно збирає відносні (posix) шляхи від cwd, які матчаться предикатом.
234
+ * Глибокі ігнори — `SKIP_DIR_NAMES`. Не йде у симлінки, помилки stat — мовчки skip.
235
+ * @param {string} root абсолютний корінь обходу
236
+ * @param {(relPosix: string) => boolean} match предикат на відносний posix-шлях
237
+ * @returns {string[]} список відносних posix-шляхів
238
+ */
239
+ function collectFiles(root, match) {
240
+ /** @type {string[]} */
241
+ const out = []
242
+ /** @param {string} dirAbs */
243
+ function visit(dirAbs) {
244
+ /** @type {import('node:fs').Dirent[]} */
245
+ let entries
246
+ try {
247
+ entries = readdirSync(dirAbs, { withFileTypes: true })
248
+ } catch {
249
+ return
250
+ }
251
+ for (const e of entries) {
252
+ if (e.isSymbolicLink()) continue
253
+ const abs = join(dirAbs, e.name)
254
+ if (e.isDirectory()) {
255
+ if (SKIP_DIR_NAMES.has(e.name)) continue
256
+ visit(abs)
257
+ continue
258
+ }
259
+ if (!e.isFile()) continue
260
+ const rel = abs.slice(root.length + 1).split(sep).join('/')
261
+ if (match(rel)) out.push(rel)
262
+ }
263
+ }
264
+ visit(root)
265
+ return out
266
+ }
267
+
268
+ /**
269
+ * Розвʼязує файлові цілі для одного таргета щодо cwd.
270
+ * @param {ConftestTarget} target опис таргета
271
+ * @param {string} cwd корінь репозиторію
272
+ * @returns {string[]} список абсолютних / відносних шляхів
273
+ */
274
+ function resolveTargetFiles(target, cwd) {
275
+ if (target.single) {
276
+ return existsSync(join(cwd, target.single)) ? [target.single] : []
277
+ }
278
+ if (target.walk) {
279
+ return collectFiles(cwd, target.walk.match)
280
+ }
281
+ return []
282
+ }
283
+
284
+ /**
285
+ * Запускає conftest на одному таргеті. Повертає exit-код (0 — OK, 1+ — помилки).
286
+ *
287
+ * При відсутніх цільових файлах — мовчки повертає 0 (правило неактуальне для repo).
288
+ * Логує заголовок з namespace і кількістю файлів, як `lint-ga.mjs`.
289
+ * @param {string} conftestBin абсолютний шлях до бінарника conftest
290
+ * @param {ConftestTarget} target опис таргета
291
+ * @param {string[]} files список файлів для перевірки (відносні до cwd)
292
+ * @returns {number} exit-код
293
+ */
294
+ function runConftestForTarget(conftestBin, target, files) {
295
+ const policyAbs = join(POLICY_DIR, target.policyDir)
296
+ if (!existsSync(policyAbs)) {
297
+ return 0
298
+ }
299
+ console.log(`\n▶ conftest (${target.namespace} — ${files.length} файл(ів))`)
300
+ const r = spawnSync(
301
+ conftestBin,
302
+ ['test', ...files, '-p', policyAbs, '--namespace', target.namespace, '--no-color'],
303
+ { stdio: 'inherit', env: process.env }
304
+ )
305
+ if (r.error) {
306
+ console.error(`❌ Не вдалося запустити conftest: ${r.error.message}`)
307
+ return 1
308
+ }
309
+ return r.status ?? 1
310
+ }
311
+
312
+ /**
313
+ * Запускає `conftest test` по всіх таргетах із `TARGETS`. Перший ненульовий exit-код
314
+ * запамʼятовується, але цикл йде до кінця, щоб користувач побачив усі порушення.
315
+ *
316
+ * Якщо `conftest` не знайдено в PATH — друкує `ℹ` повідомлення і повертає 0
317
+ * (структурні перевірки в `check-*.mjs` лишаються паралельно).
318
+ * @returns {number} 0 — все OK або skip; інакше — перший ненульовий exit-код
319
+ */
320
+ export function runLintConftestCli() {
321
+ const conftestBin = resolveCmd('conftest')
322
+ if (!conftestBin) {
323
+ console.log(
324
+ 'ℹ conftest не знайдено в PATH — пропускаю Rego-перевірки.\n' +
325
+ ' Встанови, щоб запустити локально: brew install conftest (macOS) або https://www.conftest.dev/install/'
326
+ )
327
+ return 0
328
+ }
329
+ if (!existsSync(POLICY_DIR)) {
330
+ console.log(`ℹ Каталог Rego-полісі не знайдено (${POLICY_DIR}) — пропускаю conftest.`)
331
+ return 0
332
+ }
333
+
334
+ const cwd = process.cwd()
335
+ const activeRules = loadActiveCursorRules(cwd)
336
+ let firstFailureCode = 0
337
+ for (const target of TARGETS) {
338
+ if (target.rule && activeRules && !activeRules.has(target.rule)) continue
339
+ const files = resolveTargetFiles(target, cwd)
340
+ if (files.length === 0) continue
341
+ const code = runConftestForTarget(conftestBin, target, files)
342
+ if (code !== 0 && firstFailureCode === 0) {
343
+ firstFailureCode = code
344
+ }
345
+ }
346
+ return firstFailureCode
347
+ }
348
+
349
+ if (import.meta.url === `file://${process.argv[1]}`) {
350
+ process.exit(runLintConftestCli())
351
+ }
@@ -7,6 +7,13 @@
7
7
  * повідомлення й продовжуємо з кодом 0. Структурні перевірки тих самих workflow паралельно живуть у
8
8
  * `npm/scripts/check-ga.mjs`, тож відсутність conftest не пропускає порушення мовчки.
9
9
  *
10
+ * Conftest проганяється у двох режимах:
11
+ * 1) per-workflow polysi (`ga.<name>`) — для канонічних `clean-ga-workflows`, `clean-merged-branch`,
12
+ * `lint-ga`, `git-ai`, що мають фіксовані поля (cron, ім'я кроку тощо);
13
+ * 2) `ga.workflow_common` — універсальні правила (concurrency, заборонені setup-bun/cache/install у
14
+ * кроках, shell line-continuation у `run:`, checkout перед локальним setup-bun-deps), які
15
+ * застосовуються до **кожного** `.github/workflows/*.yml`.
16
+ *
10
17
  * Без preflight `actionlint` (через `bunx github-actionlint`) мовчки пропускає shell-перевірки в
11
18
  * `run:` блоках, коли `shellcheck` відсутній у PATH; локально `bun lint-ga` лишається зеленим, а CI
12
19
  * на ubuntu-latest (де shellcheck передвстановлений) падає. Preflight робить цю різницю явною.
@@ -16,7 +23,7 @@
16
23
  *
17
24
  * Експортовано окремо `runLintGaCli` — використовується з `bin/n-cursor.js` як підкоманда `lint-ga`.
18
25
  */
19
- import { existsSync } from 'node:fs'
26
+ import { existsSync, readdirSync } from 'node:fs'
20
27
  import { spawnSync } from 'node:child_process'
21
28
  import { dirname, join } from 'node:path'
22
29
  import { platform } from 'node:process'
@@ -244,5 +251,35 @@ function runConftestStep() {
244
251
  ])
245
252
  if (code !== 0) return code
246
253
  }
247
- return 0
254
+
255
+ return runConftestWorkflowCommon(conftestBin)
256
+ }
257
+
258
+ /**
259
+ * Прогоняє `ga.workflow_common` на кожному `.github/workflows/*.yml` — універсальні перевірки
260
+ * (concurrency, заборонені setup-bun/cache/install, shell line-continuation, checkout перед
261
+ * локальним setup-bun-deps). Якщо директорії немає або файлів немає — мовчки skip.
262
+ *
263
+ * Викликаємо conftest на всіх файлах одним прогоном (`conftest test <files...>`) — швидше, ніж
264
+ * по одному, і summary-лог зрозуміліший. Перший ненульовий exit-код повертаємо як результат.
265
+ * @param {string} conftestBin абсолютний шлях до бінарника conftest
266
+ * @returns {number} 0 — OK, інакше exit-код conftest
267
+ */
268
+ function runConftestWorkflowCommon(conftestBin) {
269
+ const wfDir = '.github/workflows'
270
+ if (!existsSync(wfDir)) return 0
271
+ const ymlFiles = readdirSync(wfDir)
272
+ .filter(f => f.endsWith('.yml'))
273
+ .map(f => join(wfDir, f))
274
+ if (ymlFiles.length === 0) return 0
275
+
276
+ return runStep('conftest (workflow_common — усі workflow)', conftestBin, [
277
+ 'test',
278
+ ...ymlFiles,
279
+ '-p',
280
+ GA_POLICY_DIR,
281
+ '--namespace',
282
+ 'ga.workflow_common',
283
+ '--no-color'
284
+ ])
248
285
  }
@@ -80,14 +80,14 @@ export function listShellScriptPaths(cwd) {
80
80
  return []
81
81
  }
82
82
  const files = ls.stdout.split('\0').filter(Boolean)
83
- return [...new Set(files)].sort()
83
+ return new Set(files).toSorted()
84
84
  }
85
85
 
86
86
  const fromGlob = globSync('**/*.sh', {
87
87
  cwd,
88
88
  exclude: p => p.includes('node_modules') || p.startsWith(`node_modules/`) || p.split('/').includes('node_modules')
89
89
  })
90
- return [...new Set(fromGlob.map(p => p.replaceAll('\\', '/')))].sort()
90
+ return new Set(fromGlob.map(p => p.replaceAll('\\', '/'))).toSorted()
91
91
  }
92
92
 
93
93
  /**
@@ -0,0 +1,170 @@
1
+ /**
2
+ * Перевірки для файлів-підключень у каталозі `#conn` (js-run.mdc → «Нейминг файлів у `src/conn/`»
3
+ * та «Експорти у файлах `src/conn/`»).
4
+ *
5
+ * Канонічна назва файла:
6
+ * - GraphQL: `ql-<id>.{js|mjs|cjs|ts|mts|cts}` (id — kebab-case ідентифікатор endpoint);
7
+ * - PostgreSQL: `pg-{read|write}.{ext}` або `pg-{read|write}-<id>.{ext}` (id — для multi-БД);
8
+ * - MySQL/MSSQL: `mysql-{read|write}.{ext}` або `mysql-{read|write}-<id>.{ext}`.
9
+ *
10
+ * Канонічний експорт — іменований, без `export default`. Імʼя константи має дорівнювати
11
+ * camelCase від basename файла (`pg-write-contract` → `pgWriteContract`).
12
+ *
13
+ * Парсимо через oxc-parser; коли файл не парситься — повертаємо порожні результати, щоб
14
+ * не змішувати помилки синтаксису з порушеннями цього правила.
15
+ */
16
+ import { parseProgramOrNull } from './ast-scan-utils.mjs'
17
+
18
+ const SOURCE_FILE_RE = /\.([cm]?[jt]sx?)$/u
19
+
20
+ /**
21
+ * Канонічний шаблон імені файла в каталозі conn.
22
+ * - `ql-<id>` для GraphQL;
23
+ * - `(pg|mysql)-(read|write)(-<id>)?` для БД.
24
+ * `<id>` — починається з [a-z0-9], далі [a-z0-9-]*.
25
+ */
26
+ const CONN_FILENAME_RE =
27
+ /^(?:ql-[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|(?:pg|mysql)-(?:read|write)(?:-[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)?)\.([cm]?[jt]sx?)$/u
28
+
29
+ /**
30
+ * Чи це файл, який сканується правилом «conn-file» (JS/TS-сімʼя, без `.d.ts`).
31
+ * @param {string} relativePathPosix відносний posix-шлях
32
+ * @returns {boolean} true, якщо потрібно перевіряти
33
+ */
34
+ export function isConnFileRulesSourceFile(relativePathPosix) {
35
+ return SOURCE_FILE_RE.test(relativePathPosix) && !relativePathPosix.endsWith('.d.ts')
36
+ }
37
+
38
+ /**
39
+ * Витягує basename файла без розширення.
40
+ * @param {string} relativePathPosix відносний шлях у posix-форматі
41
+ * @returns {string} basename без розширення (наприклад, `pg-write-contract`)
42
+ */
43
+ function basenameNoExt(relativePathPosix) {
44
+ const last = relativePathPosix.lastIndexOf('/')
45
+ const base = last >= 0 ? relativePathPosix.slice(last + 1) : relativePathPosix
46
+ const dot = base.lastIndexOf('.')
47
+ return dot > 0 ? base.slice(0, dot) : base
48
+ }
49
+
50
+ /**
51
+ * Перетворює kebab-case ідентифікатор у camelCase.
52
+ * @param {string} kebab kebab-case рядок (`pg-write-contract`)
53
+ * @returns {string} camelCase (`pgWriteContract`)
54
+ */
55
+ export function kebabToCamel(kebab) {
56
+ return kebab.replaceAll(/-([a-z0-9])/gu, (_m, c) => c.toUpperCase())
57
+ }
58
+
59
+ /**
60
+ * Чи відповідає назва файла канонічному шаблону для каталогу conn.
61
+ * @param {string} relativePathPosix відносний posix-шлях файла
62
+ * @returns {boolean} true, якщо basename + ext збігається зі схемою
63
+ */
64
+ export function isConnFileNameValid(relativePathPosix) {
65
+ const last = relativePathPosix.lastIndexOf('/')
66
+ const base = last >= 0 ? relativePathPosix.slice(last + 1) : relativePathPosix
67
+ return CONN_FILENAME_RE.test(base)
68
+ }
69
+
70
+ /**
71
+ * Збирає всі імена named-експортів у програмі.
72
+ *
73
+ * Покриває: `export const/let/var X`, `export function X`, `export class X`,
74
+ * `export { X }`, `export { X as Y }` (повертає `Y`). `export *` ігнорується
75
+ * (немає конкретного імені для звірки), `export default` обробляється окремо.
76
+ * @param {unknown} program AST root
77
+ * @returns {string[]} список експортованих імен
78
+ */
79
+ function collectNamedExportNames(program) {
80
+ /** @type {string[]} */
81
+ const out = []
82
+ if (!program || typeof program !== 'object') return out
83
+ const body = /** @type {Record<string, unknown>} */ (program).body
84
+ if (!Array.isArray(body)) return out
85
+ for (const node of body) {
86
+ if (!node || typeof node !== 'object') continue
87
+ const rec = /** @type {Record<string, unknown>} */ (node)
88
+ if (rec.type !== 'ExportNamedDeclaration') continue
89
+ const decl = /** @type {Record<string, unknown> | null} */ (rec.declaration ?? null)
90
+ if (decl) {
91
+ // export const X = ... / export let / export var
92
+ if (decl.type === 'VariableDeclaration' && Array.isArray(decl.declarations)) {
93
+ for (const d of decl.declarations) {
94
+ const id = /** @type {Record<string, unknown> | null} */ (d?.id ?? null)
95
+ if (id && id.type === 'Identifier' && typeof id.name === 'string') out.push(id.name)
96
+ }
97
+ }
98
+ // export function X / export class X
99
+ if (
100
+ (decl.type === 'FunctionDeclaration' || decl.type === 'ClassDeclaration') &&
101
+ decl.id &&
102
+ typeof decl.id === 'object' &&
103
+ typeof /** @type {Record<string, unknown>} */ (decl.id).name === 'string'
104
+ ) {
105
+ out.push(/** @type {string} */ (/** @type {Record<string, unknown>} */ (decl.id).name))
106
+ }
107
+ } else if (Array.isArray(rec.specifiers)) {
108
+ // export { X } / export { X as Y }
109
+ for (const s of rec.specifiers) {
110
+ const exported = /** @type {Record<string, unknown> | null} */ (s?.exported ?? null)
111
+ if (!exported) continue
112
+ // ESTree: Identifier (name) або Literal (value), залежно від спеки
113
+ if (exported.type === 'Identifier' && typeof exported.name === 'string') out.push(exported.name)
114
+ else if (typeof exported.value === 'string') out.push(exported.value)
115
+ }
116
+ }
117
+ }
118
+ return out
119
+ }
120
+
121
+ /**
122
+ * Чи є в програмі `export default ...`.
123
+ * @param {unknown} program AST root
124
+ * @returns {boolean} true, якщо знайдено будь-який ExportDefaultDeclaration
125
+ */
126
+ function hasDefaultExport(program) {
127
+ if (!program || typeof program !== 'object') return false
128
+ const body = /** @type {Record<string, unknown>} */ (program).body
129
+ if (!Array.isArray(body)) return false
130
+ for (const node of body) {
131
+ if (node && typeof node === 'object' && /** @type {Record<string, unknown>} */ (node).type === 'ExportDefaultDeclaration') {
132
+ return true
133
+ }
134
+ }
135
+ return false
136
+ }
137
+
138
+ /**
139
+ * Знаходить порушення правил для одного файла з каталогу conn.
140
+ *
141
+ * Якщо AST не парситься — повертає порожній масив (синтаксис падає в інших перевірках,
142
+ * не дублюємо).
143
+ * @param {string} content вихідний код файла
144
+ * @param {string} relativePathPosix відносний posix-шлях файла (від кореня пакета)
145
+ * @returns {{ kind: 'name' | 'default-export' | 'export-name', expectedName?: string, foundNames?: string[] }[]} список порушень
146
+ */
147
+ export function findConnFileRuleViolations(content, relativePathPosix) {
148
+ /** @type {{ kind: 'name' | 'default-export' | 'export-name', expectedName?: string, foundNames?: string[] }[]} */
149
+ const out = []
150
+ if (!isConnFileNameValid(relativePathPosix)) {
151
+ out.push({ kind: 'name' })
152
+ // якщо назва нестандартна — далі звірку імені експорту не робимо (camelCase двозначний)
153
+ }
154
+
155
+ const program = parseProgramOrNull(content, relativePathPosix)
156
+ if (!program) return out
157
+
158
+ if (hasDefaultExport(program)) {
159
+ out.push({ kind: 'default-export' })
160
+ }
161
+
162
+ if (out.some(v => v.kind === 'name')) return out
163
+
164
+ const expected = kebabToCamel(basenameNoExt(relativePathPosix.slice(relativePathPosix.lastIndexOf('/') + 1)))
165
+ const names = collectNamedExportNames(program)
166
+ if (!names.includes(expected)) {
167
+ out.push({ kind: 'export-name', expectedName: expected, foundNames: names })
168
+ }
169
+ return out
170
+ }