@nitra/cursor 5.2.1 → 5.3.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.
Files changed (40) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/bin/n-cursor.js +72 -50
  3. package/lib/llm.mjs +60 -47
  4. package/lib/models.mjs +1 -1
  5. package/lib/omlx-trace.mjs +158 -0
  6. package/lib/omlx.mjs +49 -11
  7. package/package.json +1 -1
  8. package/rules/js-bun-db/js-bun-db.mdc +7 -7
  9. package/rules/js-lint/js-lint.mdc +14 -1
  10. package/rules/js-run/js-run.mdc +16 -16
  11. package/rules/k8s/js/manifests.mjs +144 -82
  12. package/rules/npm-module/js/header_doc_pointer.mjs +72 -27
  13. package/rules/npm-module/js/rule_meta.mjs +72 -36
  14. package/rules/npm-module/js/skill_meta.mjs +59 -35
  15. package/rules/style-lint/js/tooling.mjs +13 -4
  16. package/rules/style-lint/style-lint.mdc +1 -1
  17. package/rules/test/js/data/stryker_config/stryker.config.baseline.mjs +1 -1
  18. package/rules/test/js/data/stryker_config/stryker.config.vue.baseline.mjs +1 -1
  19. package/rules/test/js/stryker_config.mjs +33 -5
  20. package/rules/test/js/vitest-config-pool-forks.mjs +11 -7
  21. package/rules/test/test.mdc +9 -9
  22. package/rules/vue/vue.mdc +6 -6
  23. package/scripts/coverage-classify/index.mjs +5 -17
  24. package/scripts/coverage-classify/verdict-schema.mjs +1 -1
  25. package/scripts/lib/assert-project-root.mjs +1 -1
  26. package/scripts/lib/discover-check-rules-from-cursor.mjs +1 -1
  27. package/scripts/lib/rule-predicates.mjs +30 -18
  28. package/scripts/lib/run-rule-cli.mjs +1 -1
  29. package/scripts/lib/run-standard-rule.mjs +1 -1
  30. package/scripts/post-tool-use-fix.mjs +3 -3
  31. package/scripts/skills-cli.mjs +5 -5
  32. package/scripts/worktree-cli.mjs +5 -5
  33. package/skills/doc-files/js/docgen-extract.mjs +1 -1
  34. package/skills/doc-files/js/docgen-files-batch.mjs +65 -34
  35. package/skills/doc-files/js/docgen-gen.mjs +121 -36
  36. package/skills/doc-files/js/docgen-prompts.mjs +20 -5
  37. package/skills/fix/js/llm-worker.mjs +10 -22
  38. package/skills/fix/js/orchestrator.mjs +64 -35
  39. package/skills/fix/js/t0.mjs +44 -32
  40. package/skills/start-check/js/check.mjs +1 -1
@@ -79,7 +79,7 @@ function listDescFiles(cwd) {
79
79
  /**
80
80
  * add: створити worktree від HEAD + .md-опис.
81
81
  * @param {string[]} rest [branch, ...descParts]
82
- * @param {{ cwd: string, log: Function, logError: Function, now: () => Date }} ctx контекст
82
+ * @param {{ cwd: string, log: (line: string) => void, logError: (line: string) => void, now: () => Date }} ctx контекст
83
83
  * @returns {number} exit code
84
84
  */
85
85
  function cmdAdd(rest, ctx) {
@@ -135,7 +135,7 @@ function cmdAdd(rest, ctx) {
135
135
  /**
136
136
  * remove: прибрати checkout + .md (гілку лишає).
137
137
  * @param {string[]} rest [branch, ...flags]
138
- * @param {{ cwd: string, log: Function, logError: Function }} ctx контекст
138
+ * @param {{ cwd: string, log: (line: string) => void, logError: (line: string) => void }} ctx контекст
139
139
  * @returns {number} exit code
140
140
  */
141
141
  function cmdRemove(rest, ctx) {
@@ -167,7 +167,7 @@ function cmdRemove(rest, ctx) {
167
167
 
168
168
  /**
169
169
  * list: git worktree list + вміст .md-описів.
170
- * @param {{ cwd: string, log: Function }} ctx контекст
170
+ * @param {{ cwd: string, log: (line: string) => void }} ctx контекст
171
171
  * @returns {number} exit code
172
172
  */
173
173
  function cmdList(ctx) {
@@ -181,7 +181,7 @@ function cmdList(ctx) {
181
181
 
182
182
  /**
183
183
  * prune: git worktree prune + видалити осиротілі .md.
184
- * @param {{ cwd: string, log: Function }} ctx контекст
184
+ * @param {{ cwd: string, log: (line: string) => void }} ctx контекст
185
185
  * @returns {number} exit code
186
186
  */
187
187
  function cmdPrune(ctx) {
@@ -198,7 +198,7 @@ function cmdPrune(ctx) {
198
198
  /**
199
199
  * Точка входу підкоманди worktree.
200
200
  * @param {string[]} argv аргументи після `worktree`
201
- * @param {{ cwd?: string, log?: Function, logError?: Function, now?: () => Date }} [options] ін'єкція для тестів
201
+ * @param {{ cwd?: string, log?: (line: string) => void, logError?: (line: string) => void, now?: () => Date }} [options] ін'єкція для тестів
202
202
  * @returns {Promise<number>} exit code
203
203
  */
204
204
  export function runWorktreeCli(argv, options = {}) {
@@ -65,7 +65,7 @@ function cleanJsDoc(raw) {
65
65
  }
66
66
 
67
67
  /**
68
- * Опис (без @-тегів) + параметри з @param як «name — опис».
68
+ * Опис (без @-тегів) + параметри з `@param` як «name — опис».
69
69
  * @param {string} raw сирий JSDoc-блок
70
70
  * @returns {{desc:string, params:Array<{name:string, desc:string}>, ret:string}} розпарсений опис, параметри й опис повернення
71
71
  */
@@ -79,6 +79,68 @@ function preflightProblem() {
79
79
  return `omlx помилка: ${hc.detail}`
80
80
  }
81
81
 
82
+ /**
83
+ * Текст-суфікс режиму для прогрес-рядка.
84
+ * @param {{ overwrite: boolean, retryDegraded: boolean }} mode режими
85
+ * @returns {string} ` (--overwrite)` / ` (--retry-degraded)` / порожній рядок
86
+ */
87
+ function modeSuffix({ overwrite, retryDegraded }) {
88
+ if (overwrite) return ' (--overwrite)'
89
+ if (retryDegraded) return ' (--retry-degraded)'
90
+ return ''
91
+ }
92
+
93
+ /**
94
+ * Генерує й штампує доку для одного файлу, оновлюючи лічильники й прогрес.
95
+ * @param {object} file елемент scanForDocFiles
96
+ * @param {string} root абсолютний корінь
97
+ * @param {{ done: number, total: number }} progress позиція у прогресі
98
+ * @param {{ ok: number, degraded: number, err: number, errors: string[] }} stats акумулятор статистики
99
+ * @returns {Promise<void>}
100
+ */
101
+ async function generateOne(file, root, progress, stats) {
102
+ const sourceAbs = join(root, file.sourcePath)
103
+ process.stdout.write(` [${progress.done}/${progress.total}] ${file.sourcePath} … `)
104
+ try {
105
+ const docAbs = join(root, file.docPath)
106
+ // Варіант B: передаємо наявну доку, щоб зберегти захищену секцію «Призначення»
107
+ const existingMd = existsSync(docAbs) ? readFileSync(docAbs, 'utf8') : null
108
+ const result = await generateDoc(sourceAbs, { existingMd })
109
+ const crc = crc32(readFileSync(sourceAbs))
110
+ mkdirSync(dirname(docAbs), { recursive: true })
111
+ const quality =
112
+ result.score === null ? null : { score: result.score, issues: result.degraded ? result.issues : [] }
113
+ writeFileSync(docAbs, stampDoc(result.md, file.sourcePath, crc, quality))
114
+ stats.ok++
115
+ if (result.degraded) {
116
+ stats.degraded++
117
+ process.stdout.write(`⚠ degraded score=${result.score} crc=${crc}\n`)
118
+ } else {
119
+ process.stdout.write(`✓ score=${result.score ?? '—'} crc=${crc}\n`)
120
+ }
121
+ } catch (error) {
122
+ stats.err++
123
+ stats.errors.push(file.sourcePath)
124
+ process.stdout.write(`✗ ${error.message}\n`)
125
+ }
126
+ }
127
+
128
+ /**
129
+ * Підсумковий звіт прогону у stdout.
130
+ * @param {{ ok: number, degraded: number, err: number, errors: string[] }} stats статистика
131
+ * @returns {void}
132
+ */
133
+ function reportStats(stats) {
134
+ console.log(`\n${'─'.repeat(50)}\n✓ OK: ${stats.ok} ⚠ degraded: ${stats.degraded} ✗ Err: ${stats.err}`)
135
+ if (stats.errors.length > 0) {
136
+ console.log('Помилки:')
137
+ for (const e of stats.errors) console.log(` - ${e}`)
138
+ }
139
+ if (stats.degraded > 0) {
140
+ console.log(`Degraded-доки перегенеровуються пізніше: npx @nitra/cursor doc-files gen --retry-degraded`)
141
+ }
142
+ }
143
+
82
144
  /**
83
145
  * `doc-files gen` — згенерувати документацію для застарілих/відсутніх док.
84
146
  * @param {string[]} argv аргументи після назви субкоманди
@@ -106,47 +168,16 @@ export async function runDocFilesGenCli(argv) {
106
168
  return 1
107
169
  }
108
170
 
109
- let modeTxt = ''
110
- if (overwrite) modeTxt = ' (--overwrite)'
111
- else if (retryDegraded) modeTxt = ' (--retry-degraded)'
112
- console.log(`📋 doc-files: до генерації ${targets.length} файл(ів)${modeTxt}`)
171
+ console.log(`📋 doc-files: до генерації ${targets.length} файл(ів)${modeSuffix({ overwrite, retryDegraded })}`)
113
172
  const stats = { ok: 0, degraded: 0, err: 0, errors: [] }
114
173
 
115
174
  let done = 0
116
175
  for (const file of targets) {
117
176
  done++
118
- const sourceAbs = join(root, file.sourcePath)
119
- process.stdout.write(` [${done}/${targets.length}] ${file.sourcePath} … `)
120
- try {
121
- const result = await generateDoc(sourceAbs)
122
- const crc = crc32(readFileSync(sourceAbs))
123
- const docAbs = join(root, file.docPath)
124
- mkdirSync(dirname(docAbs), { recursive: true })
125
- const quality =
126
- result.score === null ? null : { score: result.score, issues: result.degraded ? result.issues : [] }
127
- writeFileSync(docAbs, stampDoc(result.md, file.sourcePath, crc, quality))
128
- stats.ok++
129
- if (result.degraded) {
130
- stats.degraded++
131
- process.stdout.write(`⚠ degraded score=${result.score} crc=${crc}\n`)
132
- } else {
133
- process.stdout.write(`✓ score=${result.score ?? '—'} crc=${crc}\n`)
134
- }
135
- } catch (error) {
136
- stats.err++
137
- stats.errors.push(file.sourcePath)
138
- process.stdout.write(`✗ ${error.message}\n`)
139
- }
177
+ await generateOne(file, root, { done, total: targets.length }, stats)
140
178
  }
141
179
 
142
- console.log(`\n${'─'.repeat(50)}\n✓ OK: ${stats.ok} ⚠ degraded: ${stats.degraded} ✗ Err: ${stats.err}`)
143
- if (stats.errors.length > 0) {
144
- console.log('Помилки:')
145
- for (const e of stats.errors) console.log(` - ${e}`)
146
- }
147
- if (stats.degraded > 0) {
148
- console.log(`Degraded-доки перегенеровуються пізніше: npx @nitra/cursor doc-files gen --retry-degraded`)
149
- }
180
+ reportStats(stats)
150
181
  return stats.err > 0 ? 1 : 0
151
182
  }
152
183
 
@@ -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)
@@ -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
 
@@ -2,10 +2,9 @@
2
2
 
3
3
  import { existsSync, readFileSync, writeFileSync } from 'node:fs'
4
4
  import { join } from 'node:path'
5
- import { spawnSync } from 'node:child_process'
6
5
  import { env } from 'node:process'
7
6
  import { resolveModel } from '../../../lib/models.mjs'
8
- import { callOmlx, isOmlxModel } from '../../../lib/omlx.mjs'
7
+ import { callLlm } from '../../../lib/llm.mjs'
9
8
 
10
9
  // Тир за замовчуванням: min → avg при ескалації (каскад local→cloud).
11
10
  // Перевизначення через N_CURSOR_FIX_MODEL / N_CURSOR_FIX_MODEL_HEAVY.
@@ -13,6 +12,7 @@ export const MODEL = env.N_CURSOR_FIX_MODEL ?? resolveModel('min')
13
12
  export const MODEL_HEAVY = env.N_CURSOR_FIX_MODEL_HEAVY ?? resolveModel('avg')
14
13
 
15
14
  const JSON_CODE_BLOCK_RE = /```(?:json)?[ \t]{0,8}\n?([\s\S]*?)```/
15
+ const API_KEY_RE = /api key/i
16
16
 
17
17
  /**
18
18
  * Витягує відносні шляхи файлів із violation output.
@@ -87,29 +87,18 @@ function buildPrompt(ruleId, ruleMdc, output, files) {
87
87
  }
88
88
 
89
89
  /**
90
- * Викликає LLM за model-id і повертає текст відповіді.
91
- * `omlx/...` прямий HTTP до omlx (text-only, локально); решта → pi CLI.
90
+ * Викликає LLM через спільний `callLlm` (маршрут за префіксом model-id; wire-trace).
91
+ * Зберігає дружнє повідомлення про відсутній API-ключ для хмарних провайдерів.
92
92
  * @param {string} prompt текст промпта
93
93
  * @param {string} model назва моделі (provider/id, `omlx/...` або '')
94
94
  * @returns {{ text: string, error?: string }} текст відповіді або повідомлення про помилку
95
95
  */
96
96
  function callModel(prompt, model) {
97
- if (isOmlxModel(model)) {
98
- try {
99
- return { text: callOmlx([{ role: 'user', content: prompt }], model, { timeoutMs: 120_000 }) }
100
- } catch (error) {
101
- return { text: '', error: error.message }
102
- }
103
- }
104
- const modelArgs = model ? ['--model', model] : []
105
- const r = spawnSync('pi', ['-p', prompt, ...modelArgs, '--no-session', '--mode', 'text', '--no-tools'], {
106
- encoding: 'utf8',
107
- timeout: 120_000
108
- })
109
- if (r.error) return { text: '', error: r.error.message }
110
- if (r.status !== 0) {
111
- const stderr = r.stderr?.slice(0, 300) ?? ''
112
- if (stderr.toLowerCase().includes('no api key') || stderr.toLowerCase().includes('api key')) {
97
+ try {
98
+ return { text: callLlm([{ role: 'user', content: prompt }], model, { timeoutMs: 120_000, caller: 'fix' }) }
99
+ } catch (error) {
100
+ const msg = String(error.message)
101
+ if (API_KEY_RE.test(msg)) {
113
102
  const provider = model ? model.split('/')[0] : 'дефолтного провайдера'
114
103
  return {
115
104
  text: '',
@@ -120,9 +109,8 @@ function callModel(prompt, model) {
120
109
  ].join(' ')
121
110
  }
122
111
  }
123
- return { text: '', error: `pi exit ${r.status}: ${stderr}` }
112
+ return { text: '', error: msg }
124
113
  }
125
- return { text: r.stdout?.trim() ?? '' }
126
114
  }
127
115
 
128
116
  /**