@nitra/cursor 1.37.0 → 1.39.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 +13 -0
- package/README.md +6 -0
- package/bin/n-cursor.js +7 -1
- package/package.json +1 -1
- package/rules/abie/meta.json +1 -0
- package/rules/adr/meta.json +1 -0
- package/rules/bun/meta.json +1 -0
- package/rules/capacitor/meta.json +1 -0
- package/rules/changelog/meta.json +1 -0
- package/rules/ci4/meta.json +1 -0
- package/rules/docker/meta.json +1 -0
- package/rules/efes/meta.json +1 -0
- package/rules/feedback/meta.json +1 -0
- package/rules/ga/meta.json +1 -0
- package/rules/graphql/meta.json +1 -0
- package/rules/hasura/meta.json +1 -0
- package/rules/image-avif/meta.json +1 -0
- package/rules/image-compress/meta.json +1 -0
- package/rules/js-bun-db/meta.json +1 -0
- package/rules/js-bun-redis/meta.json +1 -0
- package/rules/js-lint/meta.json +1 -0
- package/rules/js-mssql/meta.json +1 -0
- package/rules/js-run/meta.json +1 -0
- package/rules/k8s/meta.json +1 -0
- package/rules/nginx-default-tpl/meta.json +1 -0
- package/rules/npm-module/js/rule_meta.mjs +63 -0
- package/rules/npm-module/meta.json +1 -0
- package/rules/php/meta.json +1 -0
- package/rules/rego/meta.json +1 -0
- package/rules/release/meta.json +1 -0
- package/rules/rust/meta.json +1 -0
- package/rules/security/meta.json +1 -0
- package/rules/style-lint/meta.json +1 -0
- package/rules/tauri/meta.json +1 -0
- package/rules/test/meta.json +1 -0
- package/rules/text/meta.json +1 -0
- package/rules/vue/meta.json +1 -0
- package/rules/worktree/fix.mjs +19 -0
- package/rules/worktree/meta.json +1 -0
- package/rules/worktree/worktree.mdc +34 -0
- package/schemas/rule-meta.json +39 -0
- package/schemas/v8r-catalog.json +5 -0
- package/scripts/auto-rules.mjs +151 -449
- package/scripts/lib/rule-meta-helpers.mjs +103 -0
- package/scripts/lib/rule-meta.mjs +66 -0
- package/scripts/lib/rule-predicates.mjs +147 -0
- package/scripts/lib/worktree.mjs +73 -0
- package/scripts/worktree-cli.mjs +200 -0
- package/skills/worktree/SKILL.md +38 -0
- package/skills/worktree/meta.json +1 -0
- package/rules/abie/auto.md +0 -1
- package/rules/adr/auto.md +0 -1
- package/rules/bun/auto.md +0 -1
- package/rules/capacitor/auto.md +0 -1
- package/rules/changelog/auto.md +0 -1
- package/rules/docker/auto.md +0 -1
- package/rules/efes/auto.md +0 -1
- package/rules/ga/auto.md +0 -1
- package/rules/graphql/auto.md +0 -1
- package/rules/hasura/auto.md +0 -1
- package/rules/image-avif/auto.md +0 -1
- package/rules/image-compress/auto.md +0 -1
- package/rules/js-bun-db/auto.md +0 -1
- package/rules/js-bun-redis/auto.md +0 -1
- package/rules/js-lint/auto.md +0 -1
- package/rules/js-mssql/auto.md +0 -1
- package/rules/js-run/auto.md +0 -1
- package/rules/k8s/auto.md +0 -1
- package/rules/nginx-default-tpl/auto.md +0 -1
- package/rules/npm-module/auto.md +0 -1
- package/rules/php/auto.md +0 -1
- package/rules/rego/auto.md +0 -1
- package/rules/rust/auto.md +0 -1
- package/rules/security/auto.md +0 -1
- package/rules/style-lint/auto.md +0 -1
- package/rules/tauri/auto.md +0 -1
- package/rules/test/auto.md +0 -1
- package/rules/text/auto.md +0 -1
- package/rules/vue/auto.md +0 -1
package/scripts/auto-rules.mjs
CHANGED
|
@@ -1,21 +1,26 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Автовизначення правил для `.n-cursor.json` за
|
|
2
|
+
* Автовизначення правил для `.n-cursor.json` за meta-даними з `npm/rules/<id>/meta.json`.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* `
|
|
4
|
+
* Основна роль: `discoverRuleAutoActivation` читає `npm/rules/<id>/meta.json`, виводить
|
|
5
|
+
* `AUTO_RULE_ORDER` (алфавітно) і `AUTO_RULE_DEPENDENCIES` з meta, а потім для кожного правила
|
|
6
|
+
* обчислює spec активації через `specMatches`: `always` — безумовно; `glob` — перевірка
|
|
7
|
+
* файлів через `globToRegex`; `predicate` — незводимий предикат із реєстру `RULE_PREDICATES`
|
|
8
|
+
* (у `lib/rule-predicates.mjs`). Транзитивне розгортання залежностей — `resolveRuleDependencies`.
|
|
9
|
+
*
|
|
10
|
+
* `collectAutoRuleFacts` зберігається для content-фактів (GQL, bun-sql, hasura) і власних тестів.
|
|
8
11
|
*
|
|
9
12
|
* Враховує винятки `disable-rules`: елементи зі списку не додаються автоматично.
|
|
10
13
|
*
|
|
11
|
-
* Автодетект скілів — у `./auto-skills.mjs` (умови — у `npm/skills/<skill>/
|
|
14
|
+
* Автодетект скілів — у `./auto-skills.mjs` (умови — у `npm/skills/<skill>/meta.json`).
|
|
12
15
|
* `mergeConfigWithAutoDetected` нижче приймає вже виявлені rules і skills і вливає
|
|
13
16
|
* їх у конфіг із поправкою на legacy-id (`migrateRuleIds`).
|
|
14
17
|
*/
|
|
15
|
-
import {
|
|
18
|
+
import { readdirSync } from 'node:fs'
|
|
16
19
|
import { readdir, readFile } from 'node:fs/promises'
|
|
17
|
-
import { basename, join, relative } from 'node:path'
|
|
20
|
+
import { basename, dirname, join, relative } from 'node:path'
|
|
21
|
+
import { fileURLToPath } from 'node:url'
|
|
18
22
|
|
|
23
|
+
import { globToRegex } from '../rules/npm-module/js/package_structure.mjs'
|
|
19
24
|
import { textHasBunSqlImport } from '../rules/js-bun-db/lib/bun-sql-scan.mjs'
|
|
20
25
|
import {
|
|
21
26
|
isGqlScanSourceFile,
|
|
@@ -23,99 +28,64 @@ import {
|
|
|
23
28
|
sourceFileHasGqlTaggedTemplate
|
|
24
29
|
} from '../rules/graphql/lib/graphql-gql-scan.mjs'
|
|
25
30
|
import { contentForVueImportScan } from '../rules/vue/lib/vue-forbidden-imports.mjs'
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
'js-lint',
|
|
42
|
-
'js-mssql',
|
|
43
|
-
'js-bun-db',
|
|
44
|
-
'js-bun-redis',
|
|
45
|
-
'js-run',
|
|
46
|
-
'k8s',
|
|
47
|
-
'nginx-default-tpl',
|
|
48
|
-
'npm-module',
|
|
49
|
-
'php',
|
|
50
|
-
'rego',
|
|
51
|
-
'rust',
|
|
52
|
-
'security',
|
|
53
|
-
'style-lint',
|
|
54
|
-
'test',
|
|
55
|
-
'text',
|
|
56
|
-
'vue'
|
|
57
|
-
])
|
|
31
|
+
import { parseRuleAutoSpec, readRuleMetaRaw } from './lib/rule-meta.mjs'
|
|
32
|
+
import { migrateRuleIds, normalizeIdList } from './lib/rule-meta-helpers.mjs'
|
|
33
|
+
import { RULE_PREDICATES } from './lib/rule-predicates.mjs'
|
|
34
|
+
|
|
35
|
+
export {
|
|
36
|
+
detectLegacyRuleIds,
|
|
37
|
+
getRepositoryUrl,
|
|
38
|
+
isMonorepoPackage,
|
|
39
|
+
migrateRuleIds,
|
|
40
|
+
normalizeIdList,
|
|
41
|
+
RULE_MIGRATIONS
|
|
42
|
+
} from './lib/rule-meta-helpers.mjs'
|
|
43
|
+
|
|
44
|
+
const PACKAGE_ROOT = dirname(dirname(fileURLToPath(import.meta.url)))
|
|
45
|
+
const RULES_DIR = join(PACKAGE_ROOT, 'rules')
|
|
58
46
|
|
|
59
47
|
/**
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
48
|
+
* Скан `npm/rules/<id>/meta.json` → мапа id → RuleAutoSpec (лише правила з розпізнаним auto).
|
|
49
|
+
* @param {string} [rulesDir] override для тестів
|
|
50
|
+
* @returns {Record<string, import('./lib/rule-meta.mjs').RuleAutoSpec>} мапа автоактивації
|
|
63
51
|
*/
|
|
64
|
-
export
|
|
65
|
-
/** @type {Record<string,
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
for (const id of ids) {
|
|
80
|
-
const replacement = Object.hasOwn(RULE_MIGRATIONS, id) ? RULE_MIGRATIONS[id] : [id]
|
|
81
|
-
for (const newId of replacement) {
|
|
82
|
-
if (!out.includes(newId)) out.push(newId)
|
|
83
|
-
}
|
|
52
|
+
export function discoverRuleAutoActivation(rulesDir = RULES_DIR) {
|
|
53
|
+
/** @type {Record<string, import('./lib/rule-meta.mjs').RuleAutoSpec>} */
|
|
54
|
+
const out = {}
|
|
55
|
+
let entries
|
|
56
|
+
try {
|
|
57
|
+
entries = readdirSync(rulesDir, { withFileTypes: true })
|
|
58
|
+
} catch {
|
|
59
|
+
return out
|
|
60
|
+
}
|
|
61
|
+
for (const entry of entries) {
|
|
62
|
+
if (!entry.isDirectory() || entry.name.startsWith('.')) continue
|
|
63
|
+
const raw = readRuleMetaRaw(join(rulesDir, entry.name))
|
|
64
|
+
if (!raw) continue
|
|
65
|
+
const spec = parseRuleAutoSpec(raw.auto)
|
|
66
|
+
if (spec) out[entry.name] = spec
|
|
84
67
|
}
|
|
85
68
|
return out
|
|
86
69
|
}
|
|
87
70
|
|
|
88
|
-
|
|
89
|
-
* Повертає лише ті legacy rule-id зі списку, для яких є запис у `RULE_MIGRATIONS`.
|
|
90
|
-
* Використовується для людинозрозумілого логування міграції при синхронізації CLI.
|
|
91
|
-
* @param {string[]} ids нормалізований список id
|
|
92
|
-
* @returns {string[]} legacy id, які потребуватимуть заміни у `migrateRuleIds`
|
|
93
|
-
*/
|
|
94
|
-
export function detectLegacyRuleIds(ids) {
|
|
95
|
-
return ids.filter(id => Object.hasOwn(RULE_MIGRATIONS, id))
|
|
96
|
-
}
|
|
71
|
+
const RULE_AUTO_ACTIVATION = discoverRuleAutoActivation()
|
|
97
72
|
|
|
98
|
-
/**
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
73
|
+
/** Стабільний алфавітний порядок (замість хардкод-масиву). */
|
|
74
|
+
export const AUTO_RULE_ORDER = Object.freeze(
|
|
75
|
+
Object.keys(RULE_AUTO_ACTIVATION).toSorted((a, b) => a.localeCompare(b))
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
/** Граф залежностей із meta (Type C) — замість хардкод-константи. */
|
|
103
79
|
export const AUTO_RULE_DEPENDENCIES = Object.freeze(
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
80
|
+
Object.fromEntries(
|
|
81
|
+
Object.entries(RULE_AUTO_ACTIVATION)
|
|
82
|
+
.filter(([, s]) => 'rules' in s)
|
|
83
|
+
.map(([id, s]) => [id, Object.freeze(/** @type {{rules:string[]}} */ (s).rules)])
|
|
84
|
+
)
|
|
109
85
|
)
|
|
110
86
|
|
|
111
|
-
const ABIE_REPOSITORY_URL_MARKER = 'https://github.com/abinbevefes/'
|
|
112
|
-
const EFES_REPOSITORY_URL_MARKER = 'https://github.com/efes-cloud/'
|
|
113
87
|
const HASURA_CONFIG_MARKER = 'metadata_directory: metadata'
|
|
114
|
-
const JS_LIKE_RE = /\.(?:mjs|cjs|js|jsx|ts|tsx)$/iu
|
|
115
88
|
const REGO_RE = /\.rego$/iu
|
|
116
|
-
const STYLE_RE = /\.(?:css|vue)$/iu
|
|
117
|
-
const VUE_RE = /\.vue$/iu
|
|
118
|
-
const NGINX_DEFAULT_FILES = new Set(['default.conf.template', 'default.conf', 'nginx.conf'])
|
|
119
89
|
const IGNORED_DIR_NAMES = new Set(['node_modules', '.git', '.next', '.turbo'])
|
|
120
90
|
const DEFAULT_DISABLED_LIST = Object.freeze([])
|
|
121
91
|
|
|
@@ -129,207 +99,6 @@ function sourceContentHasBunSqlImport(content, relativePath) {
|
|
|
129
99
|
return textHasBunSqlImport(contentForVueImportScan(content, relativePath))
|
|
130
100
|
}
|
|
131
101
|
|
|
132
|
-
/**
|
|
133
|
-
* Зчитує `package.json` і додає в `found` усі ключі з `wanted`, що присутні в `dependencies`.
|
|
134
|
-
* @param {string} absPath абсолютний шлях до package.json
|
|
135
|
-
* @param {Set<string>} wanted множина ключів-цілей
|
|
136
|
-
* @param {Set<string>} found буфер знайдених ключів
|
|
137
|
-
* @returns {Promise<void>}
|
|
138
|
-
*/
|
|
139
|
-
async function collectFoundDependencyKeysFromPackageJson(absPath, wanted, found) {
|
|
140
|
-
try {
|
|
141
|
-
const parsed = JSON.parse(await readFile(absPath, 'utf8'))
|
|
142
|
-
const deps = parsed?.dependencies
|
|
143
|
-
if (!deps || typeof deps !== 'object' || Array.isArray(deps)) return
|
|
144
|
-
for (const key of wanted) {
|
|
145
|
-
if (Object.hasOwn(deps, key)) {
|
|
146
|
-
found.add(key)
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
} catch {
|
|
150
|
-
/* ігноруємо пошкоджені/недоступні package.json */
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
/**
|
|
155
|
-
* Збирає, які з переданих ключів присутні в `dependencies` хоча б одного `package.json`.
|
|
156
|
-
* @param {string} root абсолютний шлях до кореня репозиторію
|
|
157
|
-
* @param {string[]} dependencyKeys імена залежностей (наприклад `mssql`, `pg`)
|
|
158
|
-
* @returns {Promise<Set<string>>} множина знайдених ключів
|
|
159
|
-
*/
|
|
160
|
-
async function collectDependencyKeysPresentInPackageJsonTree(root, dependencyKeys) {
|
|
161
|
-
const wanted = new Set(dependencyKeys)
|
|
162
|
-
/** @type {Set<string>} */
|
|
163
|
-
const found = new Set()
|
|
164
|
-
|
|
165
|
-
/**
|
|
166
|
-
* Обробка одного запису з readdir: рекурсія в підкаталог або зчитування package.json.
|
|
167
|
-
* @param {import('node:fs').Dirent} entry елемент readdir
|
|
168
|
-
* @param {string} dir абсолютний шлях каталогу-власника entry
|
|
169
|
-
* @returns {Promise<void>}
|
|
170
|
-
*/
|
|
171
|
-
async function processEntry(entry, dir) {
|
|
172
|
-
const absPath = join(dir, entry.name)
|
|
173
|
-
if (entry.isDirectory()) {
|
|
174
|
-
if (!IGNORED_DIR_NAMES.has(entry.name)) {
|
|
175
|
-
await walk(absPath)
|
|
176
|
-
}
|
|
177
|
-
return
|
|
178
|
-
}
|
|
179
|
-
if (entry.isFile() && entry.name === 'package.json') {
|
|
180
|
-
await collectFoundDependencyKeysFromPackageJson(absPath, wanted, found)
|
|
181
|
-
}
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
/**
|
|
185
|
-
* Рекурсивний обхід каталогу з пропуском службових директорій.
|
|
186
|
-
* @param {string} dir абсолютний шлях каталогу
|
|
187
|
-
* @returns {Promise<void>}
|
|
188
|
-
*/
|
|
189
|
-
async function walk(dir) {
|
|
190
|
-
if (found.size === wanted.size) return
|
|
191
|
-
let entries
|
|
192
|
-
try {
|
|
193
|
-
entries = await readdir(dir, { withFileTypes: true })
|
|
194
|
-
} catch {
|
|
195
|
-
return
|
|
196
|
-
}
|
|
197
|
-
for (const entry of entries) {
|
|
198
|
-
if (found.size === wanted.size) return
|
|
199
|
-
await processEntry(entry, dir)
|
|
200
|
-
}
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
await walk(root)
|
|
204
|
-
return found
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
/**
|
|
208
|
-
* Перевіряє один package.json: повертає true, якщо в `devDependencies` немає `vite`.
|
|
209
|
-
* @param {string} absPath абсолютний шлях до package.json
|
|
210
|
-
* @returns {Promise<boolean>} true, якщо vite відсутній у devDependencies
|
|
211
|
-
*/
|
|
212
|
-
async function packageJsonLacksViteDevDependency(absPath) {
|
|
213
|
-
try {
|
|
214
|
-
const parsed = JSON.parse(await readFile(absPath, 'utf8'))
|
|
215
|
-
const devDeps = parsed?.devDependencies
|
|
216
|
-
if (!devDeps || typeof devDeps !== 'object' || Array.isArray(devDeps)) {
|
|
217
|
-
return true
|
|
218
|
-
}
|
|
219
|
-
return !Object.hasOwn(devDeps, 'vite')
|
|
220
|
-
} catch {
|
|
221
|
-
return false
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
/**
|
|
226
|
-
* Перевіряє, чи існує хоча б один вкладений `package.json` (не кореневий),
|
|
227
|
-
* у якому в `devDependencies` відсутня залежність `vite`.
|
|
228
|
-
* @param {string} root абсолютний шлях до кореня репозиторію
|
|
229
|
-
* @returns {Promise<boolean>} true, якщо знайдено вкладений package.json без `vite` у devDependencies
|
|
230
|
-
*/
|
|
231
|
-
async function hasNestedPackageJsonWithoutViteDevDependency(root) {
|
|
232
|
-
let result = false
|
|
233
|
-
|
|
234
|
-
/**
|
|
235
|
-
* Рекурсивний обхід каталогу з пропуском службових директорій.
|
|
236
|
-
* @param {string} dir абсолютний шлях каталогу
|
|
237
|
-
* @returns {Promise<void>} завершується після обходу всього піддерева або встановлення `result`
|
|
238
|
-
*/
|
|
239
|
-
async function walk(dir) {
|
|
240
|
-
if (result) return
|
|
241
|
-
let entries
|
|
242
|
-
try {
|
|
243
|
-
entries = await readdir(dir, { withFileTypes: true })
|
|
244
|
-
} catch {
|
|
245
|
-
return
|
|
246
|
-
}
|
|
247
|
-
for (const entry of entries) {
|
|
248
|
-
if (result) return
|
|
249
|
-
const absPath = join(dir, entry.name)
|
|
250
|
-
if (entry.isDirectory()) {
|
|
251
|
-
if (!IGNORED_DIR_NAMES.has(entry.name)) {
|
|
252
|
-
await walk(absPath)
|
|
253
|
-
}
|
|
254
|
-
continue
|
|
255
|
-
}
|
|
256
|
-
if (
|
|
257
|
-
entry.isFile() &&
|
|
258
|
-
entry.name === 'package.json' &&
|
|
259
|
-
absPath !== join(root, 'package.json') &&
|
|
260
|
-
(await packageJsonLacksViteDevDependency(absPath))
|
|
261
|
-
) {
|
|
262
|
-
result = true
|
|
263
|
-
return
|
|
264
|
-
}
|
|
265
|
-
}
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
await walk(root)
|
|
269
|
-
return result
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
/**
|
|
273
|
-
* Фіксує ознаки, що залежать лише від імені підкаталогу.
|
|
274
|
-
* @param {string} dirName імʼя каталогу
|
|
275
|
-
* @param {{
|
|
276
|
-
* hasK8sDir: boolean,
|
|
277
|
-
* hasTempoDir: boolean
|
|
278
|
-
* }} facts агреговані факти
|
|
279
|
-
* @returns {void}
|
|
280
|
-
*/
|
|
281
|
-
function updateDirFacts(dirName, facts) {
|
|
282
|
-
if (dirName === 'k8s') {
|
|
283
|
-
facts.hasK8sDir = true
|
|
284
|
-
}
|
|
285
|
-
if (dirName === 'tempo') {
|
|
286
|
-
facts.hasTempoDir = true
|
|
287
|
-
}
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
/**
|
|
291
|
-
* Фіксує ознаки, що визначаються за шляхом/іменем файлу.
|
|
292
|
-
* @param {string} fileName базове імʼя файлу
|
|
293
|
-
* @param {string} relPath шлях відносно кореня
|
|
294
|
-
* @param {{
|
|
295
|
-
* hasCapacitorConfig: boolean,
|
|
296
|
-
* hasCargoToml: boolean,
|
|
297
|
-
* hasDockerfile: boolean,
|
|
298
|
-
* hasJsLikeSource: boolean,
|
|
299
|
-
* hasNginxDefaultTplFile: boolean,
|
|
300
|
-
* hasRegoFile: boolean,
|
|
301
|
-
* hasVueOrCssSource: boolean,
|
|
302
|
-
* hasVueSource: boolean
|
|
303
|
-
* }} facts агреговані факти
|
|
304
|
-
* @returns {void}
|
|
305
|
-
*/
|
|
306
|
-
function updateFileFacts(fileName, relPath, facts) {
|
|
307
|
-
if (fileName === 'capacitor.config.json') {
|
|
308
|
-
facts.hasCapacitorConfig = true
|
|
309
|
-
}
|
|
310
|
-
if (fileName === 'Cargo.toml') {
|
|
311
|
-
facts.hasCargoToml = true
|
|
312
|
-
}
|
|
313
|
-
if (fileName === 'Dockerfile' || fileName.startsWith('Dockerfile.')) {
|
|
314
|
-
facts.hasDockerfile = true
|
|
315
|
-
}
|
|
316
|
-
if (NGINX_DEFAULT_FILES.has(fileName)) {
|
|
317
|
-
facts.hasNginxDefaultTplFile = true
|
|
318
|
-
}
|
|
319
|
-
if (JS_LIKE_RE.test(relPath)) {
|
|
320
|
-
facts.hasJsLikeSource = true
|
|
321
|
-
}
|
|
322
|
-
if (VUE_RE.test(relPath)) {
|
|
323
|
-
facts.hasVueSource = true
|
|
324
|
-
}
|
|
325
|
-
if (STYLE_RE.test(relPath)) {
|
|
326
|
-
facts.hasVueOrCssSource = true
|
|
327
|
-
}
|
|
328
|
-
if (REGO_RE.test(relPath)) {
|
|
329
|
-
facts.hasRegoFile = true
|
|
330
|
-
}
|
|
331
|
-
}
|
|
332
|
-
|
|
333
102
|
/**
|
|
334
103
|
* Чи потрібно сканувати файл на gql tagged template.
|
|
335
104
|
* @param {string} relPath шлях відносно кореня
|
|
@@ -407,28 +176,24 @@ async function updateHasuraFactFromFile(absPath, fileName, facts) {
|
|
|
407
176
|
}
|
|
408
177
|
|
|
409
178
|
/**
|
|
410
|
-
* Обробляє файл під час обходу
|
|
179
|
+
* Обробляє файл під час обходу дерева — оновлює content-факти, потрібні предикатам,
|
|
180
|
+
* та `hasRegoFile` (тримається для прямих читачів `collectAutoRuleFacts`).
|
|
411
181
|
* @param {string} absPath абсолютний шлях до файлу
|
|
412
182
|
* @param {string} root абсолютний шлях кореня
|
|
413
183
|
* @param {{
|
|
414
184
|
* hasBunSqlImport: boolean,
|
|
415
|
-
* hasCapacitorConfig: boolean,
|
|
416
|
-
* hasCargoToml: boolean,
|
|
417
|
-
* hasDockerfile: boolean,
|
|
418
185
|
* hasGqlTaggedTemplates: boolean,
|
|
419
186
|
* hasHasuraConfig: boolean,
|
|
420
|
-
*
|
|
421
|
-
* hasNginxDefaultTplFile: boolean,
|
|
422
|
-
* hasRegoFile: boolean,
|
|
423
|
-
* hasVueOrCssSource: boolean,
|
|
424
|
-
* hasVueSource: boolean
|
|
187
|
+
* hasRegoFile: boolean
|
|
425
188
|
* }} facts агреговані факти
|
|
426
189
|
* @returns {Promise<void>}
|
|
427
190
|
*/
|
|
428
191
|
async function processFileEntry(absPath, root, facts) {
|
|
429
192
|
const rel = relative(root, absPath).split('\\').join('/')
|
|
430
193
|
const fileName = basename(absPath)
|
|
431
|
-
|
|
194
|
+
if (REGO_RE.test(rel)) {
|
|
195
|
+
facts.hasRegoFile = true
|
|
196
|
+
}
|
|
432
197
|
if (shouldScanFileForGql(rel, facts)) {
|
|
433
198
|
await updateGqlFactFromFile(absPath, rel, facts)
|
|
434
199
|
}
|
|
@@ -439,98 +204,26 @@ async function processFileEntry(absPath, root, facts) {
|
|
|
439
204
|
}
|
|
440
205
|
|
|
441
206
|
/**
|
|
442
|
-
*
|
|
443
|
-
*
|
|
444
|
-
*
|
|
445
|
-
|
|
446
|
-
export function normalizeIdList(value) {
|
|
447
|
-
if (!Array.isArray(value)) {
|
|
448
|
-
return []
|
|
449
|
-
}
|
|
450
|
-
const out = []
|
|
451
|
-
for (const item of value) {
|
|
452
|
-
const normalized = String(item).trim().toLowerCase()
|
|
453
|
-
if (normalized && !out.includes(normalized)) {
|
|
454
|
-
out.push(normalized)
|
|
455
|
-
}
|
|
456
|
-
}
|
|
457
|
-
return out
|
|
458
|
-
}
|
|
459
|
-
|
|
460
|
-
/**
|
|
461
|
-
* Повертає URL репозиторію з package.json (`repository` може бути рядком або обʼєктом).
|
|
462
|
-
* @param {unknown} repository значення `packageJson.repository`
|
|
463
|
-
* @returns {string | null} URL або null
|
|
464
|
-
*/
|
|
465
|
-
export function getRepositoryUrl(repository) {
|
|
466
|
-
if (typeof repository === 'string') {
|
|
467
|
-
return repository
|
|
468
|
-
}
|
|
469
|
-
if (repository && typeof repository === 'object' && !Array.isArray(repository)) {
|
|
470
|
-
const url = /** @type {Record<string, unknown>} */ (repository).url
|
|
471
|
-
if (typeof url === 'string') {
|
|
472
|
-
return url
|
|
473
|
-
}
|
|
474
|
-
}
|
|
475
|
-
return null
|
|
476
|
-
}
|
|
477
|
-
|
|
478
|
-
/**
|
|
479
|
-
* Чи package.json виглядає як монорепо (поле `workspaces`).
|
|
480
|
-
* @param {unknown} packageJson кореневий package.json як JS-обʼєкт
|
|
481
|
-
* @returns {boolean} true, якщо оголошено workspaces
|
|
482
|
-
*/
|
|
483
|
-
export function isMonorepoPackage(packageJson) {
|
|
484
|
-
if (packageJson === null || typeof packageJson !== 'object' || Array.isArray(packageJson)) {
|
|
485
|
-
return false
|
|
486
|
-
}
|
|
487
|
-
const workspaces = /** @type {Record<string, unknown>} */ (packageJson).workspaces
|
|
488
|
-
if (Array.isArray(workspaces)) {
|
|
489
|
-
return workspaces.length > 0
|
|
490
|
-
}
|
|
491
|
-
if (workspaces && typeof workspaces === 'object' && !Array.isArray(workspaces)) {
|
|
492
|
-
const packages = /** @type {Record<string, unknown>} */ (workspaces).packages
|
|
493
|
-
return Array.isArray(packages) && packages.length > 0
|
|
494
|
-
}
|
|
495
|
-
return false
|
|
496
|
-
}
|
|
497
|
-
|
|
498
|
-
/**
|
|
499
|
-
* Обходить дерево проєкту, збираючи факти для автоувімкнення правил.
|
|
207
|
+
* Обходить дерево проєкту, збираючи content-факти для предикатів автоувімкнення.
|
|
208
|
+
*
|
|
209
|
+
* `hasRegoFile` і `hasTempoDir` лишаються для зворотної сумісності з прямими читачами
|
|
210
|
+
* фактів (тести, зовнішній код); саме автоувімкнення тепер data-driven через meta.json.
|
|
500
211
|
* @param {string} root абсолютний шлях кореня репозиторію
|
|
501
212
|
* @returns {Promise<{
|
|
502
|
-
* hasCapacitorConfig: boolean,
|
|
503
|
-
* hasCargoToml: boolean,
|
|
504
|
-
* hasDockerfile: boolean,
|
|
505
|
-
* hasGaWorkflowsDir: boolean,
|
|
506
213
|
* hasBunSqlImport: boolean,
|
|
507
214
|
* hasGqlTaggedTemplates: boolean,
|
|
508
215
|
* hasHasuraConfig: boolean,
|
|
509
|
-
* hasJsLikeSource: boolean,
|
|
510
|
-
* hasK8sDir: boolean,
|
|
511
|
-
* hasNginxDefaultTplFile: boolean,
|
|
512
216
|
* hasRegoFile: boolean,
|
|
513
|
-
* hasTempoDir: boolean
|
|
514
|
-
* hasVueSource: boolean,
|
|
515
|
-
* hasVueOrCssSource: boolean
|
|
217
|
+
* hasTempoDir: boolean
|
|
516
218
|
* }>} агреговані факти
|
|
517
219
|
*/
|
|
518
220
|
export async function collectAutoRuleFacts(root) {
|
|
519
221
|
const facts = {
|
|
520
222
|
hasBunSqlImport: false,
|
|
521
|
-
hasCapacitorConfig: false,
|
|
522
|
-
hasCargoToml: false,
|
|
523
|
-
hasDockerfile: false,
|
|
524
|
-
hasGaWorkflowsDir: existsSync(join(root, '.github', 'workflows')),
|
|
525
223
|
hasGqlTaggedTemplates: false,
|
|
526
224
|
hasHasuraConfig: false,
|
|
527
|
-
hasJsLikeSource: false,
|
|
528
|
-
hasK8sDir: false,
|
|
529
|
-
hasNginxDefaultTplFile: false,
|
|
530
225
|
hasRegoFile: false,
|
|
531
|
-
hasTempoDir: false
|
|
532
|
-
hasVueSource: false,
|
|
533
|
-
hasVueOrCssSource: false
|
|
226
|
+
hasTempoDir: false
|
|
534
227
|
}
|
|
535
228
|
|
|
536
229
|
/**
|
|
@@ -549,9 +242,10 @@ export async function collectAutoRuleFacts(root) {
|
|
|
549
242
|
for (const entry of entries) {
|
|
550
243
|
const absPath = join(dir, entry.name)
|
|
551
244
|
if (entry.isDirectory()) {
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
245
|
+
if (!IGNORED_DIR_NAMES.has(entry.name)) {
|
|
246
|
+
if (entry.name === 'tempo') {
|
|
247
|
+
facts.hasTempoDir = true
|
|
248
|
+
}
|
|
555
249
|
await walk(absPath)
|
|
556
250
|
}
|
|
557
251
|
} else if (entry.isFile()) {
|
|
@@ -564,6 +258,46 @@ export async function collectAutoRuleFacts(root) {
|
|
|
564
258
|
return facts
|
|
565
259
|
}
|
|
566
260
|
|
|
261
|
+
/**
|
|
262
|
+
* Збирає relative-posix шляхи дерева (і файли, і каталоги) для glob-матчингу Type A.
|
|
263
|
+
*
|
|
264
|
+
* Каталоги теж потрапляють у вихід, бо частина glob-специфікацій вказує на самі директорії
|
|
265
|
+
* (наприклад `npm`, `k8s`, `.github/workflows`), які можуть бути порожніми — без цього
|
|
266
|
+
* правила npm-module/k8s/ga не активувалися б на дереві без файлів усередині.
|
|
267
|
+
* @param {string} root корінь репо
|
|
268
|
+
* @returns {Promise<string[]>} шляхи відносно root у posix-форматі
|
|
269
|
+
*/
|
|
270
|
+
async function collectRepoPaths(root) {
|
|
271
|
+
/** @type {string[]} */
|
|
272
|
+
const out = []
|
|
273
|
+
/**
|
|
274
|
+
* Рекурсивний обхід каталогу з пропуском службових директорій.
|
|
275
|
+
* @param {string} dir каталог
|
|
276
|
+
* @returns {Promise<void>}
|
|
277
|
+
*/
|
|
278
|
+
async function walk(dir) {
|
|
279
|
+
let entries
|
|
280
|
+
try {
|
|
281
|
+
entries = await readdir(dir, { withFileTypes: true })
|
|
282
|
+
} catch {
|
|
283
|
+
return
|
|
284
|
+
}
|
|
285
|
+
for (const entry of entries) {
|
|
286
|
+
const abs = join(dir, entry.name)
|
|
287
|
+
if (entry.isDirectory()) {
|
|
288
|
+
if (!IGNORED_DIR_NAMES.has(entry.name)) {
|
|
289
|
+
out.push(relative(root, abs).split('\\').join('/'))
|
|
290
|
+
await walk(abs)
|
|
291
|
+
}
|
|
292
|
+
} else if (entry.isFile()) {
|
|
293
|
+
out.push(relative(root, abs).split('\\').join('/'))
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
await walk(root)
|
|
298
|
+
return out
|
|
299
|
+
}
|
|
300
|
+
|
|
567
301
|
/**
|
|
568
302
|
* Транзитивно розгортає правила за `AUTO_RULE_DEPENDENCIES`: повторно проходить
|
|
569
303
|
* усіма парами «правило → залежності» доки на одному з проходів не зʼявляється
|
|
@@ -589,7 +323,36 @@ function resolveRuleDependencies(detectedRules, addRule) {
|
|
|
589
323
|
}
|
|
590
324
|
|
|
591
325
|
/**
|
|
592
|
-
*
|
|
326
|
+
* Чи активується правило за його spec.
|
|
327
|
+
*
|
|
328
|
+
* Диспетчинг предикатів за іменем (сигнатури неоднорідні — див. `rule-predicates.mjs`):
|
|
329
|
+
* - `repoUrlMarker` читає кореневий `package.json` + маркер-arg;
|
|
330
|
+
* - `gqlTaggedTemplate`, `hasuraConfigMarker` читають content-`facts`;
|
|
331
|
+
* - `jsBunDbSignal` бере `(root, facts)`;
|
|
332
|
+
* - решта (`depInAnyPackageJson`, `nestedPackageWithoutVite`) — `(root, arg)`.
|
|
333
|
+
* @param {import('./lib/rule-meta.mjs').RuleAutoSpec} spec нормалізований auto
|
|
334
|
+
* @param {{root:string, facts:object, paths:string[], packageJsonParsed:unknown}} ctx контекст
|
|
335
|
+
* @returns {Promise<boolean>} true, якщо правило активне
|
|
336
|
+
*/
|
|
337
|
+
async function specMatches(spec, ctx) {
|
|
338
|
+
if ('always' in spec) return true
|
|
339
|
+
if ('glob' in spec) {
|
|
340
|
+
const res = spec.glob.map(g => globToRegex(g))
|
|
341
|
+
return ctx.paths.some(p => res.some(re => re.test(p)))
|
|
342
|
+
}
|
|
343
|
+
if ('predicate' in spec) {
|
|
344
|
+
const fn = RULE_PREDICATES[spec.predicate]
|
|
345
|
+
if (!fn) return false
|
|
346
|
+
if (spec.predicate === 'repoUrlMarker') return fn(ctx.packageJsonParsed, spec.arg)
|
|
347
|
+
if (spec.predicate === 'gqlTaggedTemplate' || spec.predicate === 'hasuraConfigMarker') return fn(ctx.facts)
|
|
348
|
+
if (spec.predicate === 'jsBunDbSignal') return fn(ctx.root, ctx.facts)
|
|
349
|
+
return fn(ctx.root, spec.arg)
|
|
350
|
+
}
|
|
351
|
+
return false
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Визначає авто-правила згідно з `rules/<rule>/meta.json`.
|
|
593
356
|
* @param {object} params параметри аналізу
|
|
594
357
|
* @param {string} params.root абсолютний шлях до кореня репозиторію
|
|
595
358
|
* @param {string[]} params.availableRules перелік доступних правил з пакету
|
|
@@ -597,92 +360,31 @@ function resolveRuleDependencies(detectedRules, addRule) {
|
|
|
597
360
|
* @param {string[]} [params.disableRules] список `disable-rules` з конфігу
|
|
598
361
|
* @returns {Promise<{ rules: string[] }>} список id у стабільному порядку (за `AUTO_RULE_ORDER`)
|
|
599
362
|
*/
|
|
600
|
-
export async function detectAutoRules({
|
|
601
|
-
root,
|
|
602
|
-
availableRules,
|
|
603
|
-
packageJsonParsed,
|
|
604
|
-
disableRules = DEFAULT_DISABLED_LIST
|
|
605
|
-
}) {
|
|
363
|
+
export async function detectAutoRules({ root, availableRules, packageJsonParsed, disableRules = DEFAULT_DISABLED_LIST }) {
|
|
606
364
|
const facts = await collectAutoRuleFacts(root)
|
|
365
|
+
const paths = await collectRepoPaths(root)
|
|
607
366
|
const normalizedRules = new Set(availableRules.map(r => r.trim().toLowerCase()))
|
|
608
367
|
const disableRulesSet = new Set(disableRules)
|
|
609
368
|
|
|
610
|
-
const packageJsonExists = existsSync(join(root, 'package.json'))
|
|
611
|
-
const npmDirExists = existsSync(join(root, 'npm'))
|
|
612
|
-
const composerJsonExists = existsSync(join(root, 'composer.json'))
|
|
613
|
-
const repositoryUrl = getRepositoryUrl(
|
|
614
|
-
packageJsonParsed && typeof packageJsonParsed === 'object' && !Array.isArray(packageJsonParsed)
|
|
615
|
-
? /** @type {Record<string, unknown>} */ (packageJsonParsed).repository
|
|
616
|
-
: null
|
|
617
|
-
)
|
|
618
|
-
const isAbie = typeof repositoryUrl === 'string' && repositoryUrl.toLowerCase().includes(ABIE_REPOSITORY_URL_MARKER)
|
|
619
|
-
const isEfes = typeof repositoryUrl === 'string' && repositoryUrl.toLowerCase().includes(EFES_REPOSITORY_URL_MARKER)
|
|
620
|
-
const depHits = await collectDependencyKeysPresentInPackageJsonTree(root, [
|
|
621
|
-
'mssql',
|
|
622
|
-
'pg',
|
|
623
|
-
'pg-format',
|
|
624
|
-
'mysql2',
|
|
625
|
-
'ioredis',
|
|
626
|
-
'node-redis'
|
|
627
|
-
])
|
|
628
|
-
const hasMssqlDependency = depHits.has('mssql')
|
|
629
|
-
const hasJsBunDbSignal =
|
|
630
|
-
depHits.has('pg') || depHits.has('pg-format') || depHits.has('mysql2') || facts.hasBunSqlImport
|
|
631
|
-
const hasJsBunRedisSignal = depHits.has('ioredis') || depHits.has('node-redis')
|
|
632
|
-
const hasNestedNodePackage = await hasNestedPackageJsonWithoutViteDevDependency(root)
|
|
633
|
-
|
|
634
369
|
/** @type {string[]} */
|
|
635
370
|
const detectedRules = []
|
|
636
|
-
|
|
637
371
|
/**
|
|
638
372
|
* Додає правило до результату, якщо воно доступне і не в disable-списку.
|
|
639
373
|
* @param {string} ruleId id правила
|
|
640
374
|
* @returns {void}
|
|
641
375
|
*/
|
|
642
376
|
function addRule(ruleId) {
|
|
643
|
-
if (!normalizedRules.has(ruleId) || disableRulesSet.has(ruleId) || detectedRules.includes(ruleId))
|
|
644
|
-
return
|
|
645
|
-
}
|
|
377
|
+
if (!normalizedRules.has(ruleId) || disableRulesSet.has(ruleId) || detectedRules.includes(ruleId)) return
|
|
646
378
|
detectedRules.push(ruleId)
|
|
647
379
|
}
|
|
648
380
|
|
|
649
|
-
const
|
|
650
|
-
|
|
651
|
-
{
|
|
652
|
-
{ enabled: facts.hasCapacitorConfig, id: 'capacitor' },
|
|
653
|
-
{ enabled: facts.hasDockerfile, id: 'docker' },
|
|
654
|
-
{ enabled: isEfes, id: 'efes' },
|
|
655
|
-
{ enabled: facts.hasGaWorkflowsDir, id: 'ga' },
|
|
656
|
-
{ enabled: facts.hasGqlTaggedTemplates, id: 'graphql' },
|
|
657
|
-
{ enabled: facts.hasHasuraConfig, id: 'hasura' },
|
|
658
|
-
{ enabled: facts.hasJsLikeSource, id: 'js-lint' },
|
|
659
|
-
{ enabled: hasMssqlDependency, id: 'js-mssql' },
|
|
660
|
-
{ enabled: hasJsBunDbSignal, id: 'js-bun-db' },
|
|
661
|
-
{ enabled: hasJsBunRedisSignal, id: 'js-bun-redis' },
|
|
662
|
-
{ enabled: hasNestedNodePackage, id: 'js-run' },
|
|
663
|
-
{ enabled: facts.hasK8sDir, id: 'k8s' },
|
|
664
|
-
{ enabled: facts.hasNginxDefaultTplFile, id: 'nginx-default-tpl' },
|
|
665
|
-
{ enabled: npmDirExists, id: 'npm-module' },
|
|
666
|
-
{ enabled: composerJsonExists, id: 'php' },
|
|
667
|
-
{ enabled: facts.hasRegoFile, id: 'rego' },
|
|
668
|
-
{ enabled: facts.hasCargoToml, id: 'rust' },
|
|
669
|
-
{ enabled: facts.hasVueOrCssSource, id: 'style-lint' }
|
|
670
|
-
]
|
|
671
|
-
for (const item of autoRuleChecks) {
|
|
672
|
-
if (item.enabled) {
|
|
673
|
-
addRule(item.id)
|
|
674
|
-
}
|
|
675
|
-
}
|
|
676
|
-
addRule('adr')
|
|
677
|
-
addRule('security')
|
|
678
|
-
addRule('test')
|
|
679
|
-
addRule('text')
|
|
680
|
-
if (facts.hasVueSource) {
|
|
681
|
-
addRule('vue')
|
|
381
|
+
for (const [ruleId, spec] of Object.entries(RULE_AUTO_ACTIVATION)) {
|
|
382
|
+
if ('rules' in spec) continue
|
|
383
|
+
if (await specMatches(spec, { root, facts, paths, packageJsonParsed })) addRule(ruleId)
|
|
682
384
|
}
|
|
683
385
|
resolveRuleDependencies(detectedRules, addRule)
|
|
684
386
|
|
|
685
|
-
const rules = AUTO_RULE_ORDER.filter(
|
|
387
|
+
const rules = AUTO_RULE_ORDER.filter(r => detectedRules.includes(r))
|
|
686
388
|
return { rules }
|
|
687
389
|
}
|
|
688
390
|
|