@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.
Files changed (79) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +6 -0
  3. package/bin/n-cursor.js +7 -1
  4. package/package.json +1 -1
  5. package/rules/abie/meta.json +1 -0
  6. package/rules/adr/meta.json +1 -0
  7. package/rules/bun/meta.json +1 -0
  8. package/rules/capacitor/meta.json +1 -0
  9. package/rules/changelog/meta.json +1 -0
  10. package/rules/ci4/meta.json +1 -0
  11. package/rules/docker/meta.json +1 -0
  12. package/rules/efes/meta.json +1 -0
  13. package/rules/feedback/meta.json +1 -0
  14. package/rules/ga/meta.json +1 -0
  15. package/rules/graphql/meta.json +1 -0
  16. package/rules/hasura/meta.json +1 -0
  17. package/rules/image-avif/meta.json +1 -0
  18. package/rules/image-compress/meta.json +1 -0
  19. package/rules/js-bun-db/meta.json +1 -0
  20. package/rules/js-bun-redis/meta.json +1 -0
  21. package/rules/js-lint/meta.json +1 -0
  22. package/rules/js-mssql/meta.json +1 -0
  23. package/rules/js-run/meta.json +1 -0
  24. package/rules/k8s/meta.json +1 -0
  25. package/rules/nginx-default-tpl/meta.json +1 -0
  26. package/rules/npm-module/js/rule_meta.mjs +63 -0
  27. package/rules/npm-module/meta.json +1 -0
  28. package/rules/php/meta.json +1 -0
  29. package/rules/rego/meta.json +1 -0
  30. package/rules/release/meta.json +1 -0
  31. package/rules/rust/meta.json +1 -0
  32. package/rules/security/meta.json +1 -0
  33. package/rules/style-lint/meta.json +1 -0
  34. package/rules/tauri/meta.json +1 -0
  35. package/rules/test/meta.json +1 -0
  36. package/rules/text/meta.json +1 -0
  37. package/rules/vue/meta.json +1 -0
  38. package/rules/worktree/fix.mjs +19 -0
  39. package/rules/worktree/meta.json +1 -0
  40. package/rules/worktree/worktree.mdc +34 -0
  41. package/schemas/rule-meta.json +39 -0
  42. package/schemas/v8r-catalog.json +5 -0
  43. package/scripts/auto-rules.mjs +151 -449
  44. package/scripts/lib/rule-meta-helpers.mjs +103 -0
  45. package/scripts/lib/rule-meta.mjs +66 -0
  46. package/scripts/lib/rule-predicates.mjs +147 -0
  47. package/scripts/lib/worktree.mjs +73 -0
  48. package/scripts/worktree-cli.mjs +200 -0
  49. package/skills/worktree/SKILL.md +38 -0
  50. package/skills/worktree/meta.json +1 -0
  51. package/rules/abie/auto.md +0 -1
  52. package/rules/adr/auto.md +0 -1
  53. package/rules/bun/auto.md +0 -1
  54. package/rules/capacitor/auto.md +0 -1
  55. package/rules/changelog/auto.md +0 -1
  56. package/rules/docker/auto.md +0 -1
  57. package/rules/efes/auto.md +0 -1
  58. package/rules/ga/auto.md +0 -1
  59. package/rules/graphql/auto.md +0 -1
  60. package/rules/hasura/auto.md +0 -1
  61. package/rules/image-avif/auto.md +0 -1
  62. package/rules/image-compress/auto.md +0 -1
  63. package/rules/js-bun-db/auto.md +0 -1
  64. package/rules/js-bun-redis/auto.md +0 -1
  65. package/rules/js-lint/auto.md +0 -1
  66. package/rules/js-mssql/auto.md +0 -1
  67. package/rules/js-run/auto.md +0 -1
  68. package/rules/k8s/auto.md +0 -1
  69. package/rules/nginx-default-tpl/auto.md +0 -1
  70. package/rules/npm-module/auto.md +0 -1
  71. package/rules/php/auto.md +0 -1
  72. package/rules/rego/auto.md +0 -1
  73. package/rules/rust/auto.md +0 -1
  74. package/rules/security/auto.md +0 -1
  75. package/rules/style-lint/auto.md +0 -1
  76. package/rules/tauri/auto.md +0 -1
  77. package/rules/test/auto.md +0 -1
  78. package/rules/text/auto.md +0 -1
  79. package/rules/vue/auto.md +0 -1
@@ -1,21 +1,26 @@
1
1
  /**
2
- * Автовизначення правил для `.n-cursor.json` за умовами з `npm/rules/<rule>/auto.md`.
2
+ * Автовизначення правил для `.n-cursor.json` за meta-даними з `npm/rules/<id>/meta.json`.
3
3
  *
4
- * Модуль аналізує дерево проєкту (наявність файлів/директорій, `gql\`...\`` у source,
5
- * залежності `mssql` / `pg` / `pg-format` / `mysql2` / `ioredis` / `node-redis` у `package.json`,
6
- * імпорт `sql`/`SQL` з `bun`, кореневий `package.json`, `config.yaml` з рядком
7
- * `metadata_directory: metadata` для hasura) та повертає ідентифікатори правил, які потрібно автододати.
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>/auto.md`).
14
+ * Автодетект скілів — у `./auto-skills.mjs` (умови — у `npm/skills/<skill>/meta.json`).
12
15
  * `mergeConfigWithAutoDetected` нижче приймає вже виявлені rules і skills і вливає
13
16
  * їх у конфіг із поправкою на legacy-id (`migrateRuleIds`).
14
17
  */
15
- import { existsSync } from 'node:fs'
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
- /** Порядок автододавання правил відповідно до `rules/<rule>/auto.md`. */
28
- export const AUTO_RULE_ORDER = Object.freeze([
29
- 'abie',
30
- 'adr',
31
- 'bun',
32
- 'capacitor',
33
- 'changelog',
34
- 'docker',
35
- 'efes',
36
- 'ga',
37
- 'graphql',
38
- 'hasura',
39
- 'image-avif',
40
- 'image-compress',
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
- * Карта міграції застарілих rule-id у `.n-cursor.json` на актуальні.
61
- * Застосовується автоматично при читанні конфігу (як для `rules`, так і для `disable-rules`).
62
- * Приклад: `image` → `image-compress` + `image-avif` (правило розщеплене у 1.8.197).
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 const RULE_MIGRATIONS = Object.freeze(
65
- /** @type {Record<string, readonly string[]>} */ ({
66
- image: Object.freeze(['image-compress', 'image-avif'])
67
- })
68
- )
69
-
70
- /**
71
- * Розгортає застарілі rule-id у списку згідно з `RULE_MIGRATIONS`. Зберігає порядок,
72
- * дедуплікує. Чистий хелпер: не мутує вхід, не логує.
73
- * @param {string[]} ids нормалізований список id (як з `normalizeIdList`)
74
- * @returns {string[]} список з legacy-id, заміненими на нові; решта без змін
75
- */
76
- export function migrateRuleIds(ids) {
77
- /** @type {string[]} */
78
- const out = []
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
- * Граф залежностей між правилами (`rules/<rule>/auto.md` синтаксис `rule - [other]`).
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
- /** @type {Record<string, readonly string[]>} */ ({
105
- changelog: Object.freeze(['bun']),
106
- 'image-avif': Object.freeze(['vue', 'image-compress']),
107
- 'image-compress': Object.freeze(['bun'])
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
- * hasJsLikeSource: boolean,
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
- updateFileFacts(fileName, rel, facts)
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
- * Нормалізує список ідентифікаторів (trim + lowercase + унікальність збереженням порядку).
443
- * @param {unknown} value вихідне значення з `.n-cursor.json`
444
- * @returns {string[]} масив id у нормалізованому вигляді
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
- const isIgnoredDir = IGNORED_DIR_NAMES.has(entry.name)
553
- if (!isIgnoredDir) {
554
- updateDirFacts(entry.name, facts)
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
- * Визначає авто-правила згідно з `rules/<rule>/auto.md`.
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 autoRuleChecks = [
650
- { enabled: isAbie, id: 'abie' },
651
- { enabled: packageJsonExists, id: 'bun' },
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(ruleId => detectedRules.includes(ruleId))
387
+ const rules = AUTO_RULE_ORDER.filter(r => detectedRules.includes(r))
686
388
  return { rules }
687
389
  }
688
390