@nitra/cursor 12.16.1 → 12.17.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 CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## [12.17.0] - 2026-06-27
4
+
5
+ ### Changed
6
+
7
+ - Перехід на globby для обходу файлової системи з урахуванням .gitignore
8
+
9
+ ## [12.16.2] - 2026-06-27
10
+
11
+ ### Changed
12
+
13
+ - feat(orchestrator): skip non-actionable violation (не годувати агента tool-crash); feat(pi-agent-fix): логувати вхід LLM у trace (violation + розмір промпта)
14
+
3
15
  ## [12.16.1] - 2026-06-26
4
16
 
5
17
  ### Changed
@@ -213,12 +213,11 @@ export async function runPiAgentFix(ruleId, violation, cwd, opts = {}) {
213
213
  }
214
214
  })
215
215
 
216
+ const fixPrompt = buildFixPrompt({ ruleId, violation, ruleText, feedback })
216
217
  const startedAt = clock()
217
218
  let error = null
218
219
  try {
219
- await withTimeout(session.prompt(buildFixPrompt({ ruleId, violation, ruleText, feedback })), timeoutMs, () =>
220
- session.abort?.()
221
- )
220
+ await withTimeout(session.prompt(fixPrompt), timeoutMs, () => session.abort?.())
222
221
  } catch (e) {
223
222
  error = e.message
224
223
  }
@@ -244,9 +243,17 @@ export async function runPiAgentFix(ruleId, violation, cwd, opts = {}) {
244
243
  rung: tier,
245
244
  model: modelSpec,
246
245
  cwd,
246
+ // ВХІД LLM (щоб «що подали» було видно у trace, без розкопок escalation-log):
247
+ // violation — обрізаний (може бути великим); promptChars — повний розмір промпта.
248
+ violation: typeof violation === 'string' ? violation.slice(0, 4000) : null,
249
+ violationChars: typeof violation === 'string' ? violation.length : 0,
250
+ promptChars: fixPrompt.length,
251
+ // вихід:
247
252
  turnCount,
248
253
  toolCallCount,
254
+ touchedFiles,
249
255
  backstopHit,
256
+ wallMs: clock() - startedAt,
250
257
  error
251
258
  })
252
259
  return { applied: touchedFiles.length > 0, touchedFiles, telemetry, error, rollback: guard.rollback }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nitra/cursor",
3
- "version": "12.16.1",
3
+ "version": "12.17.0",
4
4
  "description": "CLI для завантаження cursor-правил (префікс n-) у локальний репозиторій",
5
5
  "keywords": [
6
6
  "cli",
@@ -16,10 +16,13 @@
16
16
  * їх у конфіг із поправкою на legacy-id (`migrateRuleIds`).
17
17
  */
18
18
  import { readdirSync } from 'node:fs'
19
- import { readdir, readFile } from 'node:fs/promises'
19
+ import { readFile } from 'node:fs/promises'
20
20
  import { basename, dirname, join, relative } from 'node:path'
21
21
  import { fileURLToPath } from 'node:url'
22
22
 
23
+ import { globby } from 'globby'
24
+
25
+ import { ALWAYS_IGNORE } from './utils/walkDir.mjs'
23
26
  import { globToRegex } from '../rules/npm-module/js/package_structure.mjs'
24
27
  import { textHasBunSqlImport } from '../rules/js-bun-db/lib/bun-sql-scan.mjs'
25
28
  import {
@@ -84,9 +87,30 @@ export const AUTO_RULE_DEPENDENCIES = Object.freeze(
84
87
 
85
88
  const HASURA_CONFIG_MARKER = 'metadata_directory: metadata'
86
89
  const REGO_RE = /\.rego$/iu
87
- const IGNORED_DIR_NAMES = new Set(['node_modules', '.git', '.next', '.turbo'])
88
90
  const DEFAULT_DISABLED_LIST = Object.freeze([])
89
91
 
92
+ /**
93
+ * Збирає relative-posix шляхи дерева (файли + директорії), **поважаючи `.gitignore`** —
94
+ * через той самий `globby`-канон, що й `walkDir` (звідси `ALWAYS_IGNORE`). Спільне джерело
95
+ * для `collectRepoPaths` (Type A glob-матчинг) і `collectAutoRuleFacts` (content-факти).
96
+ * Раніше тут був ручний `readdir`-обхід із хардкод skip-набором, який ігнорував `.gitignore`
97
+ * і помилково активував правила на згенерованих артефактах (`coverage/*.png` → image-compress).
98
+ * @param {string} root абсолютний шлях кореня репозиторію
99
+ * @returns {Promise<{ files: string[], dirs: string[] }>} relative-posix шляхи файлів і директорій
100
+ */
101
+ async function collectTreePaths(root) {
102
+ const opts = { cwd: root, gitignore: true, dot: true, ignore: ALWAYS_IGNORE }
103
+ try {
104
+ const [files, dirs] = await Promise.all([
105
+ globby('**/*', { ...opts, onlyFiles: true }),
106
+ globby('**/*', { ...opts, onlyDirectories: true })
107
+ ])
108
+ return { files, dirs }
109
+ } catch {
110
+ return { files: [], dirs: [] }
111
+ }
112
+ }
113
+
90
114
  /**
91
115
  * Чи містить текст джерела імпорт імені `sql` або `SQL` з `"bun"` (після витягування `<script>` у `.vue`).
92
116
  * @param {string} content вміст файлу
@@ -224,35 +248,13 @@ export async function collectAutoRuleFacts(root) {
224
248
  hasTempoDir: false
225
249
  }
226
250
 
227
- /**
228
- * Рекурсивний обхід каталогу з пропуском службових директорій.
229
- * @param {string} dir абсолютний шлях каталогу
230
- * @returns {Promise<void>}
231
- */
232
- async function walk(dir) {
233
- let entries
234
- try {
235
- entries = await readdir(dir, { withFileTypes: true })
236
- } catch {
237
- return
238
- }
239
-
240
- for (const entry of entries) {
241
- const absPath = join(dir, entry.name)
242
- if (entry.isDirectory()) {
243
- if (!IGNORED_DIR_NAMES.has(entry.name)) {
244
- if (entry.name === 'tempo') {
245
- facts.hasTempoDir = true
246
- }
247
- await walk(absPath)
248
- }
249
- } else if (entry.isFile()) {
250
- await processFileEntry(absPath, root, facts)
251
- }
252
- }
251
+ const { files, dirs } = await collectTreePaths(root)
252
+ if (dirs.some(d => basename(d) === 'tempo')) {
253
+ facts.hasTempoDir = true
254
+ }
255
+ for (const rel of files) {
256
+ await processFileEntry(join(root, rel), root, facts)
253
257
  }
254
-
255
- await walk(root)
256
258
  return facts
257
259
  }
258
260
 
@@ -266,34 +268,8 @@ export async function collectAutoRuleFacts(root) {
266
268
  * @returns {Promise<string[]>} шляхи відносно root у posix-форматі
267
269
  */
268
270
  async function collectRepoPaths(root) {
269
- /** @type {string[]} */
270
- const out = []
271
- /**
272
- * Рекурсивний обхід каталогу з пропуском службових директорій.
273
- * @param {string} dir каталог
274
- * @returns {Promise<void>}
275
- */
276
- async function walk(dir) {
277
- let entries
278
- try {
279
- entries = await readdir(dir, { withFileTypes: true })
280
- } catch {
281
- return
282
- }
283
- for (const entry of entries) {
284
- const abs = join(dir, entry.name)
285
- if (entry.isDirectory()) {
286
- if (!IGNORED_DIR_NAMES.has(entry.name)) {
287
- out.push(relative(root, abs).split('\\').join('/'))
288
- await walk(abs)
289
- }
290
- } else if (entry.isFile()) {
291
- out.push(relative(root, abs).split('\\').join('/'))
292
- }
293
- }
294
- }
295
- await walk(root)
296
- return out
271
+ const { files, dirs } = await collectTreePaths(root)
272
+ return [...dirs, ...files]
297
273
  }
298
274
 
299
275
  /**
@@ -51,6 +51,19 @@ export function classifyFixError(error) {
51
51
  return 'quality'
52
52
  }
53
53
 
54
+ /**
55
+ * Чи violation придатний для LLM-фіксу: містить хоч одне actionable `❌`-порушення
56
+ * (формат конформ-чека `❌ <file>: <інструкція>`). Без жодного ❌ — це не список
57
+ * фіксабельних порушень, а шум/збій тула (Usage, «лок взято», порожньо). Годувати
58
+ * таким агента = марні turns/timeout (вимір 7n-test: doc-files surface-ив Usage
59
+ * downstream-тула → агент флоундерив 4 рунги). Тоді LLM-фікс пропускаємо.
60
+ * @param {string|null|undefined} output violation-вивід правила
61
+ * @returns {boolean} true — є що фіксити
62
+ */
63
+ export function hasActionableViolation(output) {
64
+ return /❌/u.test(output ?? '')
65
+ }
66
+
54
67
  /**
55
68
  * Будує драбину ескалації за наявними тирами (спека 2026-06-19-fix-escalation-cascade):
56
69
  * 1. `local-min` — `N_LOCAL_MIN_MODEL`, перший прохід;
@@ -124,6 +137,27 @@ export async function escalateRule(rule, cwd, deps) {
124
137
  const skipModels = new Set()
125
138
  let avgUsed = 0
126
139
 
140
+ // §2-профілактика: violation без actionable ❌ (tool-crash/Usage/шум) → не годуємо
141
+ // агента (інакше флоундерить рунги до timeout). Рапортуємо як non-actionable, не фіксимо.
142
+ if (!hasActionableViolation(rule.output)) {
143
+ record({
144
+ rung: -1,
145
+ tier: 'skip',
146
+ model: '',
147
+ withFeedback: false,
148
+ callOk: false,
149
+ callError: 'non-actionable violation (нема ❌ — ймовірно check-error/tool-crash)',
150
+ recheckOk: false,
151
+ remainingViolation: rule.output,
152
+ diagnosis: null,
153
+ ms: 0
154
+ })
155
+ log(
156
+ ` ⏭️ ${rule.ruleId}: LLM-фікс пропущено — у violation немає ❌-порушень (check-error/tool-crash, не фіксабельне)`
157
+ )
158
+ return { resolved: false, avgUsed: 0 }
159
+ }
160
+
127
161
  for (const [idx, rung] of ladder.entries()) {
128
162
  if (skipModels.has(rung.model)) continue
129
163
 
@@ -8,27 +8,23 @@
8
8
  * Сигнатури неоднорідні (одні беруть `facts`, інші — `cwd`/`packageJson`), бо предикати
9
9
  * читають різні джерела; виклик диспетчиться в `auto-rules.mjs` за іменем предиката.
10
10
  */
11
- import { readdir, readFile } from 'node:fs/promises'
11
+ import { readFile } from 'node:fs/promises'
12
12
  import { join } from 'node:path'
13
13
 
14
+ import { findAllPackageJsonPaths } from '../utils/find-package-json-paths.mjs'
14
15
  import { getRepositoryUrl } from './rule-meta-helpers.mjs'
15
16
 
16
- const IGNORED_DIR_NAMES = new Set(['node_modules', '.git', '.next', '.turbo'])
17
-
18
17
  /**
19
18
  * Чи package.json дерева містить будь-який із зазначених пакетів у dependencies.
19
+ * Обхід — через `findAllPackageJsonPaths` (на `walkDir`/`globby`), тож **поважає `.gitignore`**
20
+ * і не зчитує package.json з ігнорованих каталогів (build-артефакти, vendored-копії).
20
21
  * @param {string} root корінь репо
21
22
  * @param {string[]} keys імена пакетів
22
23
  * @returns {Promise<boolean>} true, якщо знайдено хоч один
23
24
  */
24
- function anyDepInTree(root, keys) {
25
+ async function anyDepInTree(root, keys) {
25
26
  const wanted = new Set(keys)
26
- /**
27
- * Чи package.json за `abs` оголошує будь-який пакет із `wanted` у `dependencies`.
28
- * @param {string} abs шлях до package.json
29
- * @returns {Promise<boolean>} true, якщо знайдено хоч один
30
- */
31
- async function pkgDeclaresWanted(abs) {
27
+ for (const abs of await findAllPackageJsonPaths(root, [])) {
32
28
  try {
33
29
  const deps = JSON.parse(await readFile(abs, 'utf8'))?.dependencies
34
30
  if (deps && typeof deps === 'object' && !Array.isArray(deps)) {
@@ -37,70 +33,29 @@ function anyDepInTree(root, keys) {
37
33
  } catch {
38
34
  /* ігноруємо пошкоджені package.json */
39
35
  }
40
- return false
41
36
  }
42
- /**
43
- * @param {string} dir каталог обходу
44
- * @returns {Promise<boolean>} true, якщо знайдено хоч один пакет
45
- */
46
- async function walk(dir) {
47
- let entries
48
- try {
49
- entries = await readdir(dir, { withFileTypes: true })
50
- } catch {
51
- return false
52
- }
53
- for (const entry of entries) {
54
- const abs = join(dir, entry.name)
55
- if (entry.isDirectory()) {
56
- if (!IGNORED_DIR_NAMES.has(entry.name) && (await walk(abs))) return true
57
- } else if (entry.isFile() && entry.name === 'package.json' && (await pkgDeclaresWanted(abs))) {
58
- return true
59
- }
60
- }
61
- return false
62
- }
63
- return walk(root)
37
+ return false
64
38
  }
65
39
 
66
40
  /**
67
41
  * Чи існує вкладений (не кореневий) package.json без `vite` у devDependencies.
42
+ * Обхід — `findAllPackageJsonPaths` (gitignore-aware), як у `anyDepInTree`.
68
43
  * @param {string} root корінь репо
69
44
  * @returns {Promise<boolean>} true, якщо знайдено
70
45
  */
71
46
  async function nestedWithoutVite(root) {
72
47
  const rootPkg = join(root, 'package.json')
73
- let result = false
74
- /**
75
- * @param {string} dir каталог
76
- * @returns {Promise<void>}
77
- */
78
- async function walk(dir) {
79
- if (result) return
80
- let entries
48
+ for (const abs of await findAllPackageJsonPaths(root, [])) {
49
+ if (abs === rootPkg) continue
81
50
  try {
82
- entries = await readdir(dir, { withFileTypes: true })
51
+ const dev = JSON.parse(await readFile(abs, 'utf8'))?.devDependencies
52
+ const hasVite = dev && typeof dev === 'object' && !Array.isArray(dev) && Object.hasOwn(dev, 'vite')
53
+ if (!hasVite) return true
83
54
  } catch {
84
- return
85
- }
86
- for (const entry of entries) {
87
- if (result) return
88
- const abs = join(dir, entry.name)
89
- if (entry.isDirectory()) {
90
- if (!IGNORED_DIR_NAMES.has(entry.name)) await walk(abs)
91
- } else if (entry.isFile() && entry.name === 'package.json' && abs !== rootPkg) {
92
- try {
93
- const dev = JSON.parse(await readFile(abs, 'utf8'))?.devDependencies
94
- const hasVite = dev && typeof dev === 'object' && !Array.isArray(dev) && Object.hasOwn(dev, 'vite')
95
- if (!hasVite) result = true
96
- } catch {
97
- /* пошкоджений package.json не вважаємо vite-проєктом */
98
- }
99
- }
55
+ /* пошкоджений package.json не вважаємо vite-проєктом */
100
56
  }
101
57
  }
102
- await walk(root)
103
- return result
58
+ return false
104
59
  }
105
60
 
106
61
  /** Реєстр предикатів: імʼя → реалізація. Виклик за `meta.json.auto.predicate`. */
@@ -4,7 +4,7 @@ import { globby } from 'globby'
4
4
 
5
5
  // .git ніколи не потрапляє в .gitignore — пропускаємо завжди.
6
6
  // node_modules — safety net: проєкт може не мати .gitignore або запускатись поза git-репо.
7
- const ALWAYS_IGNORE = ['.git/**', 'node_modules/**']
7
+ export const ALWAYS_IGNORE = ['.git/**', 'node_modules/**']
8
8
 
9
9
  /**
10
10
  * Рекурсивно обходить каталог, поважаючи .gitignore (включно з вкладеними).