@nitra/cursor 1.10.0 → 1.11.0

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 (74) hide show
  1. package/CHANGELOG.md +34 -1
  2. package/bin/n-cursor.js +29 -29
  3. package/package.json +2 -1
  4. package/rules/abie/js/applies/check.mjs +24 -0
  5. package/rules/abie/js/env_dns/check.mjs +53 -0
  6. package/rules/abie/js/firebase_hosting/check.mjs +49 -0
  7. package/rules/abie/js/hc_pairing/check.mjs +58 -0
  8. package/rules/abie/js/ua_http_route/check.mjs +86 -0
  9. package/rules/abie/js/ua_node_selector/check.mjs +65 -0
  10. package/rules/abie/policy/base_deployment_preem/target.json +10 -0
  11. package/rules/abie/policy/clean_merged_ignore_branches/target.json +4 -0
  12. package/rules/abie/policy/health_check_policy/target.json +4 -0
  13. package/rules/abie/policy/http_route_base/target.json +4 -0
  14. package/rules/abie/utils/enabled.mjs +35 -0
  15. package/rules/abie/utils/env-dns.mjs +81 -0
  16. package/rules/abie/utils/hc-yaml.mjs +27 -0
  17. package/rules/abie/utils/http-route.mjs +93 -0
  18. package/rules/abie/utils/k8s-tree.mjs +102 -0
  19. package/rules/abie/utils/kustomization-patches.mjs +224 -0
  20. package/rules/abie/utils/overlay-paths.mjs +97 -0
  21. package/rules/abie/utils/yaml.mjs +72 -0
  22. package/rules/adr/policy/settings_json/target.json +4 -0
  23. package/rules/adr/policy/settings_local_json/target.json +4 -0
  24. package/rules/bun/policy/bunfig/target.json +4 -0
  25. package/rules/bun/policy/package_json/target.json +4 -0
  26. package/rules/capacitor/policy/package_json/target.json +4 -0
  27. package/rules/docker/policy/lint_docker_yml/target.json +4 -0
  28. package/rules/docker/policy/package_json/target.json +4 -0
  29. package/rules/hasura/policy/svc_hl/target.json +4 -0
  30. package/rules/image-avif/policy/package_json/target.json +4 -0
  31. package/rules/image-compress/policy/package_json/target.json +4 -0
  32. package/rules/js-bun-db/policy/package_json/target.json +4 -0
  33. package/rules/js-bun-redis/policy/package_json/target.json +4 -0
  34. package/rules/js-lint/policy/lint_js_yml/target.json +4 -0
  35. package/rules/js-lint/policy/package_json/target.json +4 -0
  36. package/rules/js-mssql/policy/package_json/target.json +4 -0
  37. package/rules/js-run/policy/configmap/target.json +4 -0
  38. package/rules/js-run/policy/package_json/target.json +4 -0
  39. package/rules/k8s/policy/base_kustomization/target.json +4 -0
  40. package/rules/k8s/policy/base_manifest/target.json +10 -0
  41. package/rules/k8s/policy/gateway/target.json +4 -0
  42. package/rules/k8s/policy/hpa_pdb/target.json +4 -0
  43. package/rules/k8s/policy/kustomization/target.json +4 -0
  44. package/rules/k8s/policy/manifest/target.json +4 -0
  45. package/rules/k8s/policy/svc_hl_yaml/target.json +4 -0
  46. package/rules/k8s/policy/svc_yaml/target.json +4 -0
  47. package/rules/npm-module/policy/emit_types_config/target.json +4 -0
  48. package/rules/npm-module/policy/npm_package_json/target.json +4 -0
  49. package/rules/npm-module/policy/npm_publish_yml/target.json +4 -0
  50. package/rules/npm-module/policy/root_package_json/target.json +4 -0
  51. package/rules/php/policy/lint_php_yml/target.json +4 -0
  52. package/rules/php/policy/package_json/target.json +4 -0
  53. package/rules/rego/js/applies/check.mjs +54 -0
  54. package/rules/rego/policy/package_json/target.json +5 -0
  55. package/rules/rego/policy/vscode_extensions/target.json +5 -0
  56. package/rules/rego/policy/vscode_settings/target.json +5 -0
  57. package/rules/style-lint/policy/lint_style_yml/target.json +4 -0
  58. package/rules/style-lint/policy/package_json/target.json +4 -0
  59. package/rules/style-lint/policy/vscode_extensions/target.json +4 -0
  60. package/rules/style-lint/policy/vscode_settings/target.json +4 -0
  61. package/rules/text/policy/cspell/target.json +4 -0
  62. package/rules/text/policy/markdownlint/target.json +4 -0
  63. package/rules/text/policy/oxfmtrc/target.json +4 -0
  64. package/rules/text/policy/package_json/target.json +4 -0
  65. package/rules/text/policy/vscode_extensions/target.json +4 -0
  66. package/rules/text/policy/vscode_settings/target.json +4 -0
  67. package/rules/vue/policy/package_json/target.json +4 -0
  68. package/schemas/target.json +58 -0
  69. package/scripts/lint-conftest.mjs +65 -414
  70. package/scripts/utils/discover-checkable-rules.mjs +123 -0
  71. package/scripts/utils/resolve-target-files.mjs +109 -0
  72. package/scripts/utils/run-rule.mjs +131 -0
  73. package/rules/abie/js/check.mjs +0 -1152
  74. package/rules/rego/js/check.mjs +0 -106
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Discovery rules для CLI `check`. Шукає правила, для яких є щось «прогонне»:
3
+ * - JS concerns: `rules/<id>/js/<concern>/<check.mjs | check-*.mjs>` — кожен concern окремий вузол.
4
+ * - Policy concerns: `rules/<id>/policy/<concern>/target.json` — пара з `<concern>.rego`.
5
+ * - Legacy JS (на час міграції): `rules/<id>/js/check.mjs` (плаский) — мапиться у concern `legacy`,
6
+ * щоб не ламати ще не мігровані правила.
7
+ *
8
+ * Каталог `utils/` всередині `js/` свідомо пропускається — це хелпери, не концерни.
9
+ * Файли `*.test.mjs` фільтруються regex (`^check(?:-.+)?\.mjs$`).
10
+ *
11
+ * Намеренно НЕ парсимо `target.json` тут (це робить runner). Discovery — швидкий скан структури:
12
+ * шляхи + назви, без I/O вмісту.
13
+ */
14
+ import { existsSync } from 'node:fs'
15
+ import { readdir } from 'node:fs/promises'
16
+ import { join } from 'node:path'
17
+
18
+ const CHECK_FILENAME_RE = /^check(?:-.+)?\.mjs$/u
19
+ const TEST_SUFFIX = '.test.mjs'
20
+
21
+ /**
22
+ * @typedef {object} JsConcern
23
+ * @property {string} name імʼя концерну (`<name>` у `js/<name>/`); для legacy — `'legacy'`
24
+ * @property {string[]} files імена `check*.mjs` у концерні (відсортовані алфавітно)
25
+ * @property {boolean} legacy чи це fallback на плаский `js/check.mjs`
26
+ */
27
+
28
+ /**
29
+ * @typedef {object} PolicyConcern
30
+ * @property {string} name імʼя концерну (`<name>` у `policy/<name>/`)
31
+ */
32
+
33
+ /**
34
+ * @typedef {object} CheckableRule
35
+ * @property {string} id ідентифікатор правила (імʼя каталогу `rules/<id>/`)
36
+ * @property {JsConcern[]} jsConcerns
37
+ * @property {PolicyConcern[]} policyConcerns
38
+ */
39
+
40
+ /**
41
+ * Перелічує JS-концерни одного правила: підкаталоги `js/<name>/` з принаймні одним `check*.mjs`,
42
+ * плюс legacy-fallback на плаский `js/check.mjs` (без підкаталогу).
43
+ *
44
+ * `js/utils/` свідомо пропускається — це хелпери, а не концерни.
45
+ * @param {string} jsDir абсолютний шлях `rules/<id>/js/`
46
+ * @returns {Promise<JsConcern[]>} концерни в алфавітному порядку
47
+ */
48
+ async function listJsConcerns(jsDir) {
49
+ if (!existsSync(jsDir)) return []
50
+ const topLevel = await readdir(jsDir, { withFileTypes: true })
51
+
52
+ // Перевага — нова concern-структура (`js/<concern>/check*.mjs`).
53
+ /** @type {JsConcern[]} */
54
+ const concerns = []
55
+ for (const entry of topLevel) {
56
+ if (!entry.isDirectory() || entry.name === 'utils' || entry.name.startsWith('.')) continue
57
+ const concernDir = join(jsDir, entry.name)
58
+ const files = (await readdir(concernDir))
59
+ .filter(n => CHECK_FILENAME_RE.test(n) && !n.endsWith(TEST_SUFFIX))
60
+ .toSorted((a, b) => a.localeCompare(b))
61
+ if (files.length > 0) {
62
+ concerns.push({ name: entry.name, files, legacy: false })
63
+ }
64
+ }
65
+
66
+ // Legacy fallback — лише якщо subdir-концернів немає взагалі. Гібридні правила
67
+ // (одночасно legacy check.mjs + нові концерни) трактуються як уже мігровані:
68
+ // CLI запускає тільки субдиректорні концерни, flat-файл лишається для backward-compat
69
+ // тестів, які імпортують `check` напряму.
70
+ if (concerns.length === 0) {
71
+ const flatChecks = topLevel
72
+ .filter(e => e.isFile() && CHECK_FILENAME_RE.test(e.name) && !e.name.endsWith(TEST_SUFFIX))
73
+ .map(e => e.name)
74
+ .toSorted((a, b) => a.localeCompare(b))
75
+ if (flatChecks.length > 0) {
76
+ concerns.push({ name: 'legacy', files: flatChecks, legacy: true })
77
+ }
78
+ }
79
+
80
+ return concerns.toSorted((a, b) => a.name.localeCompare(b.name))
81
+ }
82
+
83
+ /**
84
+ * Перелічує policy-концерни: підкаталоги `policy/<name>/` з наявним `target.json`.
85
+ * @param {string} policyDir абсолютний шлях `rules/<id>/policy/`
86
+ * @returns {Promise<PolicyConcern[]>} концерни в алфавітному порядку
87
+ */
88
+ async function listPolicyConcerns(policyDir) {
89
+ if (!existsSync(policyDir)) return []
90
+ const entries = await readdir(policyDir, { withFileTypes: true })
91
+ /** @type {PolicyConcern[]} */
92
+ const out = []
93
+ for (const entry of entries) {
94
+ if (!entry.isDirectory() || entry.name.startsWith('.')) continue
95
+ if (existsSync(join(policyDir, entry.name, 'target.json'))) {
96
+ out.push({ name: entry.name })
97
+ }
98
+ }
99
+ return out.toSorted((a, b) => a.name.localeCompare(b.name))
100
+ }
101
+
102
+ /**
103
+ * Сканує `rules/` і повертає правила, для яких є JS-концерни або policy-концерни.
104
+ * Правила без жодної прогонної частини (тільки `.mdc` + `auto.md`) фільтруються.
105
+ * @param {string} bundledRulesDir абсолютний шлях до `npm/rules/`
106
+ * @returns {Promise<CheckableRule[]>} відсортовані за id
107
+ */
108
+ export async function discoverCheckableRules(bundledRulesDir) {
109
+ if (!existsSync(bundledRulesDir)) return []
110
+ const entries = await readdir(bundledRulesDir, { withFileTypes: true })
111
+ /** @type {CheckableRule[]} */
112
+ const out = []
113
+ for (const entry of entries) {
114
+ if (!entry.isDirectory() || entry.name.startsWith('.')) continue
115
+ const ruleDir = join(bundledRulesDir, entry.name)
116
+ const jsConcerns = await listJsConcerns(join(ruleDir, 'js'))
117
+ const policyConcerns = await listPolicyConcerns(join(ruleDir, 'policy'))
118
+ if (jsConcerns.length > 0 || policyConcerns.length > 0) {
119
+ out.push({ id: entry.name, jsConcerns, policyConcerns })
120
+ }
121
+ }
122
+ return out.toSorted((a, b) => a.id.localeCompare(b.id))
123
+ }
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Резолвер списку файлів для одного `policy/<name>/target.json` у новій структурі правил.
3
+ *
4
+ * Дві форми у `target.json:files`:
5
+ * - `{ "single": "<rel>" }` — конкретний відносний шлях. Якщо `existsSync(root/single)` → `[single]`;
6
+ * інакше `[]` (caller сам вирішує fail vs silent skip за `required`).
7
+ * - `{ "walkGlob": <glob | glob[]> }` — picomatch проти posix-відносних шляхів, отриманих обходом
8
+ * `walkDir` від `root` із загальними skip-ами та `.n-cursor.json:ignore`. Обхід кешований у
9
+ * `walkCache` (Map ключ — підпис ignorePaths) — повторні таргети з тим самим набором ignore
10
+ * перевикористовують список без нового readdir.
11
+ *
12
+ * Path-traversal у `single` — кидаємо помилку при resolve. Реалізує інваріант контракту: полісі
13
+ * читають лише файли в репо.
14
+ */
15
+ import { existsSync } from 'node:fs'
16
+ import { isAbsolute, join, normalize, relative, sep } from 'node:path'
17
+
18
+ import picomatch from 'picomatch'
19
+
20
+ import { loadCursorIgnorePaths } from './load-cursor-config.mjs'
21
+ import { walkDir } from './walkDir.mjs'
22
+
23
+ /** Узгоджений regex для path-traversal: `..` як сегмент або абсолютний шлях. */
24
+ const PARENT_SEGMENT_RE = /(^|[\\/])\.\.([\\/]|$)/u
25
+
26
+ /**
27
+ * Перевіряє, що `single`-шлях у `target.json:files` лежить у межах репозиторію.
28
+ * Кидає помилку, якщо шлях абсолютний або містить сегмент `..`.
29
+ * @param {string} singlePath значення `files.single`
30
+ * @returns {void}
31
+ */
32
+ function assertSafeSinglePath(singlePath) {
33
+ if (isAbsolute(singlePath)) {
34
+ throw new Error(`target.json: files.single має бути відносним шляхом (отримано: ${singlePath})`)
35
+ }
36
+ if (PARENT_SEGMENT_RE.test(singlePath)) {
37
+ throw new Error(`target.json: files.single не може містити '..' (отримано: ${singlePath})`)
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Збирає всі файли (posix-відносні шляхи від `root`) одним обходом дерева.
43
+ * Скіпи: загальні з `walkDir` + `.n-cursor.json:ignore`.
44
+ * @param {string} root абсолютний корінь репозиторію
45
+ * @param {string[]} ignorePaths абсолютні posix-шляхи виключених каталогів
46
+ * @returns {Promise<string[]>} відсортовані posix-відносні шляхи
47
+ */
48
+ async function walkAllRelative(root, ignorePaths) {
49
+ /** @type {string[]} */
50
+ const out = []
51
+ await walkDir(
52
+ root,
53
+ abs => {
54
+ const rel = relative(root, abs).split(sep).join('/')
55
+ if (rel.length > 0) out.push(rel)
56
+ },
57
+ ignorePaths
58
+ )
59
+ return out.toSorted((a, b) => a.localeCompare(b))
60
+ }
61
+
62
+ /**
63
+ * Витягує (або обчислює і кешує) список усіх файлів у дереві для заданого набору ignore-шляхів.
64
+ * Кеш — мapа `signature → Promise<string[]>`, тож паралельні виклики одного й того ж набору
65
+ * чекають один обхід.
66
+ * @param {string} root абсолютний корінь репозиторію
67
+ * @param {string[]} ignorePaths абсолютні posix-шляхи виключених каталогів
68
+ * @param {Map<string, Promise<string[]>>} walkCache мутабельний кеш від caller-а
69
+ * @returns {Promise<string[]>} відсортовані posix-відносні шляхи
70
+ */
71
+ function getAllFilesCached(root, ignorePaths, walkCache) {
72
+ const signature = `${root}|${ignorePaths.join('|')}`
73
+ let p = walkCache.get(signature)
74
+ if (!p) {
75
+ p = walkAllRelative(root, ignorePaths)
76
+ walkCache.set(signature, p)
77
+ }
78
+ return p
79
+ }
80
+
81
+ /**
82
+ * Резолвить список файлів для одного `target.json:files`.
83
+ * @param {object} filesSpec поле `files` з `target.json` (вже після schema-валідації)
84
+ * @param {string} root абсолютний корінь репозиторію
85
+ * @param {Map<string, Promise<string[]>>} walkCache кеш обходів дерева (cross-target у межах одного check-прогону)
86
+ * @returns {Promise<string[]>} абсолютні шляхи знайдених файлів (порожній — нічого не знайдено)
87
+ */
88
+ export async function resolveTargetFiles(filesSpec, root, walkCache) {
89
+ if (typeof filesSpec?.single === 'string') {
90
+ assertSafeSinglePath(filesSpec.single)
91
+ const normalized = normalize(filesSpec.single).split(sep).join('/')
92
+ const abs = join(root, normalized)
93
+ return existsSync(abs) ? [abs] : []
94
+ }
95
+ if (filesSpec?.walkGlob !== undefined) {
96
+ const ignorePaths = await loadCursorIgnorePaths(root)
97
+ const all = await getAllFilesCached(root, ignorePaths, walkCache)
98
+ const globs = Array.isArray(filesSpec.walkGlob) ? filesSpec.walkGlob : [filesSpec.walkGlob]
99
+ // picomatch у масиві трактує `!neg` як ОКРЕМИЙ позитивний матчер «не-neg» (some-OR логіка),
100
+ // тож наївне `picomatch(['pos','!neg'])` повертає true майже на всьому. Розділяємо вручну:
101
+ // позитиви join-имо через picomatch(...), негативні фільтруємо окремим isExcluded.
102
+ const positives = globs.filter(g => !g.startsWith('!'))
103
+ const negatives = globs.filter(g => g.startsWith('!')).map(g => g.slice(1))
104
+ const isMatch = positives.length > 0 ? picomatch(positives, { dot: false }) : () => false
105
+ const isExcluded = negatives.length > 0 ? picomatch(negatives, { dot: false }) : () => false
106
+ return all.filter(rel => isMatch(rel) && !isExcluded(rel)).map(rel => join(root, rel))
107
+ }
108
+ throw new Error(`target.json: files має містити single або walkGlob (отримано: ${JSON.stringify(filesSpec)})`)
109
+ }
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Оркестратор одного правила під CLI `check`.
3
+ *
4
+ * Послідовність (concerns у межах правила — алфавітно):
5
+ * 1. **applies-гейт** з `js/applies/check.mjs`. Якщо модуль експортує `applies()` і вона повертає
6
+ * false — друкуємо `✅ правило не застосовне` і завершуємо без подальших викликів.
7
+ * 2. **JS-концерни** — кожен `check*.mjs` у `js/<concern>/`. Concern `applies` теж може мати
8
+ * `check()` для друку контексту (його `applies()` уже відпрацював на кроці 1, він не повторюється).
9
+ * Legacy-fallback: плаский `js/check.mjs` лежить як concern `legacy` — імпортується з кореня `js/`,
10
+ * а не з підкаталога.
11
+ * 3. **Policy-концерни** — кожен `policy/<concern>/target.json` через `runConftestBatch`.
12
+ * Реcолвер `resolveTargetFiles` ділить cache (`walkCache`) між концернами.
13
+ *
14
+ * Кожен concern має власний `createCheckReporter` — їхні exit-коди OR-яться в один на рівні правила.
15
+ * Це дає той самий 0/1 контракт, що й попередня модель «один check.mjs на правило».
16
+ */
17
+ import { readFile } from 'node:fs/promises'
18
+ import { join } from 'node:path'
19
+
20
+ import { createCheckReporter } from './check-reporter.mjs'
21
+ import { resolveTargetFiles } from './resolve-target-files.mjs'
22
+ import { runConftestBatch } from './run-conftest-batch.mjs'
23
+
24
+ const APPLIES_CONCERN_NAME = 'applies'
25
+
26
+ /**
27
+ * Обчислює абсолютний шлях до файла-check у JS-концерні.
28
+ * @param {string} bundledRulesDir абсолютний `rules/`
29
+ * @param {string} ruleId id правила
30
+ * @param {import('./discover-checkable-rules.mjs').JsConcern} concern опис концерну
31
+ * @param {string} fileName імʼя файла з `concern.files`
32
+ * @returns {string} абсолютний шлях
33
+ */
34
+ function resolveJsCheckPath(bundledRulesDir, ruleId, concern, fileName) {
35
+ return concern.legacy
36
+ ? join(bundledRulesDir, ruleId, 'js', fileName)
37
+ : join(bundledRulesDir, ruleId, 'js', concern.name, fileName)
38
+ }
39
+
40
+ /**
41
+ * Спробувати викликати applies() гейт з `js/applies/check.mjs` правила.
42
+ * Гейт активний лише за наявності концерну з імʼям `applies` і експортом-функцією `applies` у його
43
+ * першому check-файлі (алфавіт).
44
+ * @param {string} bundledRulesDir абсолютний `rules/`
45
+ * @param {import('./discover-checkable-rules.mjs').CheckableRule} rule опис правила
46
+ * @returns {Promise<boolean>} `true` — правило застосовне (або гейту немає); `false` — пропустити
47
+ */
48
+ async function evaluateAppliesGate(bundledRulesDir, rule) {
49
+ const concern = rule.jsConcerns.find(c => c.name === APPLIES_CONCERN_NAME)
50
+ if (!concern || concern.files.length === 0) return true
51
+ const path = resolveJsCheckPath(bundledRulesDir, rule.id, concern, concern.files[0])
52
+ const mod = await import(path)
53
+ if (typeof mod.applies !== 'function') return true
54
+ return Boolean(await mod.applies())
55
+ }
56
+
57
+ /**
58
+ * Запускає одну policy-полісі через `runConftestBatch`. Створює локальний репортер,
59
+ * читає `target.json`, резолвить файли, фіксує fail/pass — і повертає exit-код.
60
+ * @param {string} bundledRulesDir абсолютний `rules/`
61
+ * @param {string} ruleId id правила
62
+ * @param {string} concernName імʼя полісі (= підкаталог у `policy/`)
63
+ * @param {Map<string, Promise<string[]>>} walkCache shared cache між концернами одного check-прогону
64
+ * @returns {Promise<number>} 0 — OK, 1 — є порушення
65
+ */
66
+ async function runPolicyConcern(bundledRulesDir, ruleId, concernName, walkCache) {
67
+ const reporter = createCheckReporter()
68
+ const targetPath = join(bundledRulesDir, ruleId, 'policy', concernName, 'target.json')
69
+ /** @type {{ files: { single?: string, walkGlob?: string|string[], required?: boolean }, missingMessage?: string }} */
70
+ const target = JSON.parse(await readFile(targetPath, 'utf8'))
71
+ const files = await resolveTargetFiles(target.files, process.cwd(), walkCache)
72
+ if (files.length === 0) {
73
+ if (target.files.required && target.files.single) {
74
+ const msg =
75
+ target.missingMessage ?? `${target.files.single} не існує — створи згідно ${ruleId}.mdc (${ruleId}.${concernName})`
76
+ reporter.fail(msg)
77
+ }
78
+ return reporter.getExitCode()
79
+ }
80
+ // Rego не дозволяє '-' в імені пакета, тому kebab-id у `.n-cursor.json:rules`
81
+ // мапиться на snake у namespace. Файлова структура `rules/<id>/policy/` лишається kebab.
82
+ const regoNamespace = `${ruleId.replaceAll('-', '_')}.${concernName}`
83
+ const violations = runConftestBatch({
84
+ policyDirRel: `${ruleId}/${concernName}`,
85
+ namespace: regoNamespace,
86
+ files
87
+ })
88
+ if (violations.length === 0) {
89
+ reporter.pass(`${concernName}: ${files.length} файл(ів) OK (rego)`)
90
+ } else {
91
+ for (const v of violations) reporter.fail(v.message)
92
+ }
93
+ return reporter.getExitCode()
94
+ }
95
+
96
+ /**
97
+ * Запускає одне правило: applies-гейт → JS-концерни → policy-концерни.
98
+ * @param {import('./discover-checkable-rules.mjs').CheckableRule} rule
99
+ * @param {string} bundledRulesDir абсолютний шлях до `rules/`
100
+ * @param {Map<string, Promise<string[]>>} walkCache shared cache (один на check-прогон)
101
+ * @returns {Promise<number>} 0 — OK, 1 — є порушення в одному чи більше концернів
102
+ */
103
+ export async function runRule(rule, bundledRulesDir, walkCache) {
104
+ console.log(`📋 ${rule.id}:`)
105
+
106
+ if (!(await evaluateAppliesGate(bundledRulesDir, rule))) {
107
+ console.log(` ✅ Правило ${rule.id} не застосовне до цього репо — пропущено`)
108
+ return 0
109
+ }
110
+
111
+ let totalCode = 0
112
+
113
+ for (const concern of rule.jsConcerns) {
114
+ for (const fileName of concern.files) {
115
+ const path = resolveJsCheckPath(bundledRulesDir, rule.id, concern, fileName)
116
+ // eslint-disable-next-line no-unsanitized/method -- path будується з discovered concern/file, які пройшли regex CHECK_FILENAME_RE
117
+ const mod = await import(path)
118
+ if (typeof mod.check === 'function') {
119
+ const code = await mod.check()
120
+ if (code !== 0) totalCode = 1
121
+ }
122
+ }
123
+ }
124
+
125
+ for (const policyConcern of rule.policyConcerns) {
126
+ const code = await runPolicyConcern(bundledRulesDir, rule.id, policyConcern.name, walkCache)
127
+ if (code !== 0) totalCode = 1
128
+ }
129
+
130
+ return totalCode
131
+ }