@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.
- package/CHANGELOG.md +34 -1
- package/bin/n-cursor.js +29 -29
- package/package.json +2 -1
- package/rules/abie/js/applies/check.mjs +24 -0
- package/rules/abie/js/env_dns/check.mjs +53 -0
- package/rules/abie/js/firebase_hosting/check.mjs +49 -0
- package/rules/abie/js/hc_pairing/check.mjs +58 -0
- package/rules/abie/js/ua_http_route/check.mjs +86 -0
- package/rules/abie/js/ua_node_selector/check.mjs +65 -0
- package/rules/abie/policy/base_deployment_preem/target.json +10 -0
- package/rules/abie/policy/clean_merged_ignore_branches/target.json +4 -0
- package/rules/abie/policy/health_check_policy/target.json +4 -0
- package/rules/abie/policy/http_route_base/target.json +4 -0
- package/rules/abie/utils/enabled.mjs +35 -0
- package/rules/abie/utils/env-dns.mjs +81 -0
- package/rules/abie/utils/hc-yaml.mjs +27 -0
- package/rules/abie/utils/http-route.mjs +93 -0
- package/rules/abie/utils/k8s-tree.mjs +102 -0
- package/rules/abie/utils/kustomization-patches.mjs +224 -0
- package/rules/abie/utils/overlay-paths.mjs +97 -0
- package/rules/abie/utils/yaml.mjs +72 -0
- package/rules/adr/policy/settings_json/target.json +4 -0
- package/rules/adr/policy/settings_local_json/target.json +4 -0
- package/rules/bun/policy/bunfig/target.json +4 -0
- package/rules/bun/policy/package_json/target.json +4 -0
- package/rules/capacitor/policy/package_json/target.json +4 -0
- package/rules/docker/policy/lint_docker_yml/target.json +4 -0
- package/rules/docker/policy/package_json/target.json +4 -0
- package/rules/hasura/policy/svc_hl/target.json +4 -0
- package/rules/image-avif/policy/package_json/target.json +4 -0
- package/rules/image-compress/policy/package_json/target.json +4 -0
- package/rules/js-bun-db/policy/package_json/target.json +4 -0
- package/rules/js-bun-redis/policy/package_json/target.json +4 -0
- package/rules/js-lint/policy/lint_js_yml/target.json +4 -0
- package/rules/js-lint/policy/package_json/target.json +4 -0
- package/rules/js-mssql/policy/package_json/target.json +4 -0
- package/rules/js-run/policy/configmap/target.json +4 -0
- package/rules/js-run/policy/package_json/target.json +4 -0
- package/rules/k8s/policy/base_kustomization/target.json +4 -0
- package/rules/k8s/policy/base_manifest/target.json +10 -0
- package/rules/k8s/policy/gateway/target.json +4 -0
- package/rules/k8s/policy/hpa_pdb/target.json +4 -0
- package/rules/k8s/policy/kustomization/target.json +4 -0
- package/rules/k8s/policy/manifest/target.json +4 -0
- package/rules/k8s/policy/svc_hl_yaml/target.json +4 -0
- package/rules/k8s/policy/svc_yaml/target.json +4 -0
- package/rules/npm-module/policy/emit_types_config/target.json +4 -0
- package/rules/npm-module/policy/npm_package_json/target.json +4 -0
- package/rules/npm-module/policy/npm_publish_yml/target.json +4 -0
- package/rules/npm-module/policy/root_package_json/target.json +4 -0
- package/rules/php/policy/lint_php_yml/target.json +4 -0
- package/rules/php/policy/package_json/target.json +4 -0
- package/rules/rego/js/applies/check.mjs +54 -0
- package/rules/rego/policy/package_json/target.json +5 -0
- package/rules/rego/policy/vscode_extensions/target.json +5 -0
- package/rules/rego/policy/vscode_settings/target.json +5 -0
- package/rules/style-lint/policy/lint_style_yml/target.json +4 -0
- package/rules/style-lint/policy/package_json/target.json +4 -0
- package/rules/style-lint/policy/vscode_extensions/target.json +4 -0
- package/rules/style-lint/policy/vscode_settings/target.json +4 -0
- package/rules/text/policy/cspell/target.json +4 -0
- package/rules/text/policy/markdownlint/target.json +4 -0
- package/rules/text/policy/oxfmtrc/target.json +4 -0
- package/rules/text/policy/package_json/target.json +4 -0
- package/rules/text/policy/vscode_extensions/target.json +4 -0
- package/rules/text/policy/vscode_settings/target.json +4 -0
- package/rules/vue/policy/package_json/target.json +4 -0
- package/schemas/target.json +58 -0
- package/scripts/lint-conftest.mjs +65 -414
- package/scripts/utils/discover-checkable-rules.mjs +123 -0
- package/scripts/utils/resolve-target-files.mjs +109 -0
- package/scripts/utils/run-rule.mjs +131 -0
- package/rules/abie/js/check.mjs +0 -1152
- 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
|
+
}
|