@nitra/cursor 12.15.0 → 12.15.1

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.
@@ -47,54 +47,71 @@ function extractFailPaths(output) {
47
47
  }
48
48
 
49
49
  /**
50
- * Для правил з `policy/<concern>/` підбирає лише ті concern-.mdc, що відповідають
51
- * файлам з ❌-рядків violation output. Читає безпосередньо з пакету (`PACKAGE_RULES_DIR`),
52
- * тому не потребує pre-generation. Fallbacknull (→ повний n-{id}.mdc у caller).
50
+ * Збирає .mdc-контекст правила з двох джерел у пакеті (`PACKAGE_RULES_DIR`):
51
+ * - `js/<check>.mdc` — описи check-логіки (завжди всі, відсортовані за іменем);
52
+ * - `policy/<c>/<c>.mdc`concern-специфічний контент: вибираємо лише ті concern/.mdc,
53
+ * чий `target.json → files.single` збігається з failing paths
54
+ * у violation output; якщо жоден не збігається — включаємо всі.
53
55
  * @param {string} ruleId ID правила
54
56
  * @param {string} violationOutput violation output
55
- * @returns {string|null} конкатенація релевантних .mdc або null
57
+ * @returns {string|null} конкатенація зібраних .mdc або null, якщо нічого не знайдено
56
58
  */
57
- function selectSubCheckMdc(ruleId, violationOutput) {
58
- const policyDir = join(PACKAGE_RULES_DIR, ruleId, 'policy')
59
- if (!existsSync(policyDir)) return null
60
-
61
- const failPaths = extractFailPaths(violationOutput)
62
- if (failPaths.length === 0) return null
63
-
64
- const matched = []
65
- let concerns
66
- try {
67
- concerns = readdirSync(policyDir, { withFileTypes: true })
68
- } catch {
69
- return null
59
+ function readRuleMdc(ruleId, violationOutput) {
60
+ const ruleDir = join(PACKAGE_RULES_DIR, ruleId)
61
+ const parts = []
62
+
63
+ // 1. js/**/*.mdc — завжди всі
64
+ const jsDir = join(ruleDir, 'js')
65
+ if (existsSync(jsDir)) {
66
+ let jsFiles
67
+ try {
68
+ jsFiles = readdirSync(jsDir)
69
+ .filter(f => f.endsWith('.mdc'))
70
+ .sort()
71
+ } catch {
72
+ jsFiles = []
73
+ }
74
+ for (const f of jsFiles) parts.push(readFileSync(join(jsDir, f), 'utf8').trim())
70
75
  }
71
76
 
72
- for (const entry of concerns) {
73
- if (!entry.isDirectory()) continue
74
- const concernDir = join(policyDir, entry.name)
75
- const targetPath = join(concernDir, 'target.json')
76
- if (!existsSync(targetPath)) continue
77
-
78
- let target
77
+ // 2. policy/**/*.mdc matched через target.json; fallback — всі
78
+ const policyDir = join(ruleDir, 'policy')
79
+ if (existsSync(policyDir)) {
80
+ const failPaths = extractFailPaths(violationOutput)
81
+ let concerns
79
82
  try {
80
- target = JSON.parse(readFileSync(targetPath, 'utf8'))
83
+ concerns = readdirSync(policyDir, { withFileTypes: true })
81
84
  } catch {
82
- continue
85
+ concerns = []
83
86
  }
84
87
 
85
- const targetFile = target?.files?.single
86
- if (!targetFile) continue // walkGlob та інші типи → skip, fallback на main.mdc
87
-
88
- // Перевіряємо чи хоч один failing path закінчується на targetFile
89
- const hit = failPaths.some(p => p === targetFile || p.endsWith(`/${targetFile}`))
90
- if (!hit) continue
88
+ const all = []
89
+ const matched = []
90
+ for (const entry of concerns) {
91
+ if (!entry.isDirectory()) continue
92
+ const concernDir = join(policyDir, entry.name)
93
+ const mdcEntry = readdirSync(concernDir).find(f => f.endsWith('.mdc'))
94
+ if (!mdcEntry) continue
95
+ const content = readFileSync(join(concernDir, mdcEntry), 'utf8').trim()
96
+ all.push(content)
97
+
98
+ const targetPath = join(concernDir, 'target.json')
99
+ if (!existsSync(targetPath)) continue
100
+ let target
101
+ try {
102
+ target = JSON.parse(readFileSync(targetPath, 'utf8'))
103
+ } catch {
104
+ continue
105
+ }
106
+ const targetFile = target?.files?.single
107
+ if (!targetFile) continue
108
+ if (failPaths.some(p => p === targetFile || p.endsWith(`/${targetFile}`))) matched.push(content)
109
+ }
91
110
 
92
- const mdcEntry = readdirSync(concernDir).find(f => f.endsWith('.mdc'))
93
- if (!mdcEntry) continue
94
- matched.push(readFileSync(join(concernDir, mdcEntry), 'utf8').trim())
111
+ parts.push(...(matched.length > 0 ? matched : all))
95
112
  }
96
113
 
97
- return matched.length > 0 ? matched.join('\n\n') : null
114
+ return parts.length > 0 ? parts.join('\n\n') : null
98
115
  }
99
116
 
100
117
  /**
@@ -252,7 +269,8 @@ function callModel(prompt, model, caller, timeoutMs, thinkingBudget) {
252
269
  * `model` — перевизначення моделі; `feedback` — контекст попереднього рунга
253
270
  * драбини (retry-with-feedback); `caller` — мітка для wire-trace; `timeoutMs` —
254
271
  * per-tier ліміт виклику (драбина: локалі fail-fast, хмара повний);
255
- * `thinkingBudget` — кількість thinking-токенів для omlx (дефолт `DEFAULT_THINKING_BUDGET`)
272
+ * `thinkingBudget` — кількість thinking-токенів для omlx (дефолт `DEFAULT_THINKING_BUDGET`).
273
+ * `timeoutMs` — per-tier ліміт: локальні 300s (4b повільна, backstop — turn-ceiling), хмарні 120s.
256
274
  * @returns {{ ok: boolean, error?: string, changes: Array<{path:string}>, diagnosis: string|null, reasoning: string|null, reasoningSource: string|null, promptSummary: object }}
257
275
  */
258
276
  export function runLlmWorker(ruleId, violationOutput, projectRoot, opts = {}) {
@@ -262,11 +280,8 @@ export function runLlmWorker(ruleId, violationOutput, projectRoot, opts = {}) {
262
280
  const timeoutMs = opts.timeoutMs
263
281
  const thinkingBudget = opts.thinkingBudget ?? DEFAULT_THINKING_BUDGET
264
282
 
265
- // 1. Читаємо rule .mdc: спробуємо sub-check mdc для конкретної перевірки,
266
- // якщо не вдалося fallback на повний n-{id}.mdc.
267
- const subMdc = selectSubCheckMdc(ruleId, violationOutput)
268
- const mdcPath = join(projectRoot, '.cursor', 'rules', `n-${ruleId}.mdc`)
269
- const ruleMdc = subMdc ?? (existsSync(mdcPath) ? readFileSync(mdcPath, 'utf8') : '(rule file not found)')
283
+ // 1. Читаємо rule .mdc з джерела пакету: js/**/*.mdc + policy/**/*.mdc.
284
+ const ruleMdc = readRuleMdc(ruleId, violationOutput) ?? '(rule file not found)'
270
285
 
271
286
  // 2. Витягуємо файли з violation output і читаємо їх
272
287
  const files = readFilesForFix(extractFilePaths(violationOutput), projectRoot)
@@ -274,7 +289,6 @@ export function runLlmWorker(ruleId, violationOutput, projectRoot, opts = {}) {
274
289
  // 3. Будуємо summary промпту (для verbose-блоку) до виклику моделі
275
290
  const promptSummary = {
276
291
  ruleMdcLen: ruleMdc.length,
277
- subCheckMdc: !!subMdc,
278
292
  violationLen: violationOutput.length,
279
293
  filesCount: files.length,
280
294
  filesTotalBytes: files.reduce((s, f) => s + f.content.length, 0),
@@ -16,11 +16,12 @@ import { CLOUD_AVG, CLOUD_MIN, LOCAL_MIN } from '../../../lib/models.mjs'
16
16
  const DEFAULT_MAX_AVG = 3
17
17
 
18
18
  /**
19
- * Timeout одного LLM-виклику за тиром. Локальні рунги **fail-fast**: не палити
20
- * стіну 120s на повільному 4b (curl exit 28)швидше абортнути й ескалувати.
21
- * Хмарні — повний. Перевизначення: `N_LOCAL_FIX_TIMEOUT_MS` / `N_CLOUD_FIX_TIMEOUT_MS`.
19
+ * Timeout одного LLM-виклику (або всієї агентної сесії) за тиром.
20
+ * Локальні рунги 5 хвилин: 4b модель повільна, основний backstop turn-ceiling (~50).
21
+ * Хмарні — 2 хвилини: API-виклик швидкий, перевищення = transport-помилка.
22
+ * Перевизначення: `N_LOCAL_FIX_TIMEOUT_MS` / `N_CLOUD_FIX_TIMEOUT_MS`.
22
23
  */
23
- const LOCAL_TIMEOUT_MS = Number(env.N_LOCAL_FIX_TIMEOUT_MS) || 45_000
24
+ const LOCAL_TIMEOUT_MS = Number(env.N_LOCAL_FIX_TIMEOUT_MS) || 300_000
24
25
  const CLOUD_TIMEOUT_MS = Number(env.N_CLOUD_FIX_TIMEOUT_MS) || 120_000
25
26
 
26
27
  /** Маркер дружнього повідомлення про відсутній API-ключ (з `llm-worker.callModel`). */
@@ -166,22 +166,14 @@ async function runPerFileRules(ids, ctx) {
166
166
  }
167
167
 
168
168
  /**
169
- * Конформність-фаза `--full` (поглинула `fix`): escalation-аналітику обрамляє зсувом логу
170
- * (записи саме цього прогону), у fix-режимі по конформності викликає аналіз.
169
+ * Конформність-фаза `--full`.
171
170
  * @param {string} cwd корінь
172
171
  * @param {boolean} readOnly лише детект
173
172
  * @param {(s: string) => void} log логер
174
173
  * @returns {Promise<number>} код конформності
175
174
  */
176
175
  async function runFullConformancePhase(cwd, readOnly, log) {
177
- const { escalationLogSize, maybeAnalyzeEscalation, reportRunStats } = await import('./fix/analyze-escalation.mjs')
178
- const escOffset = readOnly ? 0 : escalationLogSize()
179
- const conformanceCode = await runConformance(cwd, readOnly, log)
180
- if (!readOnly) {
181
- reportRunStats(escOffset, log) // резюме викликів моделей (локальна / cloud-min / cloud-avg)
182
- maybeAnalyzeEscalation(cwd, escOffset, log)
183
- }
184
- return conformanceCode
176
+ return runConformance(cwd, readOnly, log)
185
177
  }
186
178
 
187
179
  /**
@@ -3,34 +3,31 @@ type: JS Module
3
3
  title: walkDir.mjs
4
4
  resource: npm/scripts/utils/walkDir.mjs
5
5
  docgen:
6
- crc: 5e5fec27
6
+ crc: 73503ca2
7
+ model: omlx/gemma-4-e4b-it-OptiQ-4bit
8
+ score: 100
7
9
  ---
8
10
 
9
- Файл `n-cursor.js` забезпечує рекурсивний обхід каталогів для скриптів перевірки, дозволяючи виконувати callback-функцію для кожного звичайного файлу. Він обходить дерево каталогів від заданого кореня, ігноруючи певні директорії (наприклад, `.git`, `node_modules`) та шляхи, вказані в `ignorePaths`. Це дозволяє автоматизувати процес аналізу конфігураційних файлів та інших ресурсів у проєкті.
11
+ ## Огляд
12
+
13
+ Публічна функція `walkDir` здійснює рекурсивний пошук файлів у вказаному каталозі. Вона обходить файлову систему, ігноруючи каталоги `.git` та `node_modules`. Знаходячи кожен файл, вона передає його повний шлях у колбек для подальшої обробки. Компонент є read-only, тобто не виконує записів у файлову систему чи бази даних. При роботі з файловою системою він перехоплює помилки, забезпечуючи безпечну роботу без викидання винятків.
10
14
 
11
15
  ## Поведінка
12
16
 
13
- 1. Починається з заданого кореневого каталогу.
14
- 2. Для кожного підкаталогу в кореневому каталозі:
15
- 2.1. Перевіряє, чи є підкаталог у списку `ignorePaths`. Якщо так, пропускає обхід цього підкаталогу та його вмісту.
16
- 2.2. Якщо підкаталог не в списку `ignorePaths`, обробляє вміст підкаталогу:
17
- 2.2.1. Для кожного файлу в підкаталозі, викликає функцію `onFile` з шляхом до файлу.
18
- 2.2.2. Для кожного підкаталогу в підкаталозі, рекурсивно викликає функцію `walkDir` для обходу цього підкаталогу. Під час рекурсивного виклику, також перевіряє, чи є підкаталог у списку `ignorePaths`.
19
- 3. Не обробляє каталоги `node_modules`, `.git`, `dist`, `coverage`, `.turbo` та `.next`.
20
- 4. Якщо при спробі `readdir` для каталогу виникає помилка, обхід цього каталогу припиняється.
21
- 5. Обхід завершується, коли всі підкаталоги та файли в заданому дереві були оброблені.
17
+ 1. Викликається функція walkDir для початку рекурсивного обходу каталогу.
18
+ 2. Система розшифровує вхідний шлях до кореня обходу.
19
+ 3. Система формує додаткові правила ігнорування на основі наданих шляхів.
20
+ 4. Система завжди ігнорує каталоги .git та node_modules.
21
+ 5. Система виконує пошук усіх файлів у каталозі, застосовуючи правила ігнорування, включаючи ті, що визначені в .gitignore.
22
+ 6. У разі виникнення помилки під час пошуку, процес обходу припиняється без генерації винятку.
23
+ 7. Для кожного знайденого файлу система викликає наданий колбек, передаючи йому повний абсолютний шлях до цього файлу.
22
24
 
23
25
  ## Публічний API
24
26
 
25
- - walkDir — Обходить каталог рекурсивно, ігноруючи вказані шляхи.
27
+ walkDir — рекурсивно переглядає файли та папки, ігноруючи ті, що вказані у файлі `.gitignore`.
26
28
 
27
29
  ## Гарантії поведінки
28
30
 
29
- - Функція рекурсивно обходить дерево каталогів, починаючи з заданого кореня.
30
- - Для кожного звичайного файлу викликається переданий callback.
31
- - Не обходить каталоги `node_modules`, `.git`, `dist`, `coverage`, `.turbo`, `.next`.
32
- - Може пропустити каталоги, вказані в `ignorePaths`.
33
- - У разі невдачі `readdir` для каталогу, функція тихо виходить без викидання помилок.
34
- - Функція не викидає винятки назовні.
35
- - У разі невдачі повертає `false` або `null`.
36
- - Не використовує кешування.
31
+ - Read-only: не виконує операцій запису (ФС/БД).
32
+ - Перехоплює помилки і не пропускає винятків назовні (fail-safe).
33
+ - Свідомо пропускає шляхи: `.git`, `node_modules`.
@@ -1,353 +0,0 @@
1
- /**
2
- * Аналітика escalation-логу (спека 2026-06-19-fix-escalation-analysis-design).
3
- *
4
- * Читає записи рунгів драбини (`escalation-log.mjs`) — за один прогін (від байтового
5
- * зсуву) або весь лог, — ділить на чанки за бюджетом символів і просить хмарну
6
- * **avg**-модель проаналізувати: як зменшити LLM-залежність fix-конформності.
7
- * Мета аналізу — конкретні правки пакета `@nitra/cursor`:
8
- * (A) новий ДЕТЕРМІНОВАНИЙ T0-патерн (`t0.mjs`) — прибирає LLM зовсім;
9
- * (B) уточнення `.mdc`-інструкцій правила, щоб локальна min-модель влучала з першого рунга;
10
- * (C) зміна скрипта/чека в пакеті.
11
- * Результат — markdown-звіт у `.n-cursor/fix-escalation-analysis.md` (append із timestamp).
12
- *
13
- * Викликається CLI `n-cursor analyze-escalation` (весь лог) і наприкінці `lint --full`
14
- * (записи цього прогону). Кожен виклик моделі йде через спільний `callLlm` (wire-trace).
15
- */
16
- import { appendFileSync, mkdirSync, readFileSync, statSync } from 'node:fs'
17
- import { dirname, join } from 'node:path'
18
- import { cwd as processCwd, env } from 'node:process'
19
-
20
- import { callLlm } from '../../../lib/llm.mjs'
21
- import { CLOUD_AVG } from '../../../lib/models.mjs'
22
- import { escalationLogPath } from './escalation-log.mjs'
23
-
24
- /** Значення `N_CURSOR_FIX_ANALYZE`, що вимикають авто-аналіз наприкінці lint. */
25
- const KILL_VALUES = new Set(['0', 'false', 'off', 'no'])
26
- /** Бюджет символів на один чанк (щоб великий лог не перевищив контекст моделі). */
27
- const DEFAULT_CHUNK_CHARS = 40_000
28
- /** Timeout одного аналітичного виклику (мс) — аналіз може бути об'ємним. */
29
- const ANALYZE_TIMEOUT_MS = 180_000
30
-
31
- /** No-op логер за замовчуванням. */
32
- const NOOP_LOG = () => {
33
- /* тихо */
34
- }
35
-
36
- /**
37
- * Спільна мета-інструкція для аналітичних викликів.
38
- */
39
- const GOAL = [
40
- `You analyze logs from @nitra/cursor's automated rule-conformance fixer ("fix").`,
41
- `Each record = one attempt by a model to fix a rule-conformance violation on a rung of`,
42
- `an escalation ladder. Fields: ruleId; tier (local-min|local-min-retry|cloud-min|cloud-avg);`,
43
- `model; callOk (model call+apply succeeded); recheckOk (rule PASSED after this rung — "did it help");`,
44
- `callError; diagnosis (model's self-stated reason a prior attempt failed); remainingViolation.`,
45
- ``,
46
- `Goal: reduce LLM dependence and time-to-green. For RECURRING patterns recommend CONCRETE changes`,
47
- `to the @nitra/cursor package, in priority order:`,
48
- `(A) a new DETERMINISTIC T0-auto pattern for npm/scripts/lib/fix/t0.mjs — give a regex that matches`,
49
- ` the violation output + the mechanical fix. PREFERRED: removes the LLM entirely.`,
50
- `(B) a clarification to a rule's .mdc instructions so the LOCAL min-model succeeds on the FIRST rung.`,
51
- `(C) a script/check change elsewhere in the package.`,
52
- `Prioritise rules that escalated to cloud (cloud-min/cloud-avg) or failed repeatedly — they cost most.`,
53
- `Ignore rules resolved at local-min with no retry — they already work.`
54
- ].join('\n')
55
-
56
- /**
57
- * Чи увімкнено авто-аналіз наприкінці lint (default — так; kill-switch `N_CURSOR_FIX_ANALYZE`).
58
- * @returns {boolean} true, якщо аналіз дозволено
59
- */
60
- export function analysisEnabled() {
61
- const v = env.N_CURSOR_FIX_ANALYZE
62
- if (v === undefined) return true
63
- return !KILL_VALUES.has(v.toLowerCase())
64
- }
65
-
66
- /**
67
- * Розмір escalation-логу в байтах (0, якщо файлу немає/вимкнено) — для since-offset.
68
- * @param {string|null} [path] шлях логу
69
- * @returns {number} розмір у байтах
70
- */
71
- export function escalationLogSize(path = escalationLogPath()) {
72
- if (!path) return 0
73
- try {
74
- return statSync(path).size
75
- } catch {
76
- return 0
77
- }
78
- }
79
-
80
- /**
81
- * Читає записи escalation-логу від байтового зсуву (default 0 — весь лог). Зсув завжди
82
- * на межі рядка (захоплюється після завершеного append), тож мультибайтні символи не б'ються.
83
- * Биті JSON-рядки пропускаються.
84
- * @param {string|null} path шлях логу
85
- * @param {number} [sinceOffset] байтовий зсув початку читання
86
- * @returns {object[]} розпарсені записи
87
- */
88
- export function readEscalationRecords(path, sinceOffset = 0) {
89
- if (!path) return []
90
- let buf
91
- try {
92
- buf = readFileSync(path)
93
- } catch {
94
- return []
95
- }
96
- const text = (sinceOffset > 0 ? buf.subarray(sinceOffset) : buf).toString('utf8')
97
- const out = []
98
- for (const line of text.split('\n')) {
99
- const t = line.trim()
100
- if (!t) continue
101
- try {
102
- out.push(JSON.parse(t))
103
- } catch {
104
- /* битий рядок — пропускаємо */
105
- }
106
- }
107
- return out
108
- }
109
-
110
- /** Маркер skip-запису avg-рунга (кеп вичерпано) — НЕ фактичний виклик моделі. */
111
- const AVG_SKIP_MARKER = 'cloud-avg cap reached'
112
-
113
- /**
114
- * Рахує фактичні виклики моделей за тирами (skip-записи avg-кепу не рахуються).
115
- * @param {object[]} records записи рунгів
116
- * @returns {{ local: number, cloudMin: number, cloudAvg: number }} лічильники викликів
117
- */
118
- export function summarizeCalls(records) {
119
- const stats = { local: 0, cloudMin: 0, cloudAvg: 0 }
120
- for (const r of records) {
121
- if (r.callError === AVG_SKIP_MARKER) continue
122
- if (r.tier === 'cloud-avg') stats.cloudAvg++
123
- else if (r.tier === 'cloud-min') stats.cloudMin++
124
- else if (typeof r.tier === 'string' && r.tier.startsWith('local')) stats.local++
125
- }
126
- return stats
127
- }
128
-
129
- /**
130
- * Друкує резюме викликів моделей за цей прогін (локальна / cloud-min / cloud-avg).
131
- * No-op, якщо викликів не було. Читає записи від `sinceOffset`.
132
- * @param {number} sinceOffset байтовий зсув логу перед прогоном
133
- * @param {(s: string) => void} log логер
134
- * @returns {void}
135
- */
136
- export function reportRunStats(sinceOffset, log) {
137
- const { local, cloudMin, cloudAvg } = summarizeCalls(readEscalationRecords(escalationLogPath(), sinceOffset))
138
- if (local + cloudMin + cloudAvg === 0) return
139
- log(
140
- `\n📊 LLM-виклики fix-конформності (цей прогін): ` +
141
- `локальна ${local} · cloud-min ${cloudMin} · cloud-avg ${cloudAvg}\n`
142
- )
143
- }
144
-
145
- /**
146
- * Стискає запис до полів, важливих для аналізу (без ts/ms-шуму).
147
- * @param {object} r сирий запис рунга
148
- * @returns {object} компактний запис
149
- */
150
- function summarizeRecord(r) {
151
- return {
152
- ruleId: r.ruleId,
153
- tier: r.tier,
154
- model: r.model,
155
- callOk: r.callOk,
156
- recheckOk: r.recheckOk,
157
- callError: r.callError ?? null,
158
- diagnosis: r.diagnosis ?? null,
159
- remainingViolation: r.remainingViolation ?? null
160
- }
161
- }
162
-
163
- /**
164
- * Ділить записи на чанки так, щоб JSON кожного чанка не перевищував `maxChars`.
165
- * Працює на стиснених записах (саме вони йдуть у prompt).
166
- * @param {object[]} records сирі записи
167
- * @param {number} [maxChars] бюджет символів на чанк
168
- * @returns {object[][]} чанки стиснених записів
169
- */
170
- export function chunkRecords(records, maxChars = DEFAULT_CHUNK_CHARS) {
171
- const items = records.map(r => summarizeRecord(r))
172
- const chunks = []
173
- let cur = []
174
- let size = 0
175
- for (const it of items) {
176
- const len = JSON.stringify(it).length + 1
177
- if (cur.length > 0 && size + len > maxChars) {
178
- chunks.push(cur)
179
- cur = []
180
- size = 0
181
- }
182
- cur.push(it)
183
- size += len
184
- }
185
- if (cur.length > 0) chunks.push(cur)
186
- return chunks
187
- }
188
-
189
- /**
190
- * Prompt для аналізу одного чанка.
191
- * @param {object[]} items стиснені записи чанка
192
- * @param {number} idx індекс чанка (0-based)
193
- * @param {number} total всього чанків
194
- * @returns {string} текст prompt
195
- */
196
- function buildChunkPrompt(items, idx, total) {
197
- return [
198
- GOAL,
199
- ``,
200
- `Log chunk ${idx + 1}/${total} (${items.length} records):`,
201
- JSON.stringify(items),
202
- ``,
203
- `Return concise markdown: per recommendation — target ruleId, type (A/B/C), and the concrete change`,
204
- `(for A: the regex + mechanical fix; for B: the exact .mdc clarification; for C: the script change).`
205
- ].join('\n')
206
- }
207
-
208
- /**
209
- * Prompt для злиття часткових аналізів у фінальний звіт.
210
- * @param {string[]} partials часткові аналізи чанків
211
- * @returns {string} текст prompt
212
- */
213
- function buildSynthesisPrompt(partials) {
214
- return [
215
- GOAL,
216
- ``,
217
- `Below are partial analyses of separate log chunks. Merge, dedupe and prioritise into ONE report.`,
218
- ``,
219
- partials.map((p, i) => `--- chunk ${i + 1} ---\n${p}`).join('\n\n'),
220
- ``,
221
- `Return the final markdown report: recommendations ordered highest-impact first.`
222
- ].join('\n')
223
- }
224
-
225
- /**
226
- * Безпечний виклик моделі: ковтає помилку у `null` (аналіз не має валити lint).
227
- * @param {(messages: object[], model: string, opts: object) => string} call функція callLlm
228
- * @param {string} prompt текст prompt
229
- * @param {string} model model-id
230
- * @returns {string|null} текст відповіді або null
231
- */
232
- function safeCall(call, prompt, model) {
233
- try {
234
- const text = call([{ role: 'user', content: prompt }], model, {
235
- timeoutMs: ANALYZE_TIMEOUT_MS,
236
- caller: 'fix-analyze'
237
- })
238
- return text || null
239
- } catch {
240
- return null
241
- }
242
- }
243
-
244
- /**
245
- * Аналізує записи: чанкінг → виклик avg-моделі по чанках → синтез (якщо чанків >1).
246
- * Синхронний (callLlm — spawnSync-based).
247
- * @param {object[]} records записи escalation-логу
248
- * @param {{ model?: string, callLlm?: (messages: object[], model: string, opts: object) => string, log?: (s: string) => void, maxChars?: number }} [opts]
249
- * `model` — модель (default `CLOUD_AVG`); `callLlm` — інжекція для тестів; `log` — логер
250
- * @returns {{ report: string|null, chunks: number, reason: string }} звіт і метадані
251
- */
252
- export function analyzeEscalations(records, opts = {}) {
253
- const model = opts.model ?? CLOUD_AVG
254
- const call = opts.callLlm ?? callLlm
255
- const log = opts.log ?? NOOP_LOG
256
- const maxChars = opts.maxChars ?? DEFAULT_CHUNK_CHARS
257
-
258
- if (records.length === 0) return { report: null, chunks: 0, reason: 'no-records' }
259
- if (!model) return { report: null, chunks: 0, reason: 'no-cloud-avg-model' }
260
-
261
- const chunks = chunkRecords(records, maxChars)
262
- const partials = []
263
- for (const [i, chunk] of chunks.entries()) {
264
- log(` 🔎 escalation-analysis: чанк ${i + 1}/${chunks.length} (${chunk.length} записів)`)
265
- const text = safeCall(call, buildChunkPrompt(chunk, i, chunks.length), model)
266
- if (text) partials.push(text)
267
- }
268
-
269
- if (partials.length === 0) return { report: null, chunks: chunks.length, reason: 'empty-responses' }
270
- const report = partials.length === 1 ? partials[0] : safeCall(call, buildSynthesisPrompt(partials), model)
271
- return { report, chunks: chunks.length, reason: report ? 'ok' : 'empty-responses' }
272
- }
273
-
274
- /**
275
- * Шлях markdown-звіту аналізу.
276
- * @param {string} [cwd] корінь
277
- * @returns {string} шлях .n-cursor/fix-escalation-analysis.md
278
- */
279
- export function analysisReportPath(cwd = processCwd()) {
280
- return join(cwd, '.n-cursor', 'fix-escalation-analysis.md')
281
- }
282
-
283
- /**
284
- * Дописує звіт у markdown-файл із timestamp-заголовком.
285
- * @param {string} report текст звіту
286
- * @param {string} cwd корінь
287
- * @param {string} ts ISO-час
288
- * @returns {string} шлях файлу
289
- */
290
- export function writeAnalysisReport(report, cwd, ts) {
291
- const path = analysisReportPath(cwd)
292
- mkdirSync(dirname(path), { recursive: true })
293
- appendFileSync(path, `\n## Аналіз ${ts}\n\n${report}\n`, 'utf8')
294
- return path
295
- }
296
-
297
- /**
298
- * Спільний шлях: аналіз записів → запис звіту → лог-підсумок.
299
- * @param {object[]} records записи
300
- * @param {string} cwd корінь
301
- * @param {(s: string) => void} log логер
302
- * @returns {number} 0 — ок/пропуск, 1 — модель не дала звіт
303
- */
304
- function analyzeAndReport(records, cwd, log) {
305
- const res = analyzeEscalations(records, { log })
306
- if (res.reason === 'no-records') {
307
- log('ℹ️ escalation-analysis: немає записів для аналізу.')
308
- return 0
309
- }
310
- if (res.reason === 'no-cloud-avg-model') {
311
- log('⚠️ escalation-analysis: N_CLOUD_AVG_MODEL не заданий — аналіз пропущено.')
312
- return 0
313
- }
314
- if (!res.report) {
315
- log('⚠️ escalation-analysis: модель не повернула звіт.')
316
- return 1
317
- }
318
- const reportPath = writeAnalysisReport(res.report, cwd, new Date().toISOString())
319
- log(`📝 escalation-analysis: звіт → ${reportPath} (${res.chunks} чанк(и))`)
320
- return 0
321
- }
322
-
323
- /**
324
- * CLI `n-cursor analyze-escalation` — аналізує ВЕСЬ escalation-лог і пише звіт.
325
- * @param {string[]} _args аргументи (зарезервовано)
326
- * @param {string} [cwd] корінь
327
- * @returns {number} exit code
328
- */
329
- export function runEscalationAnalysisCli(_args, cwd = processCwd()) {
330
- const records = readEscalationRecords(escalationLogPath(), 0)
331
- return analyzeAndReport(records, cwd, s => console.log(s))
332
- }
333
-
334
- /**
335
- * Хук наприкінці `lint --full` (non-read-only): аналізує записи ЦЬОГО прогону
336
- * (від `sinceOffset`). Gated: kill-switch, наявність cloud-avg, наявність записів.
337
- * Помилки не валять lint.
338
- * @param {string} cwd корінь
339
- * @param {number} sinceOffset байтовий зсув логу перед прогоном
340
- * @param {(s: string) => void} log логер
341
- * @returns {void}
342
- */
343
- export function maybeAnalyzeEscalation(cwd, sinceOffset, log) {
344
- if (!analysisEnabled()) return
345
- const records = readEscalationRecords(escalationLogPath(), sinceOffset)
346
- if (records.length === 0) return
347
- if (!CLOUD_AVG) {
348
- log('\nℹ️ escalation-analysis: були LLM-ескалації, але N_CLOUD_AVG_MODEL не заданий — аналіз пропущено.\n')
349
- return
350
- }
351
- log('\n🔬 escalation-analysis: аналізую ескалації цього прогону…\n')
352
- analyzeAndReport(records, cwd, log)
353
- }
@@ -1,44 +0,0 @@
1
- ---
2
- type: JS Module
3
- title: analyze-escalation.mjs
4
- resource: npm/scripts/lib/fix/analyze-escalation.mjs
5
- docgen:
6
- crc: f26cd4c7
7
- model: omlx/gemma-4-e4b-it-OptiQ-4bit
8
- score: 100
9
- ---
10
-
11
- Аналізує записи рунгів драбини з `escalation-log.mjs` для виявлення шляхів зменшення LLM-залежності у fix-конформності. Лог обробляється за один прогін (від байтового зсуву) або повністю, ділячись на чанки для аналізу хмарною **avg**-моделлю. Мета аналізу — визначити конкретні правки пакета `@nitra/cursor`: (A) новий ДЕТЕРМІНОВАНИЙ T0-патерн (`t0.mjs`), (B) уточнення `.mdc`-інструкцій правила, або (C) зміна скрипта/чека. Результат зберігається у markdown-звіт `.n-cursor/fix-escalation-analysis.md` (append із timestamp) після виклику CLI `n-cursor analyze-escalation`.
12
-
13
- ## Поведінка
14
-
15
- analysisEnabled визначає, чи дозволено виконувати автоматичний аналіз ескалації наприкінці процесу `lint`.
16
- escalationLogSize повертає розмір файлу логу ескалації у байтах.
17
- readEscalationRecords читає записи логу ескалації, починаючи з заданого байтового зсуву, та повертає їх як масив об'єктів.
18
- summarizeCalls рахує кількість викликів моделей для різних рівнів (локальний, cloud-min, cloud-avg) у наданих записах.
19
- reportRunStats друкує резюме кількості викликів моделей для поточного прогону, використовуючи заданий байтовий зсув.
20
- chunkRecords ділить масив стиснених записів на менші чанки, щоб кожен чанк не перевищив заданий бюджет символів.
21
- analyzeEscalations аналізує надані записи, ділить їх на чанки, викликає модель для аналізу кожного чанка, а потім синтезує фінальний звіт.
22
- analysisReportPath повертає шлях до файлу, де зберігається звіт аналізу ескалації.
23
- writeAnalysisReport дописує згенерований звіт у markdown-файл у відповідному шляху, додаючи до нього мітку часу.
24
- runEscalationAnalysisCli виконує повний аналіз всього логу ескалації та записує звіт.
25
- maybeAnalyzeEscalation виконує аналіз ескалацій лише для записів поточного прогону, якщо дозволено та є необхідні умови.
26
-
27
- ## Публічний API
28
-
29
- analysisEnabled — Вмикає автоматичний аналіз після завершення lint.
30
- escalationLogSize — Визначає максимальний розмір escalation-логу в байтах.
31
- readEscalationRecords — Зчитує записи з логу, починаючи з заданого байтового зсуву.
32
- summarizeCalls — Підраховує реальні виклики моделей за тирами, ігноруючи записи про середнє кешування.
33
- reportRunStats — Виводить підсумок викликів моделей за поточний запуск.
34
- chunkRecords — Розбиває записи на менші частини, щоб розмір JSON кожного чанку не перевищував заданий ліміт.
35
- analyzeEscalations — Обробляє записи: розбиває їх на чанки, викликає модель для кожного чанку та синтезує результат, якщо чанків більше одного.
36
- analysisReportPath — Вказує шлях для збереження markdown-звіту аналізу.
37
- writeAnalysisReport — Додає звіт до markdown-файлу, додаючи до нього мітку часу.
38
- runEscalationAnalysisCli — Виконує повний аналіз всього логу escalation через інтерфейс командного рядка.
39
- maybeAnalyzeEscalation — Запускає аналіз записів поточного прогону після `lint --full`, якщо це дозволено налаштуваннями.
40
-
41
- ## Гарантії поведінки
42
-
43
- - Перехоплює помилки і не пропускає винятків назовні (fail-safe).
44
- - За певних помилок повертає порожнє значення (напр. `null`) замість винятку.