@nitra/cursor 1.8.103 → 1.8.105

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.
@@ -27,6 +27,22 @@
27
27
  "minLength": 1
28
28
  }
29
29
  },
30
+ "disable-rules": {
31
+ "type": "array",
32
+ "description": "Список rule id (без префікса n-), які не потрібно автододавати в `.n-cursor.json` під час запуску CLI.",
33
+ "items": {
34
+ "type": "string",
35
+ "minLength": 1
36
+ }
37
+ },
38
+ "disable-skills": {
39
+ "type": "array",
40
+ "description": "Список skill id (без префікса n-), які не потрібно автододавати в `.n-cursor.json` під час запуску CLI.",
41
+ "items": {
42
+ "type": "string",
43
+ "minLength": 1
44
+ }
45
+ },
30
46
  "version": {
31
47
  "type": "string",
32
48
  "description": "Застаріле поле, ігнорується CLI. Правила завжди копіюються з каталогу mdc/ установленого пакету (node_modules або кеш npx); змініть версію через оновлення залежності."
@@ -0,0 +1,404 @@
1
+ /**
2
+ * Автовизначення правил і skills для `.n-cursor.json` за умовами з `npm/bin/auto-rules.md`.
3
+ *
4
+ * Модуль аналізує дерево проєкту (наявність файлів/директорій, `gql\`...\`` у source, кореневий
5
+ * `package.json`) та повертає ідентифікатори правил і skills, які потрібно автододати.
6
+ *
7
+ * Також враховує винятки `disable-rules` і `disable-skills`: елементи з цих списків не
8
+ * додаються автоматично.
9
+ */
10
+ import { existsSync } from 'node:fs'
11
+ import { readdir, readFile } from 'node:fs/promises'
12
+ import { basename, join, relative } from 'node:path'
13
+
14
+ import {
15
+ isGqlScanSourceFile,
16
+ shouldSkipFileForGqlScan,
17
+ sourceFileHasGqlTaggedTemplate
18
+ } from './utils/graphql-gql-scan.mjs'
19
+
20
+ /** Порядок автододавання правил відповідно до `auto-rules.md`. */
21
+ export const AUTO_RULE_ORDER = Object.freeze([
22
+ 'abie',
23
+ 'bun',
24
+ 'docker',
25
+ 'ga',
26
+ 'graphql',
27
+ 'js-lint',
28
+ 'js-pino',
29
+ 'k8s',
30
+ 'nginx-default-tpl',
31
+ 'npm-module',
32
+ 'style-lint',
33
+ 'text',
34
+ 'vue'
35
+ ])
36
+
37
+ /** Порядок автододавання skills відповідно до `auto-rules.md`. */
38
+ export const AUTO_SKILL_ORDER = Object.freeze(['abie-kustomize', 'fix', 'lint'])
39
+
40
+ const ABIE_REPOSITORY_URL_MARKER = 'https://github.com/abinbevefes/'
41
+ const JS_LIKE_RE = /\.(?:mjs|cjs|js|jsx|ts|tsx)$/iu
42
+ const STYLE_RE = /\.(?:css|vue)$/iu
43
+ const VUE_RE = /\.vue$/iu
44
+ const NGINX_DEFAULT_FILES = new Set(['default.conf.template', 'default.conf', 'nginx.conf'])
45
+ const IGNORED_DIR_NAMES = new Set(['node_modules', '.git', '.next', '.turbo'])
46
+ const DEFAULT_DISABLED_LIST = Object.freeze([])
47
+
48
+ /**
49
+ * Фіксує ознаки, що залежать лише від імені підкаталогу.
50
+ * @param {string} dirName імʼя каталогу
51
+ * @param {{
52
+ * hasK8sDir: boolean,
53
+ * hasTempoDir: boolean
54
+ * }} facts агреговані факти
55
+ * @returns {void}
56
+ */
57
+ function updateDirFacts(dirName, facts) {
58
+ if (dirName === 'k8s') {
59
+ facts.hasK8sDir = true
60
+ }
61
+ if (dirName === 'tempo') {
62
+ facts.hasTempoDir = true
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Фіксує ознаки, що визначаються за шляхом/іменем файлу.
68
+ * @param {string} fileName базове імʼя файлу
69
+ * @param {string} relPath шлях відносно кореня
70
+ * @param {{
71
+ * hasDockerfile: boolean,
72
+ * hasJsLikeSource: boolean,
73
+ * hasNginxDefaultTplFile: boolean,
74
+ * hasVueOrCssSource: boolean,
75
+ * hasVueSource: boolean
76
+ * }} facts агреговані факти
77
+ * @returns {void}
78
+ */
79
+ function updateFileFacts(fileName, relPath, facts) {
80
+ if (fileName === 'Dockerfile' || fileName.startsWith('Dockerfile.')) {
81
+ facts.hasDockerfile = true
82
+ }
83
+ if (NGINX_DEFAULT_FILES.has(fileName)) {
84
+ facts.hasNginxDefaultTplFile = true
85
+ }
86
+ if (JS_LIKE_RE.test(relPath)) {
87
+ facts.hasJsLikeSource = true
88
+ }
89
+ if (VUE_RE.test(relPath)) {
90
+ facts.hasVueSource = true
91
+ }
92
+ if (STYLE_RE.test(relPath)) {
93
+ facts.hasVueOrCssSource = true
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Чи потрібно сканувати файл на gql tagged template.
99
+ * @param {string} relPath шлях відносно кореня
100
+ * @param {{ hasGqlTaggedTemplates: boolean }} facts агреговані факти
101
+ * @returns {boolean} true, якщо файл варто сканувати
102
+ */
103
+ function shouldScanFileForGql(relPath, facts) {
104
+ return !facts.hasGqlTaggedTemplates && isGqlScanSourceFile(relPath) && !shouldSkipFileForGqlScan(relPath)
105
+ }
106
+
107
+ /**
108
+ * Оновлює ознаку `hasGqlTaggedTemplates` за вмістом конкретного файлу.
109
+ * @param {string} absPath абсолютний шлях до файлу
110
+ * @param {string} relPath шлях відносно кореня
111
+ * @param {{ hasGqlTaggedTemplates: boolean }} facts агреговані факти
112
+ * @returns {Promise<void>}
113
+ */
114
+ async function updateGqlFactFromFile(absPath, relPath, facts) {
115
+ try {
116
+ const content = await readFile(absPath, 'utf8')
117
+ if (sourceFileHasGqlTaggedTemplate(content, relPath)) {
118
+ facts.hasGqlTaggedTemplates = true
119
+ }
120
+ } catch {
121
+ /* ігноруємо пошкоджені/недоступні файли */
122
+ }
123
+ }
124
+
125
+ /**
126
+ * Обробляє файл під час обходу дерева.
127
+ * @param {string} absPath абсолютний шлях до файлу
128
+ * @param {string} root абсолютний шлях кореня
129
+ * @param {{
130
+ * hasDockerfile: boolean,
131
+ * hasGqlTaggedTemplates: boolean,
132
+ * hasJsLikeSource: boolean,
133
+ * hasNginxDefaultTplFile: boolean,
134
+ * hasVueOrCssSource: boolean,
135
+ * hasVueSource: boolean
136
+ * }} facts агреговані факти
137
+ * @returns {Promise<void>}
138
+ */
139
+ async function processFileEntry(absPath, root, facts) {
140
+ const rel = relative(root, absPath).split('\\').join('/')
141
+ const fileName = basename(absPath)
142
+ updateFileFacts(fileName, rel, facts)
143
+ if (shouldScanFileForGql(rel, facts)) {
144
+ await updateGqlFactFromFile(absPath, rel, facts)
145
+ }
146
+ }
147
+
148
+ /**
149
+ * Нормалізує список ідентифікаторів (trim + lowercase + унікальність збереженням порядку).
150
+ * @param {unknown} value вихідне значення з `.n-cursor.json`
151
+ * @returns {string[]} масив id у нормалізованому вигляді
152
+ */
153
+ export function normalizeIdList(value) {
154
+ if (!Array.isArray(value)) {
155
+ return []
156
+ }
157
+ const out = []
158
+ for (const item of value) {
159
+ const normalized = String(item).trim().toLowerCase()
160
+ if (normalized && !out.includes(normalized)) {
161
+ out.push(normalized)
162
+ }
163
+ }
164
+ return out
165
+ }
166
+
167
+ /**
168
+ * Повертає URL репозиторію з package.json (`repository` може бути рядком або обʼєктом).
169
+ * @param {unknown} repository значення `packageJson.repository`
170
+ * @returns {string | null} URL або null
171
+ */
172
+ export function getRepositoryUrl(repository) {
173
+ if (typeof repository === 'string') {
174
+ return repository
175
+ }
176
+ if (repository && typeof repository === 'object' && !Array.isArray(repository)) {
177
+ const url = /** @type {Record<string, unknown>} */ (repository).url
178
+ if (typeof url === 'string') {
179
+ return url
180
+ }
181
+ }
182
+ return null
183
+ }
184
+
185
+ /**
186
+ * Чи package.json виглядає як монорепо (поле `workspaces`).
187
+ * @param {unknown} packageJson кореневий package.json як JS-обʼєкт
188
+ * @returns {boolean} true, якщо оголошено workspaces
189
+ */
190
+ export function isMonorepoPackage(packageJson) {
191
+ if (packageJson === null || typeof packageJson !== 'object' || Array.isArray(packageJson)) {
192
+ return false
193
+ }
194
+ const workspaces = /** @type {Record<string, unknown>} */ (packageJson).workspaces
195
+ if (Array.isArray(workspaces)) {
196
+ return workspaces.length > 0
197
+ }
198
+ if (workspaces && typeof workspaces === 'object' && !Array.isArray(workspaces)) {
199
+ const packages = /** @type {Record<string, unknown>} */ (workspaces).packages
200
+ return Array.isArray(packages) && packages.length > 0
201
+ }
202
+ return false
203
+ }
204
+
205
+ /**
206
+ * Обходить дерево проєкту, збираючи факти для автоувімкнення правил.
207
+ * @param {string} root абсолютний шлях кореня репозиторію
208
+ * @returns {Promise<{
209
+ * hasDockerfile: boolean,
210
+ * hasGaWorkflowsDir: boolean,
211
+ * hasGqlTaggedTemplates: boolean,
212
+ * hasJsLikeSource: boolean,
213
+ * hasK8sDir: boolean,
214
+ * hasNginxDefaultTplFile: boolean,
215
+ * hasTempoDir: boolean,
216
+ * hasVueSource: boolean,
217
+ * hasVueOrCssSource: boolean
218
+ * }>} агреговані факти
219
+ */
220
+ export async function collectAutoRuleFacts(root) {
221
+ const facts = {
222
+ hasDockerfile: false,
223
+ hasGaWorkflowsDir: existsSync(join(root, '.github', 'workflows')),
224
+ hasGqlTaggedTemplates: false,
225
+ hasJsLikeSource: false,
226
+ hasK8sDir: false,
227
+ hasNginxDefaultTplFile: false,
228
+ hasTempoDir: false,
229
+ hasVueSource: false,
230
+ hasVueOrCssSource: false
231
+ }
232
+
233
+ /**
234
+ * Рекурсивний обхід каталогу з пропуском службових директорій.
235
+ * @param {string} dir абсолютний шлях каталогу
236
+ * @returns {Promise<void>}
237
+ */
238
+ async function walk(dir) {
239
+ let entries
240
+ try {
241
+ entries = await readdir(dir, { withFileTypes: true })
242
+ } catch {
243
+ return
244
+ }
245
+
246
+ for (const entry of entries) {
247
+ const absPath = join(dir, entry.name)
248
+ if (entry.isDirectory()) {
249
+ const isIgnoredDir = IGNORED_DIR_NAMES.has(entry.name)
250
+ if (!isIgnoredDir) {
251
+ updateDirFacts(entry.name, facts)
252
+ await walk(absPath)
253
+ }
254
+ } else if (entry.isFile()) {
255
+ await processFileEntry(absPath, root, facts)
256
+ }
257
+ }
258
+ }
259
+
260
+ await walk(root)
261
+ return facts
262
+ }
263
+
264
+ /**
265
+ * Визначає авто-правила та skills згідно з `auto-rules.md`.
266
+ * @param {object} params параметри аналізу
267
+ * @param {string} params.root абсолютний шлях до кореня репозиторію
268
+ * @param {string[]} params.availableRules перелік доступних правил з пакету
269
+ * @param {string[]} params.availableSkills перелік доступних skills з пакету
270
+ * @param {unknown} params.packageJsonParsed кореневий package.json (розпарсений) або null
271
+ * @param {string[]} [params.disableRules] список `disable-rules` з конфігу
272
+ * @param {string[]} [params.disableSkills] список `disable-skills` з конфігу
273
+ * @returns {Promise<{ rules: string[], skills: string[] }>} списки id у стабільному порядку
274
+ */
275
+ export async function detectAutoRulesAndSkills({
276
+ root,
277
+ availableRules,
278
+ availableSkills,
279
+ packageJsonParsed,
280
+ disableRules = DEFAULT_DISABLED_LIST,
281
+ disableSkills = DEFAULT_DISABLED_LIST
282
+ }) {
283
+ const facts = await collectAutoRuleFacts(root)
284
+ const normalizedRules = new Set(availableRules.map(r => r.trim().toLowerCase()))
285
+ const normalizedSkills = new Set(availableSkills.map(s => s.trim().toLowerCase()))
286
+ const disableRulesSet = new Set(disableRules)
287
+ const disableSkillsSet = new Set(disableSkills)
288
+
289
+ const packageJsonExists = existsSync(join(root, 'package.json'))
290
+ const npmDirExists = existsSync(join(root, 'npm'))
291
+ const repositoryUrl = getRepositoryUrl(
292
+ packageJsonParsed && typeof packageJsonParsed === 'object' && !Array.isArray(packageJsonParsed)
293
+ ? /** @type {Record<string, unknown>} */ (packageJsonParsed).repository
294
+ : null
295
+ )
296
+ const isAbie = typeof repositoryUrl === 'string' && repositoryUrl.toLowerCase().includes(ABIE_REPOSITORY_URL_MARKER)
297
+ const isMonorepo = isMonorepoPackage(packageJsonParsed)
298
+
299
+ /** @type {string[]} */
300
+ const detectedRules = []
301
+ /** @type {string[]} */
302
+ const detectedSkills = []
303
+
304
+ /**
305
+ * Додає правило до результату, якщо воно доступне і не в disable-списку.
306
+ * @param {string} ruleId id правила
307
+ * @returns {void}
308
+ */
309
+ function addRule(ruleId) {
310
+ if (!normalizedRules.has(ruleId) || disableRulesSet.has(ruleId) || detectedRules.includes(ruleId)) {
311
+ return
312
+ }
313
+ detectedRules.push(ruleId)
314
+ }
315
+
316
+ /**
317
+ * Додає skill до результату, якщо він доступний і не в disable-списку.
318
+ * @param {string} skillId id skill
319
+ * @returns {void}
320
+ */
321
+ function addSkill(skillId) {
322
+ if (!normalizedSkills.has(skillId) || disableSkillsSet.has(skillId) || detectedSkills.includes(skillId)) {
323
+ return
324
+ }
325
+ detectedSkills.push(skillId)
326
+ }
327
+
328
+ const autoRuleChecks = [
329
+ { enabled: isAbie, id: 'abie' },
330
+ { enabled: packageJsonExists, id: 'bun' },
331
+ { enabled: facts.hasDockerfile, id: 'docker' },
332
+ { enabled: facts.hasGaWorkflowsDir, id: 'ga' },
333
+ { enabled: facts.hasGqlTaggedTemplates, id: 'graphql' },
334
+ { enabled: facts.hasJsLikeSource, id: 'js-lint' },
335
+ { enabled: facts.hasJsLikeSource && !(isMonorepo && facts.hasVueSource && facts.hasTempoDir), id: 'js-pino' },
336
+ { enabled: facts.hasK8sDir, id: 'k8s' },
337
+ { enabled: facts.hasNginxDefaultTplFile, id: 'nginx-default-tpl' },
338
+ { enabled: npmDirExists, id: 'npm-module' },
339
+ { enabled: facts.hasVueOrCssSource, id: 'style-lint' }
340
+ ]
341
+ for (const item of autoRuleChecks) {
342
+ if (item.enabled) {
343
+ addRule(item.id)
344
+ }
345
+ }
346
+ addRule('text')
347
+ if (facts.hasVueSource) {
348
+ addRule('vue')
349
+ }
350
+
351
+ const autoSkillChecks = [
352
+ { enabled: isAbie, id: 'abie-kustomize' },
353
+ { enabled: true, id: 'fix' },
354
+ { enabled: true, id: 'lint' }
355
+ ]
356
+ for (const item of autoSkillChecks) {
357
+ if (item.enabled) {
358
+ addSkill(item.id)
359
+ }
360
+ }
361
+
362
+ const rules = AUTO_RULE_ORDER.filter(ruleId => detectedRules.includes(ruleId))
363
+ const skills = AUTO_SKILL_ORDER.filter(skillId => detectedSkills.includes(skillId))
364
+ return { rules, skills }
365
+ }
366
+
367
+ /**
368
+ * Доповнює конфіг автодетектом (лише додає; існуючі вручну задані елементи не прибирає).
369
+ * @param {object} params параметри оновлення
370
+ * @param {{ rules: unknown, skills?: unknown, ['disable-rules']?: unknown, ['disable-skills']?: unknown }} params.config розпарсений `.n-cursor.json`
371
+ * @param {string[]} params.detectedRules правила, визначені автодетектом
372
+ * @param {string[]} params.detectedSkills skills, визначені автодетектом
373
+ * @returns {{ rules: string[], skills: string[] } & Record<string, unknown>} новий нормалізований конфіг
374
+ */
375
+ export function mergeConfigWithAutoDetected({ config, detectedRules, detectedSkills }) {
376
+ const existingRules = normalizeIdList(config.rules)
377
+ const existingSkills = normalizeIdList(config.skills)
378
+ const disableRules = normalizeIdList(config['disable-rules'])
379
+ const disableSkills = normalizeIdList(config['disable-skills'])
380
+
381
+ const rules = [...existingRules]
382
+ for (const id of detectedRules) {
383
+ if (!rules.includes(id) && !disableRules.includes(id)) {
384
+ rules.push(id)
385
+ }
386
+ }
387
+
388
+ const skills = [...existingSkills]
389
+ for (const id of detectedSkills) {
390
+ if (!skills.includes(id) && !disableSkills.includes(id)) {
391
+ skills.push(id)
392
+ }
393
+ }
394
+
395
+ /** @type {{ rules: string[], skills: string[] } & Record<string, unknown>} */
396
+ const normalized = { rules, skills }
397
+ if (disableRules.length > 0) {
398
+ normalized['disable-rules'] = disableRules
399
+ }
400
+ if (disableSkills.length > 0) {
401
+ normalized['disable-skills'] = disableSkills
402
+ }
403
+ return normalized
404
+ }