@nitra/cursor 9.3.0 → 10.0.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
+ ## [10.0.0] - 2026-06-14
4
+
5
+ ### Changed
6
+
7
+ - Інлайн _fix-check/fix-t0 у прямі функції — видалено внутрішні subcommand'и. runFixCheck (scripts/lib/fix/run-fix-check) + listProjectRulesMdcFiles винесено з bin; orchestrator/t0/PostToolUse-хук/lint-конформність кличуть прямо (без subprocess-обгортки). Per-rule ізоляція збережена
8
+
9
+ ## [9.4.0] - 2026-06-14
10
+
11
+ ### Changed
12
+
13
+ - docgen: класифікація omlx-збоїв (transient/systemic/permanent) — ретрай ETIMEDOUT з backoff (2s→8s), circuit-breaker на systemic-каскад (3 підряд → abort, exit 2), permanent→skip; pre-send byte-guard (джерело > 0.5× контексту → instant-skip без LLM); scan поважає .gitignore; прибрано хардкод DEFAULT_OMLX_MODEL (fail-loud, модель через N_LOCAL_MIN_MODEL)
14
+
3
15
  ## [9.3.0] - 2026-06-14
4
16
 
5
17
  ### Added
package/bin/n-cursor.js CHANGED
@@ -97,8 +97,7 @@ import { readSkillMetaRaw } from '../scripts/lib/skill-meta.mjs'
97
97
  import { injectWorktreeNotice } from '../scripts/lib/worktree-notice.mjs'
98
98
  import { injectRootNotice } from '../scripts/lib/root-notice.mjs'
99
99
  import { runPostToolUseFixCli } from '../scripts/post-tool-use-fix.mjs'
100
- import { discoverCheckRulesFromCursorRules } from '../scripts/lib/discover-check-rules-from-cursor.mjs'
101
- import { listRuleIds } from '../scripts/lib/list-rule-ids.mjs'
100
+ import { listProjectRulesMdcFiles } from '../scripts/lib/list-project-rules-mdc.mjs'
102
101
  import { ensureNitraCursorInRootDevDependencies } from '../scripts/ensure-nitra-cursor-dev-dependencies.mjs'
103
102
  import { assertCwdIsProjectRoot } from '../scripts/lib/assert-project-root.mjs'
104
103
  import { runLintDocker } from '../rules/docker/lint/lint.mjs'
@@ -114,8 +113,6 @@ import { runSkillsCli } from '../scripts/skills-cli.mjs'
114
113
  import { runWorktreeCli } from '../scripts/worktree-cli.mjs'
115
114
  import { syncSetupBunDepsAction } from '../scripts/sync-setup-bun-deps-action.mjs'
116
115
  import { runLint } from '../rules/lint/js/orchestrate.mjs'
117
- import { formatTimingSummary } from '../scripts/lib/timing-summary.mjs'
118
- import { ensureHkInstall, ensureTool } from '../scripts/lib/ensure-tool.mjs'
119
116
 
120
117
  const PACKAGE_NAME = '@nitra/cursor'
121
118
  const CONFIG_FILE = '.n-cursor.json'
@@ -525,19 +522,6 @@ function formatPiSkillFrontmatter(skillName, descriptionRaw) {
525
522
  return `---\nname: ${skillName}\ndescription: >-\n ${text}\n---\n\n`
526
523
  }
527
524
 
528
- /**
529
- * Повертає відсортовані імена *.mdc у .cursor/rules поточного проєкту
530
- * @returns {Promise<string[]>} базові імена файлів (лише .mdc)
531
- */
532
- async function listProjectRulesMdcFiles() {
533
- const rulesDir = join(cwd(), RULES_DIR)
534
- if (!existsSync(rulesDir)) {
535
- return []
536
- }
537
- const names = await readdir(rulesDir)
538
- return names.filter(n => n.endsWith('.mdc')).toSorted((a, b) => a.localeCompare(b))
539
- }
540
-
541
525
  /**
542
526
  * Базові імена файлів .mdc, які очікуються згідно з .n-cursor.json (префікс n-).
543
527
  * @param {string[]} configRules елементи масиву rules з конфігу
@@ -1242,120 +1226,6 @@ function logRemovedManagedItems(title, basePath, names) {
1242
1226
  }
1243
1227
  }
1244
1228
 
1245
- /**
1246
- * Визначає список правил для fix: явно задані (з валідацією проти available) або
1247
- * discovery з `.cursor/rules/*.mdc`. Друкує діагностику й кидає на невідомих правилах.
1248
- * @param {string[]} requestedRules запитані правила (порожній → discovery)
1249
- * @param {string[]} available доступні в пакеті rule-id
1250
- * @param {boolean} json json-режим (впливає на early-return вивід при порожньому discovery)
1251
- * @returns {Promise<string[]|null>} список id або null — нічого запускати (вивід уже зроблено)
1252
- */
1253
- async function resolveFixRuleIds(requestedRules, available, json) {
1254
- if (requestedRules.length > 0) {
1255
- const unknown = requestedRules.filter(id => !available.includes(id))
1256
- if (unknown.length > 0) {
1257
- console.error(`❌ Невідомі правила: ${unknown.join(', ')}`)
1258
- console.log(` Доступні: ${available.join(', ')}`)
1259
- throw new Error(`Unknown rules: ${unknown.join(', ')}`)
1260
- }
1261
- return requestedRules
1262
- }
1263
-
1264
- const mdcFiles = await listProjectRulesMdcFiles()
1265
- if (mdcFiles.length === 0) {
1266
- throw new Error(
1267
- `Немає файлів *.mdc у ${RULES_DIR}/. Запустіть \`npx ${PACKAGE_NAME}\` або вкажіть правила: \`npx ${PACKAGE_NAME} fix bun ga\``
1268
- )
1269
- }
1270
- const idsToRun = discoverCheckRulesFromCursorRules(available, mdcFiles)
1271
- if (idsToRun.length === 0) {
1272
- if (json) {
1273
- process.stdout.write(`${JSON.stringify({ total: 0, failed: 0, rules: [] })}\n`)
1274
- return null
1275
- }
1276
- console.log(
1277
- `\n🔍 ${PACKAGE_NAME} fix — у ${RULES_DIR}/ немає правил з programmatic перевіркою ` +
1278
- `(відповідного fix.mjs у пакеті). Нічого не запущено.\n`
1279
- )
1280
- return null
1281
- }
1282
- return idsToRun
1283
- }
1284
-
1285
- /**
1286
- * Прогоняє `fix.mjs` кожного правила окремим процесом; збирає timings і (для json) per-rule output.
1287
- * @param {string[]} idsToRun правила до запуску
1288
- * @param {boolean} json json-режим — захоплює stdout/stderr у структуру замість inherit у термінал
1289
- * @returns {{ totalFailed: number, timings: {id:string, ms:number, ok:boolean}[], ruleResults: {ruleId:string, ok:boolean, output:string}[] }} підсумок прогону
1290
- */
1291
- function runRuleFixProcesses(idsToRun, json) {
1292
- let totalFailed = 0
1293
- const timings = []
1294
- const ruleResults = []
1295
- for (const id of idsToRun) {
1296
- const fixPath = join(BUNDLED_RULES_DIR, id, 'fix.mjs')
1297
- const startedAt = Date.now()
1298
- const result = json
1299
- ? spawnSync('bun', [fixPath], { encoding: 'utf8' })
1300
- : spawnSync('bun', [fixPath], { stdio: 'inherit' })
1301
- const ok = result.status === 0
1302
- timings.push({ id: `fix-${id}`, ms: Date.now() - startedAt, ok })
1303
- if (json) {
1304
- ruleResults.push({ ruleId: id, ok, output: `${result.stdout ?? ''}${result.stderr ?? ''}`.trim() })
1305
- }
1306
- if (!ok) totalFailed++
1307
- }
1308
- return { totalFailed, timings, ruleResults }
1309
- }
1310
-
1311
- /**
1312
- * Spawn-wrapper для `npx \@nitra/cursor fix [<rule>...]`. Один шлях у коді: для кожного правила
1313
- * робить `bun rules/<id>/fix.mjs` як окремий процес. Сам `fix.mjs` читає `.n-cursor.json`,
1314
- * перевіряє whitelist (`runRuleCli`) і друкує per-rule summary.
1315
- *
1316
- * Без аргументів — discover з `.cursor/rules/*.mdc` у проекті-споживачі.
1317
- *
1318
- * Серіалізація паралельних запусків — per-rule, всередині `runStandardRule` (`withLock('fix-<id>')`).
1319
- * На рівні `runFixCommand` локу нема: різні набори правил можуть прогресувати незалежно,
1320
- * а однакові правила серіалізуються в spawn'ах нижче.
1321
- * @param {string[]} requestedRules імена правил; порожній масив — discovery з `.cursor/rules/`
1322
- * @param {{json?: boolean}} [opts] json — друкувати компактний JSON `{total, failed, rules:[{ruleId, ok, output}]}`
1323
- * замість per-rule stdio + timing (для скілу n-fix: агент читає лише впалі правила, не парсить текст)
1324
- * @returns {Promise<void>}
1325
- */
1326
- async function runFixCommand(requestedRules, opts = {}) {
1327
- const json = opts.json === true
1328
- // json-режим — діагностичний read-only вивід для скілу: пропускаємо встановлення
1329
- // git-hook (`ensureHkInstall` друкує «Installed hk hook…» у stdout і забруднив би
1330
- // чистий JSON; сам pre-commit hook для діагностики не потрібен).
1331
- const hkBin = ensureTool('hk')
1332
- if (!json) ensureHkInstall(hkBin)
1333
- ensureTool('conftest')
1334
-
1335
- const available = await listRuleIds(BUNDLED_RULES_DIR)
1336
- if (available.length === 0) {
1337
- console.error('❌ Не знайдено жодного правила у пакеті')
1338
- throw new Error('No rules found')
1339
- }
1340
-
1341
- // json-режим: захоплюємо stdout/stderr правила у структуру (а не inherit у термінал),
1342
- // щоб віддати агенту згруповано {ruleId, ok, output} і він читав лише впалі.
1343
- const idsToRun = await resolveFixRuleIds(requestedRules, available, json)
1344
- if (idsToRun === null) return
1345
-
1346
- const { totalFailed, timings, ruleResults } = runRuleFixProcesses(idsToRun, json)
1347
-
1348
- if (json) {
1349
- process.stdout.write(`${JSON.stringify({ total: idsToRun.length, failed: totalFailed, rules: ruleResults })}\n`)
1350
- } else {
1351
- process.stdout.write(formatTimingSummary('Fix timing', timings))
1352
- }
1353
-
1354
- if (totalFailed > 0) {
1355
- throw new Error(`${totalFailed} з ${idsToRun.length} правил мають проблеми`)
1356
- }
1357
- }
1358
-
1359
1229
  /**
1360
1230
  * Читає поле `version` з `package.json` пакету за абсолютним шляхом до його кореня.
1361
1231
  * @param {string} packageRoot корінь пакету (тека з `package.json`)
@@ -1643,13 +1513,6 @@ try {
1643
1513
  }
1644
1514
  await ensureNitraCursorInRootDevDependencies(cwd())
1645
1515
  switch (command) {
1646
- case '_fix-check': {
1647
- // Внутрішня команда движка конформності (не публічний API): per-rule fix.mjs run() = детект.
1648
- // Повертає JSON {total, failed, rules:[{ruleId, ok, output}]} у stdout. Викликається
1649
- // конформність-фазою `lint` (read-only) і движком orchestrator/t0.
1650
- await runFixCommand(args, { json: true })
1651
- break
1652
- }
1653
1516
  case 'rename-yaml-extensions': {
1654
1517
  const code = await runRenameYamlExtensionsCli(args)
1655
1518
  if (code !== 0) {
@@ -1753,16 +1616,6 @@ try {
1753
1616
 
1754
1617
  break
1755
1618
  }
1756
- case 'fix-t0': {
1757
- // Внутрішня фаза движка конформності (не публічний API): T0-auto рівень.
1758
- // Запускає _fix-check, знаходить violation-output із детермінованим паттерном
1759
- // (vscode-ext-add, rm-forbidden-file тощо), застосовує програмний фікс (0 LLM),
1760
- // перевіряє check-gate. Викликається orchestrator.mjs (fix-режим конформності lint).
1761
- const { runT0AutoCli } = await import('../scripts/lib/fix/t0.mjs')
1762
- process.exitCode = await runT0AutoCli(args, cwd())
1763
-
1764
- break
1765
- }
1766
1619
  case 'change': {
1767
1620
  const { runChangeCli } = await import('../rules/release/change.mjs')
1768
1621
  process.exitCode = await runChangeCli(args)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nitra/cursor",
3
- "version": "9.3.0",
3
+ "version": "10.0.0",
4
4
  "description": "CLI для завантаження cursor-правил (префікс n-) у локальний репозиторій",
5
5
  "keywords": [
6
6
  "cli",
@@ -363,6 +363,18 @@ function orchestratedDoc(facts, src, model, timeoutMs, { anchors = null, tempera
363
363
 
364
364
  /** Максимальний час генерації одного LLM-виклику. */
365
365
  const LOCAL_TIMEOUT_MS = 5 * 60 * 1000
366
+
367
+ /** Контекстне вікно локальної моделі в токенах (оцінка; override — N_CURSOR_DOCGEN_CTX). */
368
+ const DEFAULT_CONTEXT_TOKENS = 131072
369
+
370
+ /**
371
+ * Бюджет токенів на джерело: половина контекстного вікна (решта — факти/стиль/вихід).
372
+ * Перевищення → pre-send guard відсікає файл без жодного LLM-виклику.
373
+ * @returns {number} бюджет у токенах
374
+ */
375
+ function srcTokenBudget() {
376
+ return Math.floor((Number(env.N_CURSOR_DOCGEN_CTX) || DEFAULT_CONTEXT_TOKENS) * 0.5)
377
+ }
366
378
  /**
367
379
  * Дефолтна модель: N_CURSOR_DOCGEN_MODEL → resolveModel('min') (→ N_LOCAL_MIN_MODEL).
368
380
  * Без хардкод-fallback: модель налаштовує кожен локально (`N_LOCAL_MIN_MODEL`); якщо
@@ -384,6 +396,14 @@ export const DEFAULT_LOCAL_MODEL = env.N_CURSOR_DOCGEN_MODEL ?? resolveModel('mi
384
396
  */
385
397
  export function generateDoc(file, { model = DEFAULT_LOCAL_MODEL, threshold = QUALITY_THRESHOLD, existingMd = null } = {}) {
386
398
  const src = readFileSync(file, 'utf8')
399
+ // Pre-send guard: весь src вшивається у промпт як є (екстракт фактів його НЕ
400
+ // замінює). Для гігантів (vendored/генерат) це переповнює контекст → інстант-skip
401
+ // без LLM-виклику. Маркер «Prompt too long» → classifyOmlxError → permanent → skip.
402
+ const estTokens = Math.round(Buffer.byteLength(src, 'utf8') / 4)
403
+ const budget = srcTokenBudget()
404
+ if (estTokens > budget) {
405
+ throw new Error(`docgen pre-send guard: джерело ~${estTokens} токенів > бюджет ${budget} (0.5× контексту) — Prompt too long, skip`)
406
+ }
387
407
  const facts = extractFacts(src, file)
388
408
  const t0 = Date.now()
389
409
  llmMeter = { calls: 0, ms: 0 }
@@ -14,7 +14,6 @@
14
14
  import { existsSync, readdirSync } from 'node:fs'
15
15
  import { dirname, join } from 'node:path'
16
16
  import { fileURLToPath } from 'node:url'
17
- import { spawnSync } from 'node:child_process'
18
17
  import { cwd as processCwd } from 'node:process'
19
18
 
20
19
  import { parseRuleLintSpec, readRuleMetaRaw } from '../../../scripts/lib/rule-meta.mjs'
@@ -23,7 +22,6 @@ import { collectChangedFilesSince, resolveChangedBase } from '../../../scripts/l
23
22
  // Цей файл: npm/rules/lint/js/orchestrate.mjs → PACKAGE_ROOT = npm (чотири dirname угору).
24
23
  const PACKAGE_ROOT = dirname(dirname(dirname(dirname(fileURLToPath(import.meta.url)))))
25
24
  const RULES_DIR = join(PACKAGE_ROOT, 'rules')
26
- const N_CURSOR_BIN = join(PACKAGE_ROOT, 'bin', 'n-cursor.js')
27
25
 
28
26
  /**
29
27
  * Конформність-фаза lint (whole-repo: config/file/workflow conformance — те, що раніше робив `fix`).
@@ -41,20 +39,11 @@ async function runConformance(cwd, readOnly, log, filter = []) {
41
39
  const { runOrchestratorCli } = await import('../../../scripts/lib/fix/orchestrator.mjs')
42
40
  return runOrchestratorCli(filter, cwd)
43
41
  }
44
- const r = spawnSync('bun', [N_CURSOR_BIN, '_fix-check', ...filter], { cwd, encoding: 'utf8', timeout: 600_000 })
45
- let parsed = null
46
- try {
47
- parsed = JSON.parse((r.stdout ?? '').trim())
48
- } catch {
49
- parsed = null
50
- }
51
- if (!parsed) {
52
- log('❌ lint: конформність — помилка перевірки (_fix-check не повернув JSON)\n')
53
- return 1
54
- }
55
- const failed = parsed.rules.filter(/** @param {{ok:boolean}} x */ x => !x.ok)
42
+ const { runFixCheck } = await import('../../../scripts/lib/fix/run-fix-check.mjs')
43
+ const { rules } = await runFixCheck(filter, cwd)
44
+ const failed = rules.filter(x => !x.ok)
56
45
  if (failed.length === 0) return 0
57
- log(`❌ lint: конформність — ${failed.length} порушень: ${failed.map(/** @param {{ruleId:string}} x */ x => x.ruleId).join(', ')}\n`)
46
+ log(`❌ lint: конформність — ${failed.length} порушень: ${failed.map(x => x.ruleId).join(', ')}\n`)
58
47
  for (const f of failed) if (f.output) log(`${f.output}\n`)
59
48
  return 1
60
49
  }
@@ -1,11 +1,7 @@
1
1
  /** @see ./docs/orchestrator.md */
2
2
 
3
- import { spawnSync } from 'node:child_process'
4
- import { fileURLToPath } from 'node:url'
5
- import { join } from 'node:path'
6
-
7
- const HERE = fileURLToPath(new URL('.', import.meta.url))
8
- const N_CURSOR_BIN = join(HERE, '../../../bin/n-cursor.js')
3
+ import { runFixCheck } from './run-fix-check.mjs'
4
+ import { runT0AutoCli } from './t0.mjs'
9
5
 
10
6
  const DEFAULT_MAX_ITER = 3
11
7
  const ESCALATE_AFTER = 2
@@ -29,13 +25,13 @@ function parseOrchestratorArgs(args) {
29
25
  * @param {string} cwd корінь проєкту
30
26
  * @param {string[]} ruleFilter фільтр правил
31
27
  * @param {Array<{ ruleId: string }>} failed правила перед кроком
32
- * @returns {Array<{ ruleId: string, ok: boolean, output: string }>} правила після T0
28
+ * @returns {Promise<Array<{ ruleId: string, ok: boolean, output: string }>>} правила після T0
33
29
  */
34
- function runT0Step(cwd, ruleFilter, failed) {
35
- spawnSync('bun', [N_CURSOR_BIN, 'fix-t0', ...ruleFilter], { cwd, stdio: 'pipe' })
30
+ async function runT0Step(cwd, ruleFilter, failed) {
31
+ await runT0AutoCli([...ruleFilter], cwd)
36
32
 
37
- const afterT0 = runFixCheck(cwd, ruleFilter)
38
- const failedAfterT0 = afterT0?.rules.filter(r => !r.ok) ?? failed
33
+ const afterT0 = await runFixCheck(ruleFilter, cwd)
34
+ const failedAfterT0 = afterT0.rules.filter(r => !r.ok)
39
35
  const t0Fixed = failed.filter(r => !failedAfterT0.some(f => f.ruleId === r.ruleId))
40
36
 
41
37
  if (t0Fixed.length > 0) {
@@ -84,12 +80,7 @@ export async function runOrchestratorCli(args, cwd) {
84
80
  const failCount = new Map()
85
81
 
86
82
  // ── Перша перевірка (тихо) ──
87
- const initial = runFixCheck(cwd, ruleFilter)
88
- if (!initial) {
89
- console.error(`❌ fix: помилка перевірки`)
90
- return 1
91
- }
92
-
83
+ const initial = await runFixCheck(ruleFilter, cwd)
93
84
  let failed = initial.rules.filter(r => !r.ok)
94
85
  const total = initial.total
95
86
 
@@ -104,14 +95,14 @@ export async function runOrchestratorCli(args, cwd) {
104
95
  if (ruleFilter.length) console.log(` filter: ${ruleFilter.join(', ')}`)
105
96
 
106
97
  for (let iter = 1; iter <= maxIter; iter++) {
107
- failed = runT0Step(cwd, ruleFilter, failed)
98
+ failed = await runT0Step(cwd, ruleFilter, failed)
108
99
  if (failed.length === 0) break
109
100
 
110
101
  await runLlmStep(failed, cwd, failCount, worker)
111
102
 
112
103
  // Перевірка після LLM
113
- const afterLLM = runFixCheck(cwd, ruleFilter)
114
- failed = afterLLM?.rules.filter(r => !r.ok) ?? failed
104
+ const afterLLM = await runFixCheck(ruleFilter, cwd)
105
+ failed = afterLLM.rules.filter(r => !r.ok)
115
106
  if (failed.length === 0) break
116
107
  }
117
108
 
@@ -123,25 +114,3 @@ export async function runOrchestratorCli(args, cwd) {
123
114
  console.log(`❌ fix: ${failed.length} невирішених — ${failed.map(r => r.ruleId).join(', ')}`)
124
115
  return 1
125
116
  }
126
-
127
- /**
128
- * Внутрішня check-gate: запускає fix-перевірки і повертає структурований результат.
129
- * Не є публічним CLI — викликається лише оркестратором.
130
- * @param {string} cwd корінь проєкту
131
- * @param {string[]} ruleFilter список ID правил (порожній — усі)
132
- * @returns {{ total: number, failed: number, rules: Array<{ ruleId: string, ok: boolean, output: string }> } | null} JSON-результат або null якщо stdout порожній/невалідний
133
- */
134
- function runFixCheck(cwd, ruleFilter = []) {
135
- const r = spawnSync('bun', [N_CURSOR_BIN, '_fix-check', ...ruleFilter], {
136
- cwd,
137
- encoding: 'utf8',
138
- timeout: 120_000
139
- })
140
- const stdout = r.stdout?.trim()
141
- if (!stdout) return null
142
- try {
143
- return JSON.parse(stdout)
144
- } catch {
145
- return null
146
- }
147
- }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Конформність-детект (колишній subcommand `_fix-check`) як ПРЯМА функція — без subprocess-обгортки
3
+ * `bun n-cursor.js _fix-check`. Викликають конформність-фаза `lint` (read-only), движок
4
+ * (`orchestrator.mjs`, `t0.mjs`) і PostToolUse-хук.
5
+ *
6
+ * Per-rule ізоляція зберігається: кожне `rules/<id>/fix.mjs` усе ще запускається окремим
7
+ * процесом `bun` (config-loading + whitelist + crash-isolation). Прибрано лише зовнішній
8
+ * wrapper-subprocess, що його раніше шелили оркестратор/хук.
9
+ */
10
+ import { spawnSync } from 'node:child_process'
11
+ import { dirname, join } from 'node:path'
12
+ import { fileURLToPath } from 'node:url'
13
+ import { cwd as processCwd } from 'node:process'
14
+
15
+ import { listRuleIds } from '../list-rule-ids.mjs'
16
+ import { ensureTool } from '../ensure-tool.mjs'
17
+ import { discoverCheckRulesFromCursorRules } from '../discover-check-rules-from-cursor.mjs'
18
+ import { listProjectRulesMdcFiles } from '../list-project-rules-mdc.mjs'
19
+
20
+ // Цей файл: npm/scripts/lib/fix/run-fix-check.mjs → npm/rules (чотири dirname угору + rules).
21
+ const BUNDLED_RULES_DIR = join(dirname(dirname(dirname(dirname(fileURLToPath(import.meta.url))))), 'rules')
22
+
23
+ /**
24
+ * Визначає id правил для прогону: явні (з валідацією) або discovery з `.cursor/rules/*.mdc`.
25
+ * @param {string[]} requestedRules запитані (порожній → discovery)
26
+ * @param {string[]} available доступні rule-id у пакеті
27
+ * @param {string} cwd корінь
28
+ * @returns {Promise<string[]>} id для прогону (можливо порожній)
29
+ * @throws {Error} на невідомих явно заданих правилах
30
+ */
31
+ async function resolveCheckRuleIds(requestedRules, available, cwd) {
32
+ if (requestedRules.length > 0) {
33
+ const unknown = requestedRules.filter(id => !available.includes(id))
34
+ if (unknown.length > 0) throw new Error(`Unknown rules: ${unknown.join(', ')}`)
35
+ return requestedRules
36
+ }
37
+ const mdcFiles = await listProjectRulesMdcFiles(cwd)
38
+ if (mdcFiles.length === 0) return []
39
+ return discoverCheckRulesFromCursorRules(available, mdcFiles)
40
+ }
41
+
42
+ /**
43
+ * Прогоняє `fix.mjs` кожного правила окремим процесом, захоплюючи output.
44
+ * @param {string[]} idsToRun правила
45
+ * @param {string} cwd корінь
46
+ * @returns {{ totalFailed:number, rules:Array<{ruleId:string, ok:boolean, output:string}> }} результат
47
+ */
48
+ function runRuleFixProcesses(idsToRun, cwd) {
49
+ let totalFailed = 0
50
+ const rules = []
51
+ for (const id of idsToRun) {
52
+ const r = spawnSync('bun', [join(BUNDLED_RULES_DIR, id, 'fix.mjs')], { cwd, encoding: 'utf8' })
53
+ const ok = r.status === 0
54
+ rules.push({ ruleId: id, ok, output: `${r.stdout ?? ''}${r.stderr ?? ''}`.trim() })
55
+ if (!ok) totalFailed++
56
+ }
57
+ return { totalFailed, rules }
58
+ }
59
+
60
+ /**
61
+ * Конформність-детект: per-rule `fix.mjs run()` (= перевірка, без мутацій).
62
+ * @param {string[]} [requestedRules] фільтр (порожній → discovery з `.cursor/rules/`)
63
+ * @param {string} [cwd] корінь
64
+ * @returns {Promise<{ total:number, failed:number, rules:Array<{ruleId:string, ok:boolean, output:string}> }>} результат
65
+ */
66
+ export async function runFixCheck(requestedRules = [], cwd = processCwd()) {
67
+ ensureTool('conftest')
68
+ const available = await listRuleIds(BUNDLED_RULES_DIR)
69
+ if (available.length === 0) return { total: 0, failed: 0, rules: [] }
70
+
71
+ const idsToRun = await resolveCheckRuleIds(requestedRules, available, cwd)
72
+ if (idsToRun.length === 0) return { total: 0, failed: 0, rules: [] }
73
+
74
+ const { totalFailed, rules } = runRuleFixProcesses(idsToRun, cwd)
75
+ return { total: idsToRun.length, failed: totalFailed, rules }
76
+ }
@@ -1,8 +1,8 @@
1
1
  /** @see ./docs/t0.md */
2
2
  import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
3
- import { dirname, join } from 'node:path'
4
- import { spawnSync } from 'node:child_process'
5
- import { fileURLToPath } from 'node:url'
3
+ import { join } from 'node:path'
4
+
5
+ import { runFixCheck } from './run-fix-check.mjs'
6
6
 
7
7
  const REC_REQUIRE_RE = /recommendations має містити "[^"]+"/
8
8
  const REC_MATCH_ALL_RE = /recommendations має містити "([^"]+)"/g
@@ -109,27 +109,6 @@ export function filterT0AutoRules(failedRules) {
109
109
 
110
110
  // ─── CLI entry-point ──────────────────────────────────────────────────────────
111
111
 
112
- const HERE = dirname(fileURLToPath(import.meta.url))
113
- /** Абсолютний шлях до npm/bin/n-cursor.js відносно цього файлу */
114
- const N_CURSOR_BIN = join(HERE, '../../../bin/n-cursor.js')
115
-
116
- /**
117
- * Запускає `_fix-check` і парсить JSON-результат.
118
- * @param {string[]} ruleFilter список rule-ids (порожній — усі)
119
- * @param {string} cwd корінь проєкту
120
- * @returns {{ rules: Array<{ ruleId: string, ok: boolean, output: string }> } | { _empty: true } | { _badJson: true }} JSON або маркер помилки
121
- */
122
- function fixCheck(ruleFilter, cwd) {
123
- const r = spawnSync('bun', [N_CURSOR_BIN, '_fix-check', ...ruleFilter], { cwd, encoding: 'utf8', timeout: 120_000 })
124
- const raw = r.stdout?.trim()
125
- if (!raw) return { _empty: true, stderr: r.stderr }
126
- try {
127
- return JSON.parse(raw)
128
- } catch {
129
- return { _badJson: true }
130
- }
131
- }
132
-
133
112
  /**
134
113
  * Застосовує T0-auto до кожного провального правила, розділяючи на applied/skipped.
135
114
  * @param {Array<{ ruleId: string, output: string }>} failed провальні правила
@@ -154,26 +133,16 @@ function applyT0ToFailed(failed, cwd) {
154
133
  * CLI підкоманда `n-cursor fix-t0 [rule...]`.
155
134
  * Запускає `fix --json`, застосовує T0-auto для кожного violation,
156
135
  * повторно перевіряє check-gate, виводить підсумок.
157
- * @param {string[]} args аргументи підкоманди (опційний список rule-ids)
136
+ * @param {string[]} args аргументи (опційний список rule-ids)
158
137
  * @param {string} cwd корінь проєкту
159
138
  * @returns {Promise<number>} 0 — T0-auto закрив всі або немає порушень; 1 — лишились
160
139
  */
161
- export function runT0AutoCli(args, cwd) {
140
+ export async function runT0AutoCli(args, cwd) {
162
141
  const ruleFilter = args.filter(a => !a.startsWith('--'))
163
142
  const verbose = args.includes('--verbose') || args.includes('-v')
164
143
 
165
- // 1. Запустити fix --json
166
- const fixJson = fixCheck(ruleFilter, cwd)
167
- if (fixJson._empty) {
168
- console.error(`n-cursor fix-t0: fix --json повернув порожній stdout`)
169
- console.error(fixJson.stderr?.slice(0, 300) ?? '')
170
- return 1
171
- }
172
- if (fixJson._badJson) {
173
- console.error(`n-cursor fix-t0: fix --json повернув невалідний JSON`)
174
- return 1
175
- }
176
-
144
+ // 1. Конформність-детект (пряма функція, без subprocess)
145
+ const fixJson = await runFixCheck(ruleFilter, cwd)
177
146
  const failed = fixJson.rules.filter(r => !r.ok)
178
147
  if (failed.length === 0) {
179
148
  console.log(`✅ fix-t0: всі правила чисті — T0 не потрібен`)
@@ -195,11 +164,7 @@ export function runT0AutoCli(args, cwd) {
195
164
  }
196
165
 
197
166
  // 4. Check-gate: перевірити лише ті правила, що ми чіпали
198
- const recheckJson = fixCheck(applied.map(a => a.ruleId), cwd)
199
- if (recheckJson._empty) {
200
- console.error(`fix-t0: check-gate: fix --json повернув порожній stdout`)
201
- return 1
202
- }
167
+ const recheckJson = await runFixCheck(applied.map(a => a.ruleId), cwd)
203
168
  const stillFailed = recheckJson.rules.filter(r => !r.ok)
204
169
 
205
170
  if (verbose) {
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Список `.mdc`-файлів правил у `.cursor/rules/` проєкту-споживача (відсортований).
3
+ * Винесено зі `bin/n-cursor.js`, щоб ділити між CLI-dispatch і `run-fix-check` (конформність-детект).
4
+ */
5
+ import { existsSync } from 'node:fs'
6
+ import { readdir } from 'node:fs/promises'
7
+ import { join } from 'node:path'
8
+ import { cwd as processCwd } from 'node:process'
9
+
10
+ /** Каталог правил у проєкті-споживачі (відносно кореня). */
11
+ export const CURSOR_RULES_DIR = '.cursor/rules'
12
+
13
+ /**
14
+ * @param {string} [cwd] корінь проєкту
15
+ * @returns {Promise<string[]>} імена `*.mdc` (відсортовані), або `[]` якщо каталогу немає
16
+ */
17
+ export async function listProjectRulesMdcFiles(cwd = processCwd()) {
18
+ const dir = join(cwd, CURSOR_RULES_DIR)
19
+ if (!existsSync(dir)) return []
20
+ const names = await readdir(dir)
21
+ return names.filter(n => n.endsWith('.mdc')).toSorted((a, b) => a.localeCompare(b))
22
+ }
@@ -8,11 +8,13 @@
8
8
  *
9
9
  * Контракт:
10
10
  * - stdin Claude Code: JSON із `tool_input.file_path`; якщо файлу немає (напр. Bash) — exit 0 (skip);
11
- * - інакше spawn `_fix-check` (детект усіх правил), exit-код прозоро пробрасуємо (PostToolUse
12
- * не блокує turn, але код лишаємо інформативним: 1 — є порушення конформності).
11
+ * - інакше пряма `runFixCheck` (детект усіх правил, без subprocess-обгортки), exit-код прозоро:
12
+ * 1 — є порушення конформності (PostToolUse не блокує turn, але код лишаємо інформативним).
13
13
  */
14
- import { spawn } from 'node:child_process'
15
14
  import { once } from 'node:events'
15
+ import { cwd as processCwd } from 'node:process'
16
+
17
+ import { runFixCheck } from './lib/fix/run-fix-check.mjs'
16
18
 
17
19
  /**
18
20
  * Зчитує stdin до EOF як utf8 рядок. На TTY — повертає `''` одразу.
@@ -57,9 +59,9 @@ export function extractFilePath(stdinJson) {
57
59
  /**
58
60
  * Точка входу. Викликається з `bin/n-cursor.js` коли argv[0] === `post-tool-use-fix`.
59
61
  * Параметри доступні для інʼєкції для тестів: `stdinJson` обходить read від `process.stdin`,
60
- * `spawnFn` — заміна `node:child_process.spawn`.
61
- * @param {{ stdinJson?: string, spawnFn?: typeof spawn }} [options] параметри для тестів
62
- * @returns {Promise<number>} exit code (0 — пропущено / конформність ОК; інше — є порушення)
62
+ * `runFixCheckFn` — заміна `runFixCheck`.
63
+ * @param {{ stdinJson?: string, runFixCheckFn?: typeof runFixCheck }} [options] параметри для тестів
64
+ * @returns {Promise<number>} exit code (0 — пропущено / конформність ОК; 1 — є порушення)
63
65
  */
64
66
  export async function runPostToolUseFixCli(options = {}) {
65
67
  const stdinJson = options.stdinJson ?? (await readStdin())
@@ -68,12 +70,15 @@ export async function runPostToolUseFixCli(options = {}) {
68
70
  if (filePath === null) {
69
71
  return 0
70
72
  }
71
- const spawnFn = options.spawnFn ?? spawn
72
- // Один read-only виклик: детект конформності всіх активованих правил, без роутингу.
73
- const child = spawnFn('npx', ['--no', '@nitra/cursor', '_fix-check'], { stdio: 'inherit' })
73
+ const check = options.runFixCheckFn ?? runFixCheck
74
+ // Один read-only детект конформності всіх активованих правил (пряма функція, без subprocess).
74
75
  try {
75
- const [code] = await once(child, 'exit')
76
- return code ?? 1
76
+ const { failed, rules } = await check([], processCwd())
77
+ if (failed === 0) return 0
78
+ for (const r of rules.filter(x => !x.ok)) {
79
+ if (r.output) process.stderr.write(`${r.output}\n`)
80
+ }
81
+ return 1
77
82
  } catch (error) {
78
83
  process.stderr.write(`post-tool-use-fix: не вдалося запустити детект конформності — ${error.message}\n`)
79
84
  return 1