@nitra/cursor 5.3.0 → 5.3.2

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.
@@ -1,11 +1,12 @@
1
1
  /** @see ./docs/docgen-gen.md */
2
- import { readFileSync } from 'node:fs'
2
+ import { readFileSync, existsSync } from 'node:fs'
3
3
  import { basename } from 'node:path'
4
4
  import { env } from 'node:process'
5
5
  import { resolveModel } from '../../../lib/models.mjs'
6
6
  import { DEFAULT_OMLX_MODEL } from '../../../lib/omlx.mjs'
7
7
  import { callLlm } from '../../../lib/llm.mjs'
8
8
  import { isRunAsCli } from '../../../scripts/cli-entry.mjs'
9
+ import { docPathForSource } from './docgen-scan.mjs'
9
10
  import { extractFacts } from './docgen-extract.mjs'
10
11
  import { extractAnchors, anchorTokens } from './docgen-extract-anchors.mjs'
11
12
  import { QUALITY_THRESHOLD } from './docgen-crc.mjs'
@@ -26,15 +27,28 @@ const SECTION_KEY_CLEAN_RE = /[^а-яіїєґa-z0-9]/gi
26
27
  const CACHE_MENTION_RE = /кеш/i
27
28
  const CACHE_NEGATION_RE = /(?:не|без)\s+(?:\S+\s+)?кеш|немає\s+кеш/i
28
29
  const CRITIC_NONE_RE = /^\s*NONE\s*$/i
29
- // R4: абстрактні «нічого-не-кажучі» формули, які обходять exact-blocklist і дають score=100
30
- const GENERIC_RE =
31
- /відповідност\S*\s+(?:даних\s+)?(?:визначеному\s+)?контракту|валідаці\S*\s+даних|перевірк\S*\s+(?:відповідності\s+)?даних|обробк\S*\s+даних|застосову\S*\s+логіку|інспекту\S*\s+та\s+збира\S*\s+дан/i
30
+ // R4: абстрактні «нічого-не-кажучі» формули, які обходять exact-blocklist і дають score=100.
31
+ // Масив дрібних патернів замість однієї alternation-regex (sonarjs/regex-complexity); .some() еквівалентний.
32
+ const GENERIC_RES = [
33
+ /відповідност\S*\s+(?:даних\s+)?(?:визначеному\s+)?контракту/i,
34
+ /валідаці\S*\s+даних/i,
35
+ /перевірк\S*\s+(?:відповідності\s+)?даних/i,
36
+ /обробк\S*\s+даних/i,
37
+ /застосову\S*\s+логіку/i,
38
+ /інспекту\S*\s+та\s+збира\S*\s+дан/i
39
+ ]
32
40
  // R7: часті русизми/суржик (курований безпечний список — без false-positive на нормальній мові).
33
41
  // Без \b: кирилиця не є ASCII-`\w`, тож межі слова в JS-regex не спрацьовують — терміни специфічні.
34
42
  const SURZHIK_RE =
35
43
  /пропуская|являється|в залежності|по замовчуванню|на протязі|відповідаюч|слідуюч|наступним разом|приймати участь|у відповідності/i
36
44
  const ANCHOR_MISS_PENALTY = 5
37
45
  const ANCHOR_MISS_CAP = 20
46
+ // Захищена людино-керована секція (Варіант B): дослівно зберігається, ніколи не
47
+ // перезаписується LLM-виходом, виключена зі скорингу. Opt-in = сам факт наявності.
48
+ const PROTECTED_HEADING = 'Призначення'
49
+ const PROTECTED_START_RE = /^##\s+Призначення\s*$/
50
+ const H2_RE = /^##\s/
51
+ const H1_RE = /^#\s/
38
52
 
39
53
  /**
40
54
  * Прибирає код-фенс-обгортку (потрійні бектіки) й випадковий провідний
@@ -82,6 +96,43 @@ function parseSections(md) {
82
96
  return result
83
97
  }
84
98
 
99
+ /**
100
+ * Відокремлює захищену секцію `## Призначення` (Варіант B). Межа — наступний `## `
101
+ * (H2); `###`+ усередині не обривають блок.
102
+ * @param {string} md документ
103
+ * @returns {{ body: string|null, without: string }} тіло блоку (або null) і md без нього
104
+ */
105
+ export function splitProtected(md) {
106
+ const lines = md.split('\n')
107
+ const start = lines.findIndex(l => PROTECTED_START_RE.test(l))
108
+ if (start === -1) return { body: null, without: md }
109
+ let end = lines.length
110
+ for (let i = start + 1; i < lines.length; i++) {
111
+ if (H2_RE.test(lines[i])) {
112
+ end = i
113
+ break
114
+ }
115
+ }
116
+ const body = lines.slice(start + 1, end).join('\n').trim()
117
+ const without = [...lines.slice(0, start), ...lines.slice(end)].join('\n')
118
+ return { body: body || null, without }
119
+ }
120
+
121
+ /**
122
+ * Вставляє захищений блок `## Призначення` одразу після H1 (фіксована позиція).
123
+ * @param {string} md машинно-згенерований документ (без блоку)
124
+ * @param {string|null} intent тіло блоку або null
125
+ * @returns {string} документ із блоком (або без змін, якщо intent порожній)
126
+ */
127
+ export function insertProtected(md, intent) {
128
+ if (!intent) return md
129
+ const lines = md.split('\n')
130
+ const h1 = lines.findIndex(l => H1_RE.test(l))
131
+ const at = h1 === -1 ? 0 : h1 + 1
132
+ lines.splice(at, 0, '', `## ${PROTECTED_HEADING}`, '', intent)
133
+ return lines.join('\n')
134
+ }
135
+
85
136
  /**
86
137
  * Чи містить текст бектік-обгорнуте імʼя символу (`sym`) — уникає substring false positives.
87
138
  * @param {string} text текст секції
@@ -92,6 +143,45 @@ function hasName(text, sym) {
92
143
  return text.includes('`' + sym + '`')
93
144
  }
94
145
 
146
+ /**
147
+ * R6: штраф за службові (неекспортовані) символи, подані як публічні.
148
+ * @param {object} facts факт-лист про файл
149
+ * @param {{ overview: string, behavior: string, api: string, guarantees: string }} secs тексти секцій
150
+ * @param {string[]} issues акумулятор кодів проблем (мутується)
151
+ * @returns {number} сумарний штраф (≥0)
152
+ */
153
+ function internalSymbolPenalty(facts, { overview, behavior, api, guarantees }, issues) {
154
+ let penalty = 0
155
+ for (const sym of [...(facts.internalSymbols ?? []), ...(facts.localSymbols ?? [])]) {
156
+ const inDoc = hasName(guarantees, sym) || hasName(overview, sym) || hasName(behavior, sym) || hasName(api, sym)
157
+ if (inDoc) {
158
+ penalty += 10
159
+ issues.push(`internal-name:${sym}`)
160
+ }
161
+ }
162
+ return penalty
163
+ }
164
+
165
+ /**
166
+ * R5: штраф за відсутні в документі валідні анкори (дослівні підрядки src).
167
+ * @param {string} md зібраний документ
168
+ * @param {object} anchors анкори файлу
169
+ * @param {string} src вміст файлу
170
+ * @param {string[]} issues акумулятор кодів проблем (мутується)
171
+ * @returns {number} штраф, обмежений ANCHOR_MISS_CAP
172
+ */
173
+ function anchorMissPenalty(md, anchors, src, issues) {
174
+ let penalty = 0
175
+ for (const tok of anchorTokens(anchors)) {
176
+ if (!src.includes(tok)) continue // валідність: фейковий анкор не вимагаємо
177
+ if (!md.includes(tok) && penalty < ANCHOR_MISS_CAP) {
178
+ penalty += ANCHOR_MISS_PENALTY
179
+ issues.push(`anchor-miss:${tok}`)
180
+ }
181
+ }
182
+ return penalty
183
+ }
184
+
95
185
  /**
96
186
  * Stage 2.5 — детермінований скоринг (0 токенів): перевіряє вихід проти фактів.
97
187
  * @param {string} md зібраний документ
@@ -111,7 +201,7 @@ export function scoreDoc(md, facts, { anchors = null, src = '' } = {}) {
111
201
  }
112
202
 
113
203
  // R4: generic-Огляд (парафрази, які обходять exact-blocklist) — як майже-відсутній.
114
- if (GENERIC_RE.test(overview)) {
204
+ if (GENERIC_RES.some(re => re.test(overview))) {
115
205
  score -= 35
116
206
  issues.push('generic-overview')
117
207
  }
@@ -133,29 +223,15 @@ export function scoreDoc(md, facts, { anchors = null, src = '' } = {}) {
133
223
 
134
224
  // R6: службові (неекспортовані) функції не мають фігурувати як публічні
135
225
  const api = s['публічнийapi'] ?? ''
136
- for (const sym of [...(facts.internalSymbols ?? []), ...(facts.localSymbols ?? [])]) {
137
- const inDoc = hasName(guarantees, sym) || hasName(overview, sym) || hasName(behavior, sym) || hasName(api, sym)
138
- if (inDoc) {
139
- score -= 10
140
- issues.push(`internal-name:${sym}`)
141
- }
142
- }
226
+ score -= internalSymbolPenalty(facts, { overview, behavior, api, guarantees }, issues)
143
227
 
144
228
  // R5: кожен валідний анкор (дослівний підрядок src) має зʼявитися в документі
145
229
  if (anchors && src) {
146
- let missPenalty = 0
147
- for (const tok of anchorTokens(anchors)) {
148
- if (!src.includes(tok)) continue // валідність: фейковий анкор не вимагаємо
149
- if (!md.includes(tok) && missPenalty < ANCHOR_MISS_CAP) {
150
- missPenalty += ANCHOR_MISS_PENALTY
151
- issues.push(`anchor-miss:${tok}`)
152
- }
153
- }
154
- score -= missPenalty
230
+ score -= anchorMissPenalty(md, anchors, src, issues)
155
231
  }
156
232
 
157
- // R7: суржик/русизми
158
- if (SURZHIK_RE.test(md)) {
233
+ // R7: суржик/русизми — лише в машинних секціях (захищене «Призначення» — людське, не штрафуємо)
234
+ if (SURZHIK_RE.test(splitProtected(md).without)) {
159
235
  score -= 10
160
236
  issues.push('surzhik')
161
237
  }
@@ -199,13 +275,14 @@ function apiNeedsRefine(facts) {
199
275
  * @param {string} src вміст файлу
200
276
  * @param {string} model model-id
201
277
  * @param {number} [timeoutMs] ліміт на виклик
278
+ * @param {{ intent?: string|null }} [opts] захищена секція «Призначення» для збереження
202
279
  * @returns {{ md: string }} зібраний документ
203
280
  */
204
- function oneShotDoc(facts, src, model, timeoutMs = LOCAL_TIMEOUT_MS) {
281
+ function oneShotDoc(facts, src, model, timeoutMs = LOCAL_TIMEOUT_MS, { intent = null } = {}) {
205
282
  const text = callLlm(oneShotMessages(facts, src), model, { timeoutMs })
206
283
  let md = stripSignatures(stripSection(text))
207
284
  if (!md.startsWith('#')) md = `# ${basename(facts.relPath)}\n\n${md}`
208
- return { md: md + '\n' }
285
+ return { md: insertProtected(md + '\n', intent) }
209
286
  }
210
287
 
211
288
  /**
@@ -236,16 +313,16 @@ function assemble(stem, sections) {
236
313
  * @param {string} src вміст файлу
237
314
  * @param {string} model model-id
238
315
  * @param {number} timeoutMs ліміт на один виклик
239
- * @param {{ anchors?: object|null, temperature?: number }} [opts] анкори й температура семплінгу
316
+ * @param {{ anchors?: object|null, temperature?: number, intent?: string|null }} [opts] анкори, температура, захищена секція як контекст
240
317
  * @returns {{ md: string }} зібраний документ
241
318
  */
242
- function orchestratedDoc(facts, src, model, timeoutMs, { anchors = null, temperature = 0.2 } = {}) {
319
+ function orchestratedDoc(facts, src, model, timeoutMs, { anchors = null, temperature = 0.2, intent = null } = {}) {
243
320
  const sections = {}
244
321
  const anc = anchors ?? extractAnchors(src)
245
322
  // E3: «Гарантії» — детермінований шаблон з markers (0 LLM-запитів, 0 generic-фраз)
246
323
  sections.guarantees = guaranteesFromMarkers(facts)
247
324
  // Спершу Поведінка (+API) — секції з фактажем
248
- for (const s of sectionMessages(facts, src, anc)) {
325
+ for (const s of sectionMessages(facts, src, anc, intent)) {
249
326
  let draft = stripSignatures(stripSection(callLlm(s.messages, model, { timeoutMs, temperature })))
250
327
  // E2: critique→refine для API, коли всі описи порожні (модель зриває на generic)
251
328
  if (s.key === 'api' && apiNeedsRefine(facts)) {
@@ -255,11 +332,14 @@ function orchestratedDoc(facts, src, model, timeoutMs, { anchors = null, tempera
255
332
  }
256
333
  // R3: «Огляд» — ОСТАННІМ, узагальненням уже написаної Поведінки (не голого факт-листа)
257
334
  let overview = stripSignatures(
258
- stripSection(callLlm(overviewMessages(facts, sections.behavior ?? '', anc), model, { timeoutMs, temperature }))
335
+ stripSection(
336
+ callLlm(overviewMessages(facts, sections.behavior ?? '', anc, intent), model, { timeoutMs, temperature })
337
+ )
259
338
  )
260
339
  overview = critiqueRefineSection('overview', overview, facts, anc, model, timeoutMs)
261
340
  sections.overview = overview
262
- return { md: assemble(basename(facts.relPath), sections) }
341
+ // Варіант B: дослівно повертаємо захищений блок у фіксовану позицію
342
+ return { md: insertProtected(assemble(basename(facts.relPath), sections), intent) }
263
343
  }
264
344
 
265
345
  /** Максимальний час генерації одного LLM-виклику. */
@@ -279,18 +359,20 @@ export const DEFAULT_LOCAL_MODEL = env.N_CURSOR_DOCGEN_MODEL ?? (resolveModel('m
279
359
  * з вищою температурою (best-of-2); якщо й він не допоміг — результат
280
360
  * позначається `degraded`, рішення про перегенерацію приймає batch/користувач.
281
361
  * @param {string} file абсолютний шлях джерела
282
- * @param {{ model?: string, threshold?: number }} [opts] model-id і поріг degraded
362
+ * @param {{ model?: string, threshold?: number, existingMd?: string|null }} [opts] model-id, поріг degraded, наявна дока (для збереження захищеної секції)
283
363
  * @returns {{ md: string, ms: number, score: number|null, issues: string[], degraded: boolean, model: string }} документ і метадані генерації
284
364
  */
285
- export function generateDoc(file, { model = DEFAULT_LOCAL_MODEL, threshold = QUALITY_THRESHOLD } = {}) {
365
+ export function generateDoc(file, { model = DEFAULT_LOCAL_MODEL, threshold = QUALITY_THRESHOLD, existingMd = null } = {}) {
286
366
  const src = readFileSync(file, 'utf8')
287
367
  const facts = extractFacts(src, file)
288
368
  const t0 = Date.now()
289
369
 
370
+ // Варіант B: захищена секція «Призначення» з наявної доки — зберегти й подати як контекст
371
+ const intent = existingMd ? splitProtected(existingMd).body : null
290
372
  const anchors = facts.unsupported ? null : extractAnchors(src)
291
373
  let r = facts.unsupported
292
- ? oneShotDoc(facts, src, model)
293
- : orchestratedDoc(facts, src, model, LOCAL_TIMEOUT_MS, { anchors })
374
+ ? oneShotDoc(facts, src, model, LOCAL_TIMEOUT_MS, { intent })
375
+ : orchestratedDoc(facts, src, model, LOCAL_TIMEOUT_MS, { anchors, intent })
294
376
 
295
377
  // unsupported (vue/py до юніт-шару): скорер не застосовний — score=null, не degraded
296
378
  if (facts.unsupported) {
@@ -303,7 +385,7 @@ export function generateDoc(file, { model = DEFAULT_LOCAL_MODEL, threshold = QUA
303
385
  // E4: best-of-2 — один retry з вищою температурою, det-вибір кращого
304
386
  if (score < threshold && env.N_CURSOR_DOCGEN_BEST_OF !== '0') {
305
387
  try {
306
- const r2 = orchestratedDoc(facts, src, model, LOCAL_TIMEOUT_MS, { anchors, temperature: 0.5 })
388
+ const r2 = orchestratedDoc(facts, src, model, LOCAL_TIMEOUT_MS, { anchors, temperature: 0.5, intent })
307
389
  const s2 = scoreDoc(r2.md, facts, { anchors, src })
308
390
  if (s2.score > score) {
309
391
  r = r2
@@ -329,7 +411,10 @@ if (isRunAsCli(import.meta.url)) {
329
411
  if (!file) {
330
412
  throw new Error('Usage: node docgen-gen.mjs <file> [--model <m>]')
331
413
  }
332
- const r = generateDoc(file, { model })
414
+ // Зберегти захищену секцію «Призначення», якщо дока вже існує
415
+ const docPath = docPathForSource(file)
416
+ const existingMd = existsSync(docPath) ? readFileSync(docPath, 'utf8') : null
417
+ const r = generateDoc(file, { model, existingMd })
333
418
  const issuesTxt = r.issues?.length ? ` issues=${r.issues.join(',')}` : ''
334
419
  process.stderr.write(`[local ${r.model}] ${r.ms}ms / score=${r.score}${r.degraded ? ' DEGRADED' : ''}${issuesTxt}\n`)
335
420
  process.stdout.write(r.md)
@@ -5,6 +5,7 @@ import picomatch from 'picomatch'
5
5
  export const DOCGEN_IGNORE_GLOBS = Object.freeze([
6
6
  '**/node_modules/**',
7
7
  '**/dist/**',
8
+ '**/target/**',
8
9
  '.git/**',
9
10
  '**/__pycache__/**',
10
11
  '**/coverage/**',
@@ -50,6 +50,17 @@ const msgs = (system, user) => [
50
50
  { role: 'user', content: user }
51
51
  ]
52
52
 
53
+ /**
54
+ * Блок read-only авторитетного контексту із захищеної секції «Призначення»
55
+ * (Варіант B): машинні секції мають узгоджуватися з ним і НЕ дублювати його.
56
+ * @param {string|null} intent тіло секції «Призначення» або null
57
+ * @returns {string} текстовий блок для system-промпта або порожній рядок
58
+ */
59
+ function intentContext(intent) {
60
+ if (!intent) return ''
61
+ return `\n\nАВТОРИТЕТНИЙ КОНТЕКСТ (секція «Призначення», написана людиною — НЕ повторюй дослівно, узгоджуйся й доповнюй):\n${intent}`
62
+ }
63
+
53
64
  /**
54
65
  * Секційні набори messages з МІНІМАЛЬНИМ контекстом під кожну секцію.
55
66
  * Код потрапляє лише в `behavior`; «Огляд» генерується окремо ОСТАННІМ
@@ -57,11 +68,13 @@ const msgs = (system, user) => [
57
68
  * @param {object} facts факт-лист про файл
58
69
  * @param {string} src вміст файлу
59
70
  * @param {object|null} [anchors] анкори файлу для обовʼязкового включення
71
+ * @param {string|null} [intent] захищена секція «Призначення» як read-only контекст
60
72
  * @returns {Array<{key:string, messages:object[], numPredict:number}>} набір секційних промптів (behavior[, api])
61
73
  */
62
- export function sectionMessages(facts, src, anchors = null) {
74
+ export function sectionMessages(facts, src, anchors = null, intent = null) {
63
75
  const factsTxt = factsSummary(facts)
64
76
  const anch = anchorsBlock(anchors)
77
+ const intentCtx = intentContext(intent)
65
78
  const multi = (facts.exports?.length || 0) > 1
66
79
 
67
80
  // R6: Поведінка описує РІВНО експортовані імена, не службові помічники
@@ -79,7 +92,7 @@ export function sectionMessages(facts, src, anchors = null) {
79
92
  key: 'behavior',
80
93
  numPredict: 500,
81
94
  messages: msgs(
82
- `${STYLE}\n\nФАЙЛ ${facts.relPath}:\n\`\`\`\n${src}\n\`\`\`\n\nВІДОМІ ФАКТИ:\n${factsTxt}${anch}`,
95
+ `${STYLE}\n\nФАЙЛ ${facts.relPath}:\n\`\`\`\n${src}\n\`\`\`\n\nВІДОМІ ФАКТИ:\n${factsTxt}${anch}${intentCtx}`,
83
96
  `Напиши вміст секції «Поведінка»: ${behaviorTask}.${onlyExports} Якщо у фактах є свідомі пропуски шляхів — згадай їх там, де доречно (не вигадуй інших «не перевіряє»). НЕ пиши аргументи функцій у дужках, без regex.${noInternal} Без заголовка, без додаткових ## чи # підзаголовків усередині секції.`
84
97
  )
85
98
  }
@@ -104,14 +117,16 @@ export function sectionMessages(facts, src, anchors = null) {
104
117
  * @param {object} facts факт-лист про файл
105
118
  * @param {string} behaviorText готовий текст секції «Поведінка»
106
119
  * @param {object|null} [anchors] анкори файлу
120
+ * @param {string|null} [intent] захищена секція «Призначення» як read-only контекст
107
121
  * @returns {Array<{role:string,content:string}>} messages-масив для Огляду
108
122
  */
109
- export function overviewMessages(facts, behaviorText, anchors = null) {
123
+ export function overviewMessages(facts, behaviorText, anchors = null, intent = null) {
110
124
  const factsTxt = factsSummary(facts)
111
125
  const anch = anchorsBlock(anchors)
126
+ const dedup = intent ? ' Не дублюй секцію «Призначення».' : ''
112
127
  return msgs(
113
- `${STYLE}\n\nВІДОМІ ФАКТИ:\n${factsTxt}${anch}`,
114
- `На основі вже написаної секції «Поведінка» (нижче) напиши «Огляд»: 1-3 речення — що файл робить і навіщо існує (роль у системі). Узагальнюй САМЕ описану поведінку, не додавай нових фактів. Без заголовка, без переліку функцій. Заборонені абстрактні формули без конкретики («перевірка/валідація/обробка даних», «відповідність контракту», «застосовує логіку») — пиши, ЩО саме і за яким контрактом.\n\nПОВЕДІНКА:\n${behaviorText}`
128
+ `${STYLE}\n\nВІДОМІ ФАКТИ:\n${factsTxt}${anch}${intentContext(intent)}`,
129
+ `На основі вже написаної секції «Поведінка» (нижче) напиши «Огляд»: 1-3 речення — що файл робить і навіщо існує (роль у системі). Узагальнюй САМЕ описану поведінку, не додавай нових фактів. Без заголовка, без переліку функцій. Заборонені абстрактні формули без конкретики («перевірка/валідація/обробка даних», «відповідність контракту», «застосовує логіку») — пиши, ЩО саме і за яким контрактом.${dedup}\n\nПОВЕДІНКА:\n${behaviorText}`
115
130
  )
116
131
  }
117
132
 
@@ -11,7 +11,7 @@ import { isDocgenIgnored } from './docgen-ignore.mjs'
11
11
  import { QUALITY_THRESHOLD, readDocQuality, staleness } from './docgen-crc.mjs'
12
12
 
13
13
  /** Кодові розширення, для яких генеруємо документацію. */
14
- const SOURCE_EXTENSIONS = new Set(['.js', '.mjs', '.ts', '.vue', '.py'])
14
+ const SOURCE_EXTENSIONS = new Set(['.js', '.mjs', '.ts', '.vue', '.py', '.rs'])
15
15
 
16
16
  /** `*.test.*`, `*.spec.*` — тести, документувати не треба. */
17
17
  const TEST_FILE_RE = /\.(?:test|spec)\.[^.]+$/u
@@ -11,18 +11,74 @@ const DEFAULT_MAX_ITER = 3
11
11
  const ESCALATE_AFTER = 2
12
12
 
13
13
  /**
14
- * @param {string[]} args CLI аргументи після 'fix'
15
- * @param {string} cwd корінь проєкту
16
- * @returns {Promise<number>} 0 = all clean, 1 = unresolved
14
+ * Парсить `--max-iter N` і збирає rule-filter (позиційні аргументи без прапорців).
15
+ * @param {string[]} args CLI аргументи після 'fix'
16
+ * @returns {{ maxIter: number, ruleFilter: string[] }} ліміт ітерацій і фільтр правил
17
17
  */
18
- export async function runOrchestratorCli(args, cwd) {
19
- const { runLlmWorker, MODEL, MODEL_HEAVY } = await import('./llm-worker.mjs')
20
-
18
+ function parseOrchestratorArgs(args) {
21
19
  const maxIterIdx = args.indexOf('--max-iter')
22
20
  const maxIter =
23
21
  maxIterIdx === -1 ? DEFAULT_MAX_ITER : Number(args[maxIterIdx + 1] ?? DEFAULT_MAX_ITER) || DEFAULT_MAX_ITER
24
22
  const skipIdxs = new Set(maxIterIdx === -1 ? [] : [maxIterIdx, maxIterIdx + 1])
25
23
  const ruleFilter = args.filter((a, i) => !a.startsWith('-') && !skipIdxs.has(i))
24
+ return { maxIter, ruleFilter }
25
+ }
26
+
27
+ /**
28
+ * Крок T0-auto: детермінований фікс без LLM, повертає правила, що лишились.
29
+ * @param {string} cwd корінь проєкту
30
+ * @param {string[]} ruleFilter фільтр правил
31
+ * @param {Array<{ ruleId: string }>} failed правила перед кроком
32
+ * @returns {Array<{ ruleId: string, ok: boolean, output: string }>} правила після T0
33
+ */
34
+ function runT0Step(cwd, ruleFilter, failed) {
35
+ spawnSync('bun', [N_CURSOR_BIN, 'fix-t0', ...ruleFilter], { cwd, stdio: 'pipe' })
36
+
37
+ const afterT0 = runFixCheck(cwd, ruleFilter)
38
+ const failedAfterT0 = afterT0?.rules.filter(r => !r.ok) ?? failed
39
+ const t0Fixed = failed.filter(r => !failedAfterT0.some(f => f.ruleId === r.ruleId))
40
+
41
+ if (t0Fixed.length > 0) {
42
+ console.log(` ⚙️ T0-auto: ${t0Fixed.map(r => r.ruleId).join(', ')}`)
43
+ }
44
+ return failedAfterT0
45
+ }
46
+
47
+ /**
48
+ * Крок T1: LLM через pi для кожного правила, з ескалацією моделі за провалами.
49
+ * @param {Array<{ ruleId: string, output: string }>} failed правила до фіксу
50
+ * @param {string} cwd корінь проєкту
51
+ * @param {Map<string, number>} failCount ruleId → кількість провалів підряд (мутується)
52
+ * @param {{ runLlmWorker: (ruleId: string, output: string, projectRoot: string, opts: {model: string}) => Promise<{ok: boolean, error?: string}>, MODEL: string, MODEL_HEAVY: string }} worker воркер і моделі
53
+ * @returns {Promise<void>}
54
+ */
55
+ async function runLlmStep(failed, cwd, failCount, { runLlmWorker, MODEL, MODEL_HEAVY }) {
56
+ for (const rule of failed) {
57
+ const prevFails = failCount.get(rule.ruleId) ?? 0
58
+ const model = prevFails >= ESCALATE_AFTER ? MODEL_HEAVY : MODEL
59
+ const label = model || 'pi'
60
+
61
+ const result = await runLlmWorker(rule.ruleId, rule.output, cwd, { model })
62
+
63
+ if (result.ok) {
64
+ console.log(` ⚡ LLM (${label}): ${rule.ruleId}`)
65
+ failCount.delete(rule.ruleId)
66
+ } else {
67
+ failCount.set(rule.ruleId, prevFails + 1)
68
+ const hint = (result.error ?? '').slice(0, 200)
69
+ console.log(` ⚡ LLM (${label}): ${rule.ruleId} ❌ ${hint}`)
70
+ }
71
+ }
72
+ }
73
+
74
+ /**
75
+ * @param {string[]} args CLI аргументи після 'fix'
76
+ * @param {string} cwd корінь проєкту
77
+ * @returns {Promise<number>} 0 = all clean, 1 = unresolved
78
+ */
79
+ export async function runOrchestratorCli(args, cwd) {
80
+ const worker = await import('./llm-worker.mjs')
81
+ const { maxIter, ruleFilter } = parseOrchestratorArgs(args)
26
82
 
27
83
  /** @type {Map<string, number>} ruleId → кількість LLM-провалів підряд */
28
84
  const failCount = new Map()
@@ -48,37 +104,10 @@ export async function runOrchestratorCli(args, cwd) {
48
104
  if (ruleFilter.length) console.log(` filter: ${ruleFilter.join(', ')}`)
49
105
 
50
106
  for (let iter = 1; iter <= maxIter; iter++) {
51
- // ── T0-auto: детермінований фікс без LLM ──
52
- spawnSync('bun', [N_CURSOR_BIN, 'fix-t0', ...ruleFilter], { cwd, stdio: 'pipe' })
53
-
54
- const afterT0 = runFixCheck(cwd, ruleFilter)
55
- const failedAfterT0 = afterT0?.rules.filter(r => !r.ok) ?? failed
56
- const t0Fixed = failed.filter(r => !failedAfterT0.some(f => f.ruleId === r.ruleId))
57
-
58
- if (t0Fixed.length > 0) {
59
- console.log(` ⚙️ T0-auto: ${t0Fixed.map(r => r.ruleId).join(', ')}`)
60
- }
61
-
62
- failed = failedAfterT0
107
+ failed = runT0Step(cwd, ruleFilter, failed)
63
108
  if (failed.length === 0) break
64
109
 
65
- // ── T1: LLM через pi ──
66
- for (const rule of failed) {
67
- const prevFails = failCount.get(rule.ruleId) ?? 0
68
- const model = prevFails >= ESCALATE_AFTER ? MODEL_HEAVY : MODEL
69
- const label = model || 'pi'
70
-
71
- const result = await runLlmWorker(rule.ruleId, rule.output, cwd, { model })
72
-
73
- if (result.ok) {
74
- console.log(` ⚡ LLM (${label}): ${rule.ruleId}`)
75
- failCount.delete(rule.ruleId)
76
- } else {
77
- failCount.set(rule.ruleId, prevFails + 1)
78
- const hint = (result.error ?? '').slice(0, 200)
79
- console.log(` ⚡ LLM (${label}): ${rule.ruleId} ❌ ${hint}`)
80
- }
81
- }
110
+ await runLlmStep(failed, cwd, failCount, worker)
82
111
 
83
112
  // Перевірка після LLM
84
113
  const afterLLM = runFixCheck(cwd, ruleFilter)
@@ -113,6 +113,43 @@ const HERE = dirname(fileURLToPath(import.meta.url))
113
113
  /** Абсолютний шлях до npm/bin/n-cursor.js відносно цього файлу */
114
114
  const N_CURSOR_BIN = join(HERE, '../../../bin/n-cursor.js')
115
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
+ /**
134
+ * Застосовує T0-auto до кожного провального правила, розділяючи на applied/skipped.
135
+ * @param {Array<{ ruleId: string, output: string }>} failed провальні правила
136
+ * @param {string} cwd корінь проєкту
137
+ * @returns {{ applied: Array<{ ruleId: string, actions: string[] }>, skipped: string[] }} застосовані й пропущені
138
+ */
139
+ function applyT0ToFailed(failed, cwd) {
140
+ const applied = []
141
+ const skipped = []
142
+ for (const r of failed) {
143
+ const result = applyT0Auto(r.ruleId, r.output, cwd)
144
+ if (result.applied) {
145
+ applied.push({ ruleId: r.ruleId, actions: result.actions })
146
+ } else {
147
+ skipped.push(r.ruleId)
148
+ }
149
+ }
150
+ return { applied, skipped }
151
+ }
152
+
116
153
  /**
117
154
  * CLI підкоманда `n-cursor fix-t0 [rule...]`.
118
155
  * Запускає `fix --json`, застосовує T0-auto для кожного violation,
@@ -126,22 +163,13 @@ export function runT0AutoCli(args, cwd) {
126
163
  const verbose = args.includes('--verbose') || args.includes('-v')
127
164
 
128
165
  // 1. Запустити fix --json
129
- const fixResult = spawnSync('bun', [N_CURSOR_BIN, '_fix-check', ...ruleFilter], {
130
- cwd,
131
- encoding: 'utf8',
132
- timeout: 120_000
133
- })
134
- const raw = fixResult.stdout?.trim()
135
- if (!raw) {
166
+ const fixJson = fixCheck(ruleFilter, cwd)
167
+ if (fixJson._empty) {
136
168
  console.error(`n-cursor fix-t0: fix --json повернув порожній stdout`)
137
- console.error(fixResult.stderr?.slice(0, 300) ?? '')
169
+ console.error(fixJson.stderr?.slice(0, 300) ?? '')
138
170
  return 1
139
171
  }
140
-
141
- let fixJson
142
- try {
143
- fixJson = JSON.parse(raw)
144
- } catch {
172
+ if (fixJson._badJson) {
145
173
  console.error(`n-cursor fix-t0: fix --json повернув невалідний JSON`)
146
174
  return 1
147
175
  }
@@ -153,16 +181,7 @@ export function runT0AutoCli(args, cwd) {
153
181
  }
154
182
 
155
183
  // 2. Застосувати T0-auto
156
- const applied = []
157
- const skipped = []
158
- for (const r of failed) {
159
- const result = applyT0Auto(r.ruleId, r.output, cwd)
160
- if (result.applied) {
161
- applied.push({ ruleId: r.ruleId, actions: result.actions })
162
- } else {
163
- skipped.push(r.ruleId)
164
- }
165
- }
184
+ const { applied, skipped } = applyT0ToFailed(failed, cwd)
166
185
 
167
186
  if (applied.length === 0) {
168
187
  console.log(`⏭️ fix-t0: T0-auto паттерн не підходить для: ${failed.map(r => r.ruleId).join(', ')}`)
@@ -176,18 +195,11 @@ export function runT0AutoCli(args, cwd) {
176
195
  }
177
196
 
178
197
  // 4. Check-gate: перевірити лише ті правила, що ми чіпали
179
- const recheck = spawnSync('bun', [N_CURSOR_BIN, '_fix-check', ...applied.map(a => a.ruleId)], {
180
- cwd,
181
- encoding: 'utf8',
182
- timeout: 120_000
183
- })
184
- const recheckRaw = recheck.stdout?.trim()
185
- if (!recheckRaw) {
198
+ const recheckJson = fixCheck(applied.map(a => a.ruleId), cwd)
199
+ if (recheckJson._empty) {
186
200
  console.error(`fix-t0: check-gate: fix --json повернув порожній stdout`)
187
201
  return 1
188
202
  }
189
-
190
- const recheckJson = JSON.parse(recheckRaw)
191
203
  const stillFailed = recheckJson.rules.filter(r => !r.ok)
192
204
 
193
205
  if (verbose) {
@@ -111,7 +111,7 @@ function diffSideEffects(before, after) {
111
111
  * Запускає `start` одного воркспейсу з grace-таймаутом і класифікує результат.
112
112
  * @param {string} cwd корінь репозиторію
113
113
  * @param {string} workspace відносний шлях воркспейсу
114
- * @param {{graceMs?:number, type?:('server'|'cli'), spawnImpl?:Function}} [opts] grace-період, тип (інакше з package.json), інʼєкція spawn для тестів
114
+ * @param {{graceMs?:number, type?:('server'|'cli'), spawnImpl?:typeof import('node:child_process').spawnSync}} [opts] grace-період, тип (інакше з package.json), інʼєкція spawn для тестів
115
115
  * @returns {Promise<{workspace:string, type:string, exitCode:number|null, timedOut:boolean, status:('OK'|'FAIL'), ready:boolean, firstError:string|null, logTail:string, sideEffects:{newFiles:string[], changedTracked:string[]}}>} результат прогону
116
116
  */
117
117
  export async function runWorkspaceStart(cwd, workspace, opts = {}) {