@nitra/cursor 3.25.1 → 3.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,158 @@
1
+ /**
2
+ * Stage 0 docgen-конвеєра: детермінована екстракція «факт-листа» з коду (0 токенів LLM).
3
+ *
4
+ * Ідея: винести з-під локальної моделі все, що вона псує (імена експортів, stdlib-vs-internal,
5
+ * крайові деталі поведінки), і віддати їй лише перефразування вже відомих фактів. Тут — суто
6
+ * парсинг JS/MJS регулярками: жодних мереж, LLM чи запису.
7
+ *
8
+ * Повертає об'єкт-факт-лист, який Stage 1 (docgen-prompts) перетворює на точкові промпти.
9
+ */
10
+
11
+ const BUILTIN_MODULES = new Set([
12
+ 'fs', 'path', 'crypto', 'os', 'util', 'stream', 'events', 'http', 'https',
13
+ 'url', 'child_process', 'process', 'assert', 'buffer', 'zlib', 'readline'
14
+ ])
15
+
16
+ /** Прибирає `/** *​/`-обрамлення й `*`-префікси, повертає чистий текст рядками. */
17
+ function cleanJsDoc(raw) {
18
+ return raw
19
+ .replace(/^\s*\/\*\*?/, '')
20
+ .replace(/\*\/\s*$/, '')
21
+ .split('\n')
22
+ .map(l => l.replace(/^\s*\*?\s?/, '').trimEnd())
23
+ .join('\n')
24
+ .trim()
25
+ }
26
+
27
+ /** Опис (без @-тегів) + параметри з @param як «name — опис». */
28
+ function parseJsDoc(raw) {
29
+ const text = cleanJsDoc(raw)
30
+ const lines = text.split('\n')
31
+ const descLines = []
32
+ const params = []
33
+ let ret = ''
34
+ for (const l of lines) {
35
+ const pm = l.match(/^@param\s+(?:\{[^}]*\}\s+)?\[?([A-Za-z0-9_.]+)\]?\s*(.*)$/)
36
+ const rm = l.match(/^@returns?\s+(?:\{[^}]*\}\s+)?(.*)$/)
37
+ if (pm) {
38
+ const desc = pm[2].trim()
39
+ // «опис.» — JSDoc-заглушка без сенсу; не тягнемо її як факт
40
+ params.push({ name: pm[1], desc: desc === 'опис.' ? '' : desc })
41
+ continue
42
+ }
43
+ if (rm) { ret = rm[1].trim(); continue }
44
+ if (l.startsWith('@')) continue
45
+ descLines.push(l)
46
+ }
47
+ return { desc: descLines.join('\n').trim(), params, ret }
48
+ }
49
+
50
+ /** Провідний блок-коментар файлу (намір), якщо він перед першим import/кодом. */
51
+ function extractFileHeader(src) {
52
+ const m = src.match(/^\s*\/\*\*([\s\S]*?)\*\//)
53
+ if (!m) return ''
54
+ // має бути на самому початку (до import/код)
55
+ if (src.slice(0, m.index).trim() !== '') return ''
56
+ return parseJsDoc(m[0]).desc
57
+ }
58
+
59
+ /**
60
+ * Блок-коментар, що стоїть ВПРИТУЛ перед позицією (лише пробіли між ними).
61
+ * `(?:(?!\*​/)[\s\S])*` гарантує, що тіло не містить `*​/`, тож захоплюється рівно один
62
+ * найближчий блок — без жадібного «перестрибування» через імпорти/код.
63
+ */
64
+ function precedingJsDoc(prefix) {
65
+ const m = prefix.match(/\/\*\*(?:(?!\*\/)[\s\S])*\*\/\s*$/)
66
+ return m ? m[0] : null
67
+ }
68
+
69
+ /** Експорти + JSDoc, що безпосередньо передує кожному. */
70
+ function extractExports(src) {
71
+ const out = []
72
+ const re = /export\s+(?:async\s+)?(function|const|class)\s+([A-Za-z0-9_]+)/g
73
+ let m
74
+ while ((m = re.exec(src))) {
75
+ const [, kind, name] = m
76
+ const jsdocRaw = precedingJsDoc(src.slice(0, m.index))
77
+ out.push({ name, kind, ...(jsdocRaw ? parseJsDoc(jsdocRaw) : { desc: '', params: [], ret: '' }) })
78
+ }
79
+ return out
80
+ }
81
+
82
+ /** Імпорти, класифіковані на stdlib / npm / internal. */
83
+ function extractImports(src) {
84
+ const stdlib = new Set(), npm = new Set(), internal = new Set()
85
+ const re = /^import\s+[\s\S]*?from\s+['"]([^'"]+)['"]/gm
86
+ let m
87
+ while ((m = re.exec(src))) {
88
+ const s = m[1]
89
+ if (s.startsWith('node:') || BUILTIN_MODULES.has(s.split('/')[0])) stdlib.add(s.replace(/^node:/, ''))
90
+ else if (s.startsWith('.') || s.startsWith('/')) internal.add(s)
91
+ else npm.add(s)
92
+ }
93
+ return { stdlib: [...stdlib], npm: [...npm], internal: [...internal] }
94
+ }
95
+
96
+ /** Імена символів, імпортованих із внутрішніх модулів — їх модель не має згадувати. */
97
+ function extractInternalSymbols(src) {
98
+ const out = new Set()
99
+ const re = /import\s+(?:([A-Za-z0-9_$]+)\s*,?\s*)?(?:\{([^}]+)\})?\s+from\s+['"](\.[^'"]+)['"]/g
100
+ let m
101
+ while ((m = re.exec(src))) {
102
+ if (m[1]) out.add(m[1].trim())
103
+ if (m[2]) for (const n of m[2].split(',')) {
104
+ const name = n.replace(/\s+as\s+.*/, '').trim()
105
+ if (name) out.add(name)
106
+ }
107
+ }
108
+ return [...out]
109
+ }
110
+
111
+ /** Поведінкові маркери — евристики регулярками. */
112
+ function extractMarkers(src) {
113
+ // помітні «пропуски»: dir/segment-літерали у фільтрах
114
+ const skips = new Set()
115
+ for (const lit of ['.github', '.git', 'node_modules', 'base/', 'ua/', '.firebase']) {
116
+ if (src.includes(`'${lit}`) || src.includes(`"${lit}`) || src.includes(`/${lit}`)) skips.add(lit)
117
+ }
118
+ return {
119
+ readOnly: !/\b(writeFile|mkdir|rmdir|unlink|appendFile|createWriteStream|rm\()/.test(src),
120
+ catchesErrors: /catch\s*\(/.test(src) || /\btry\s*\{/.test(src),
121
+ returnsFalsyOnFail: /return\s+(false|null|''|"")/.test(src),
122
+ network: /\bfetch\(|https?\.|axios|got\(/.test(src),
123
+ caches: /new Map\(\)|Cache|cache/.test(src),
124
+ skips: [...skips]
125
+ }
126
+ }
127
+
128
+ /**
129
+ * Головний екстрактор: код файлу → факт-лист.
130
+ * @param {string} src вміст файлу
131
+ * @param {string} relPath шлях (для контексту/мови екстрактора)
132
+ * @returns {{relPath:string, lang:string, header:string, exports:Array, imports:object, markers:object}}
133
+ */
134
+ export function extractFacts(src, relPath) {
135
+ const lang = relPath.split('.').pop()
136
+ if (!['js', 'mjs', 'ts'].includes(lang)) {
137
+ return { relPath, lang, unsupported: true, header: '', exports: [], imports: {}, markers: {} }
138
+ }
139
+ return {
140
+ relPath,
141
+ lang,
142
+ header: extractFileHeader(src),
143
+ exports: extractExports(src),
144
+ imports: extractImports(src),
145
+ internalSymbols: extractInternalSymbols(src),
146
+ markers: extractMarkers(src)
147
+ }
148
+ }
149
+
150
+ // CLI для інспекції: node docgen-extract.mjs <file>
151
+ import { isRunAsCli } from '../../../scripts/cli-entry.mjs'
152
+ import { readFileSync } from 'node:fs'
153
+ if (isRunAsCli(import.meta.url)) {
154
+ const file = process.argv[2]
155
+ if (!file) { console.error('Usage: node docgen-extract.mjs <file>'); process.exit(1) }
156
+ const facts = extractFacts(readFileSync(file, 'utf8'), file)
157
+ console.log(JSON.stringify(facts, null, 2))
158
+ }
@@ -0,0 +1,334 @@
1
+ /**
2
+ * docgen-конвеєр (входна точка): код файлу → .md-документація.
3
+ *
4
+ * Інверсія керування: веде цей JS, а локальна модель — лише сервіс перефразування.
5
+ * Stage 0 extractFacts — факти з коду (0 токенів)
6
+ * Stage 1 sectionInstructions — точкові промпти на кожну секцію (спільний KV-cached префікс)
7
+ * Stage 2 stripSignatures — детермінований зріз сигнатур (0 токенів)
8
+ * Stage 2.5 scoreDoc — детермінований скоринг проти фактів (0 токенів)
9
+ * Stage 3 assemble — фіксовані заголовки/порядок + зрізання fence
10
+ * Tier 2 claudeOneShot — хмарний fallback якщо score < QUALITY_THRESHOLD
11
+ *
12
+ * Hybrid routing (sym-threshold):
13
+ * sym < BORDERLINE_SYM_LOW → Tier 1 local (без хмарного рефері)
14
+ * sym ∈ [BORDERLINE_SYM_LOW, sym<4) → Tier 1 + cloudScoreDoc (Haiku) → при низькому балі → Tier 2
15
+ * sym >= DEFAULT_SYM_THRESHOLD → одразу Tier 2 (pre-routing, без local)
16
+ */
17
+ import { readFileSync } from 'node:fs'
18
+ import { basename } from 'node:path'
19
+ import { request } from 'node:http'
20
+ import { env } from 'node:process'
21
+ import Anthropic from '@anthropic-ai/sdk'
22
+ import { extractFacts } from './docgen-extract.mjs'
23
+ import { sectionMessages, oneShotMessages, STYLE, oneShotPromptText } from './docgen-prompts.mjs'
24
+
25
+ const QUALITY_THRESHOLD = 70
26
+
27
+ /** Один виклик чату до ollama зі streaming (токени стримуються → socket активний, жодного timeout). */
28
+ async function ollamaChat(messages, { model, numPredict = 600 }) {
29
+ const body = JSON.stringify({
30
+ model, messages, stream: true, think: false,
31
+ options: { num_ctx: 8192, temperature: 0.2, num_predict: numPredict },
32
+ keep_alive: '15m'
33
+ })
34
+ return new Promise((resolve, reject) => {
35
+ const req = request(
36
+ { hostname: 'localhost', port: 11434, path: '/api/chat', method: 'POST',
37
+ headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) } },
38
+ (res) => {
39
+ let text = '', genTok = 0, buf = ''
40
+ res.on('data', chunk => {
41
+ buf += chunk.toString()
42
+ const lines = buf.split('\n')
43
+ buf = lines.pop()
44
+ for (const line of lines) {
45
+ if (!line.trim()) continue
46
+ try {
47
+ const j = JSON.parse(line)
48
+ text += j.message?.content ?? ''
49
+ if (j.done) genTok = j.eval_count ?? 0
50
+ } catch { /* partial line */ }
51
+ }
52
+ })
53
+ res.on('end', () => resolve({ text, genTok }))
54
+ res.on('error', reject)
55
+ }
56
+ )
57
+ req.on('error', reject)
58
+ req.write(body)
59
+ req.end()
60
+ })
61
+ }
62
+
63
+ /** Прибирає ```-обгортку й випадковий провідний `##`-заголовок із секції. */
64
+ function stripSection(text) {
65
+ let t = text.trim()
66
+ if (t.startsWith('```')) {
67
+ t = t.replace(/^```[a-z]*\n?/, '').replace(/\n?```\s*$/, '').trim()
68
+ }
69
+ t = t.replace(/^#{1,6}\s+.*\n+/, '') // зрізати випадковий заголовок
70
+ return t.trim()
71
+ }
72
+
73
+ /**
74
+ * Stage 2 (детермінований лінт, 0 токенів): зрізає сигнатури `name(args)` → `name`.
75
+ * Два проходи — щоб зняти вкладені виклики на кшталт `check(cwd = process.cwd())`.
76
+ * Не чіпає дужки без ідентифікатора перед ними (напр. `(abie.mdc)`, «(наприклад)»).
77
+ */
78
+ function stripSignatures(text) {
79
+ let t = text
80
+ for (let i = 0; i < 2; i++) t = t.replace(/([`\w$.]+)\([^()]*\)/g, '$1')
81
+ return t
82
+ }
83
+
84
+ /** Розбиває md на секції за ## заголовками → { огляд, поведінка, api, гарантіїповедінки, … } */
85
+ function parseSections(md) {
86
+ const result = {}
87
+ let cur = null
88
+ for (const line of md.split('\n')) {
89
+ const m = line.match(/^##\s+(.+)/)
90
+ if (m) { cur = m[1].toLowerCase().replace(/[^а-яіїєґa-z0-9]/gi, ''); result[cur] = '' }
91
+ else if (cur) result[cur] += line + '\n'
92
+ }
93
+ return result
94
+ }
95
+
96
+ /**
97
+ * Stage 2.5 — детермінований скоринг (0 токенів): перевіряє вихід проти фактів.
98
+ * @returns {{ score: number, issues: string[] }}
99
+ */
100
+ function scoreDoc(md, facts) {
101
+ const s = parseSections(md)
102
+ let score = 100
103
+ const issues = []
104
+
105
+ if (!s['огляд'])
106
+ { score -= 25; issues.push('no-overview') }
107
+
108
+ const behavior = s['поведінка'] ?? ''
109
+ if (behavior.length < 60)
110
+ { score -= 20; issues.push('short-behavior') }
111
+
112
+ const guarantees = s['гарантіїповедінки'] ?? ''
113
+ // Будь-яка згадка "кеш" у Гарантіях коли файл не кешує — галюцинація
114
+ // Негація: "не кешує", "не має кешування", "без кешування", "немає кешу"
115
+ const cacheHit = /кеш/i.test(guarantees) && !/(?:не|без)\s+(?:\S+\s+)?кеш|немає\s+кеш/i.test(guarantees)
116
+ if (!facts.markers?.caches && cacheHit)
117
+ { score -= 20; issues.push('cache-hallucination') }
118
+
119
+ // Перевіряємо лише бектік-обгорнуті імена (`sym`) — уникаємо substring false positives
120
+ const hasName = (text, sym) => text.includes('`' + sym + '`')
121
+ for (const sym of facts.internalSymbols ?? []) {
122
+ const inDoc = hasName(guarantees, sym) || hasName(s['огляд'] ?? '', sym) || hasName(s['поведінка'] ?? '', sym)
123
+ if (inDoc) { score -= 10; issues.push(`internal-name:${sym}`) }
124
+ }
125
+
126
+ return { score: Math.max(0, score), issues }
127
+ }
128
+
129
+ const SCORE_RUBRIC = `Оціни якість документації для JavaScript-модуля за 4 критеріями (1-3 кожен):
130
+
131
+ - огляд: 3=описує роль модуля в системі (ЩО і НАВІЩО); 2=частково розмитий; 1=відсутній або перераховує функції
132
+ - поведінка: 3=бізнес-терміни, без деталей реалізації; 2=деякі impl-деталі; 1=переважно реалізація або відсутня
133
+ - гарантії: 3=лише реальні інваріанти підтверджені кодом, без галюцинацій; 2=частково правильні; 1=вигадані або відсутні
134
+ - стиль: 3=без сигнатур/internal-імен, правильна markdown-структура; 2=дрібні порушення; 1=сигнатури/internal-імена/відсутні заголовки
135
+
136
+ Відповідай ТІЛЬКИ JSON без пояснень:
137
+ {"огляд":N,"поведінка":N,"гарантії":N,"стиль":N,"issues":["коротко про кожен мінус 1-5 слів"]}`
138
+
139
+ /**
140
+ * Stage 2.5 cloud: Claude Haiku оцінює якість доку проти коду + фактів.
141
+ * Використовує найдешевшу хмарну модель — haiku — для мінімальної вартості судді.
142
+ * @returns {{ score: number, scores: object, issues: string[], tok: number }}
143
+ */
144
+ async function cloudScoreDoc(md, facts, src, model = 'claude-haiku-4-5-20251001') {
145
+ const client = new Anthropic()
146
+ const factsTxt = [
147
+ facts.exports?.length ? `Публічні функції: ${facts.exports.map(e => e.name).join(', ')}` : '',
148
+ facts.internalSymbols?.length ? `Внутрішні (не публічні): ${facts.internalSymbols.join(', ')}` : '',
149
+ facts.markers?.caches ? 'Кешування: є' : 'Кешування: немає',
150
+ facts.markers?.network ? 'Мережа: є' : 'Мережа: немає',
151
+ facts.markers?.readOnly ? 'Read-only (не змінює файли/стан)' : ''
152
+ ].filter(Boolean).join('\n')
153
+
154
+ const msg = await client.messages.create({
155
+ model,
156
+ max_tokens: 256,
157
+ system: SCORE_RUBRIC,
158
+ messages: [{
159
+ role: 'user',
160
+ content: [
161
+ { type: 'text', text: `ФАКТИ:\n${factsTxt}`, cache_control: { type: 'ephemeral' } },
162
+ { type: 'text', text: `КОД:\n\`\`\`\n${src.slice(0, 4000)}\n\`\`\``, cache_control: { type: 'ephemeral' } },
163
+ { type: 'text', text: `ДОКУМЕНТАЦІЯ:\n${md}` }
164
+ ]
165
+ }]
166
+ })
167
+ const tok = (msg.usage?.input_tokens ?? 0) + (msg.usage?.output_tokens ?? 0)
168
+ try {
169
+ const j = JSON.parse(msg.content[0]?.text ?? '{}')
170
+ const total = ((j.огляд ?? 0) + (j.поведінка ?? 0) + (j.гарантії ?? 0) + (j.стиль ?? 0)) / 12 * 100
171
+ return { score: Math.round(total), scores: j, issues: j.issues ?? [], tok }
172
+ } catch {
173
+ return { score: 50, scores: {}, issues: ['parse-error'], tok }
174
+ }
175
+ }
176
+
177
+ /** Tier 2: хмарний fallback через Claude коли local-score < QUALITY_THRESHOLD. */
178
+ async function claudeOneShot(facts, src, model = 'claude-sonnet-4-6') {
179
+ const client = new Anthropic()
180
+ const prompt = oneShotPromptText(facts, src)
181
+ const msg = await client.messages.create({
182
+ model,
183
+ max_tokens: 1500,
184
+ system: STYLE,
185
+ messages: [{ role: 'user', content: prompt }]
186
+ })
187
+ const text = msg.content[0]?.text ?? ''
188
+ const genTok = msg.usage?.output_tokens ?? 0
189
+ let md = stripSignatures(stripSection(text))
190
+ if (!md.startsWith('#')) md = `# ${basename(facts.relPath)}\n\n${md}`
191
+ return { md: md + '\n', genTok }
192
+ }
193
+
194
+ /** Stage 3: фіксовані заголовки у фіксованому порядку. */
195
+ function assemble(stem, sections) {
196
+ const order = [
197
+ ['overview', '## Огляд'],
198
+ ['behavior', '## Поведінка'],
199
+ ['api', '## Публічний API'],
200
+ ['guarantees', '## Гарантії поведінки']
201
+ ]
202
+ const parts = [`# ${stem}`]
203
+ for (const [key, title] of order) {
204
+ const body = sections[key]
205
+ if (body && body.trim()) parts.push(`${title}\n\n${body.trim()}`)
206
+ }
207
+ return parts.join('\n\n') + '\n'
208
+ }
209
+
210
+ /** Оркестрований режим: секційно-мінімальний контекст — код інгестується лише в `behavior`. */
211
+ async function generateOrchestrated(facts, src, model) {
212
+ const sections = {}
213
+ let genTok = 0
214
+ for (const s of sectionMessages(facts, src)) {
215
+ const { text, genTok: g } = await ollamaChat(s.messages, { model, numPredict: s.numPredict })
216
+ sections[s.key] = stripSignatures(stripSection(text))
217
+ genTok += g
218
+ }
219
+ return { md: assemble(basename(facts.relPath), sections), genTok }
220
+ }
221
+
222
+ /** One-shot режим: один промпт на весь документ. */
223
+ async function generateOneShot(facts, src, model) {
224
+ const { text, genTok } = await ollamaChat(oneShotMessages(facts, src), { model, numPredict: 1500 })
225
+ let md = stripSignatures(stripSection(text)) // Stage-2 лінт і для one-shot
226
+ if (!md.startsWith('#')) md = `# ${basename(facts.relPath)}\n\n${md}`
227
+ return { md: md + '\n', genTok }
228
+ }
229
+
230
+ /** Файли з sym ≥ цього значення одразу йдуть у Tier 2 (без локального проходу). */
231
+ const DEFAULT_SYM_THRESHOLD = 4
232
+ /** Файли з sym ≥ цього значення отримують хмарного рефері (Haiku) після локального проходу. */
233
+ const BORDERLINE_SYM_LOW = 2
234
+
235
+ /**
236
+ * Головний API: файл → { md, genTok, ms, score, issues, tier }.
237
+ *
238
+ * Routing (sym-threshold):
239
+ * sym < BORDERLINE_SYM_LOW → Tier 1 (без хмарного рефері)
240
+ * sym ∈ [BORDERLINE_SYM_LOW, symThreshold) → Tier 1 + cloudScoreDoc (Haiku) як рефері
241
+ * sym >= symThreshold → Pre-routing одразу Tier 2
242
+ * scoreCloud=true → примусово запускає cloudScoreDoc для всіх Tier 1
243
+ *
244
+ * @param {string} scoreModel — модель для хмарного рефері (Haiku за замовч.)
245
+ * @param {string} cloudModel — модель для Tier 2 генерації (Sonnet за замовч.)
246
+ * @param {boolean} scoreCloud — якщо true, cloudScoreDoc запускається для всіх Tier 1 файлів
247
+ */
248
+ export async function generateDoc(file, {
249
+ model = 'gemma3:4b',
250
+ mode = 'orchestrated',
251
+ scoreModel = 'claude-haiku-4-5-20251001',
252
+ cloudModel = 'claude-sonnet-4-6',
253
+ threshold = QUALITY_THRESHOLD,
254
+ scoreCloud = false,
255
+ symThreshold = DEFAULT_SYM_THRESHOLD
256
+ } = {}) {
257
+ const src = readFileSync(file, 'utf8')
258
+ const facts = extractFacts(src, file)
259
+ const t0 = Date.now()
260
+
261
+ // Pre-routing: складні файли (sym ≥ symThreshold) → одразу Tier 2, не витрачаємо local-час
262
+ const complexity = facts.internalSymbols?.length ?? 0
263
+ if (complexity >= symThreshold && env.ANTHROPIC_API_KEY) {
264
+ const r2 = await claudeOneShot(facts, src, cloudModel)
265
+ return { ...r2, ms: Date.now() - t0, score: null, issues: [`pre-routed:sym=${complexity}`], tier: 2 }
266
+ }
267
+
268
+ let r = facts.unsupported
269
+ ? await generateOneShot(facts, src, model)
270
+ : mode === 'oneshot'
271
+ ? await generateOneShot(facts, src, model)
272
+ : await generateOrchestrated(facts, src, model)
273
+
274
+ // Stage 2.5a: детермінований скоринг (0 токенів)
275
+ const { score: detScore, issues: detIssues } = scoreDoc(r.md, facts)
276
+
277
+ // Stage 2.5b: cloudScoreDoc (Haiku) як рефері для borderline-файлів або при scoreCloud=true
278
+ const isBorderline = complexity >= BORDERLINE_SYM_LOW && complexity < symThreshold
279
+ if ((isBorderline || scoreCloud) && env.ANTHROPIC_API_KEY) {
280
+ const cs = await cloudScoreDoc(r.md, facts, src, scoreModel)
281
+ if (cs.score < threshold) {
282
+ const r2 = await claudeOneShot(facts, src, cloudModel)
283
+ return { ...r2, ms: Date.now() - t0, score: cs.score, cloudScores: cs.scores,
284
+ issues: cs.issues, detScore, detIssues, tier: 2 }
285
+ }
286
+ return { ...r, ms: Date.now() - t0, score: cs.score, cloudScores: cs.scores,
287
+ issues: cs.issues, detScore, detIssues, tier: 1 }
288
+ }
289
+
290
+ // Детермінований fallback (без хмарного рефері)
291
+ if (detScore < threshold && env.ANTHROPIC_API_KEY) {
292
+ const r2 = await claudeOneShot(facts, src, cloudModel)
293
+ return { ...r2, ms: Date.now() - t0, score: detScore, issues: detIssues, tier: 2 }
294
+ }
295
+
296
+ return { ...r, ms: Date.now() - t0, score: detScore, issues: detIssues, tier: 1 }
297
+ }
298
+
299
+ // CLI: node docgen-gen.mjs <file> [--oneshot] [--score-cloud] [--model <m>] [--score-model <m>] [--sym-threshold N] [--tier-only]
300
+ import { isRunAsCli } from '../../../scripts/cli-entry.mjs'
301
+ if (isRunAsCli(import.meta.url)) {
302
+ const args = process.argv.slice(2)
303
+ const file = args.find(a => !a.startsWith('--'))
304
+ const mode = args.includes('--oneshot') ? 'oneshot' : 'orchestrated'
305
+ const scoreCloud = args.includes('--score-cloud')
306
+ const tierOnly = args.includes('--tier-only')
307
+ const mi = args.indexOf('--model'); const model = mi >= 0 ? args[mi + 1] : 'gemma3:4b'
308
+ const smi = args.indexOf('--score-model'); const scoreModel = smi >= 0 ? args[smi + 1] : 'claude-haiku-4-5-20251001'
309
+ const si = args.indexOf('--sym-threshold'); const symThreshold = si >= 0 ? Number(args[si + 1]) : DEFAULT_SYM_THRESHOLD
310
+ if (!file) {
311
+ console.error('Usage: node docgen-gen.mjs <file> [--oneshot] [--score-cloud] [--model <m>] [--score-model <m>] [--sym-threshold N] [--tier-only]')
312
+ process.exit(1)
313
+ }
314
+ if (tierOnly) {
315
+ const src = readFileSync(file, 'utf8')
316
+ const facts = extractFacts(src, file)
317
+ const sym = facts.internalSymbols?.length ?? 0
318
+ let label, icon
319
+ if (sym >= symThreshold) {
320
+ icon = '☁️ '; label = `Tier 2 cloud (sym=${sym} ≥ ${symThreshold}, pre-routed)`
321
+ } else if (sym >= BORDERLINE_SYM_LOW) {
322
+ icon = '🔀'; label = `Tier 1+judge (sym=${sym} ∈ [${BORDERLINE_SYM_LOW},${symThreshold}), Haiku рефері)`
323
+ } else {
324
+ icon = '💻'; label = `Tier 1 local (sym=${sym} < ${BORDERLINE_SYM_LOW})`
325
+ }
326
+ process.stdout.write(`${icon} ${label} | ${file}\n`)
327
+ process.exit(0)
328
+ }
329
+ const r = await generateDoc(file, { model, mode, scoreCloud, scoreModel, symThreshold })
330
+ const issuesTxt = r.issues?.length ? ` issues=${r.issues.join(',')}` : ''
331
+ const cloudTxt = r.cloudScores ? ` cloud-scores=${JSON.stringify(r.cloudScores)}` : ''
332
+ process.stderr.write(`[tier${r.tier} ${mode}] ${r.ms}ms / ${r.genTok} tok / score=${r.score}${issuesTxt}${cloudTxt}\n`)
333
+ process.stdout.write(r.md)
334
+ }
@@ -20,7 +20,9 @@ export const DOCGEN_IGNORE_GLOBS = Object.freeze([
20
20
  '.worktrees/**',
21
21
  '**/benchmarks/**',
22
22
  '**/demo/**',
23
- '**/docs/**'
23
+ '**/docs/**',
24
+ 'npm/reports/**',
25
+ 'npm/bin/**'
24
26
  ])
25
27
 
26
28
  const IGNORE_MATCHERS = DOCGEN_IGNORE_GLOBS.map(glob => picomatch(glob, { dot: true }))
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Stage 1 docgen-конвеєра: факт-лист + код → точкові промпти на кожну секцію.
3
+ *
4
+ * v2 — СЕКЦІЙНО-МІНІМАЛЬНИЙ контекст: код іде ЛИШЕ у `Поведінку`. `Огляд` бере тільки
5
+ * header, `API` — лише список експортів, `Гарантії` — лише markers. Так інгест коду
6
+ * оплачується один раз (а не на кожну секцію), і оркестрація перестає програвати в часі.
7
+ */
8
+
9
+ export const STYLE = [
10
+ 'Ти технічний письменник. Пишеш лаконічну ПОВЕДІНКОВУ документацію до коду українською, чистим Markdown.',
11
+ 'Пиши ЩО і НАВІЩО, не ЯК. Без вступів і висновків. Не обгортай у ```-блок.',
12
+ 'Заборонено: сигнатури, типи, параметри функцій; перелік stdlib-модулів; опис regex чи внутрішніх приватних імен.'
13
+ ].join(' ')
14
+
15
+ /** Короткий людиночитний витяг фактів (без коду). */
16
+ function factsSummary(facts) {
17
+ const m = facts.markers || {}
18
+ const lines = []
19
+ if (facts.header) lines.push(`Намір файлу: ${facts.header.replace(/\n/g, ' ')}`)
20
+ if (facts.exports?.length) lines.push(`Публічні функції: ${facts.exports.map(e => e.name).join(', ')}`)
21
+ if (m.skips?.length) lines.push(`Свідомо пропускає шляхи: ${m.skips.join(', ')}`)
22
+ lines.push(`Read-only: ${m.readOnly ? 'так' : 'ні'}`)
23
+ if (m.catchesErrors) lines.push('Перехоплює помилки (fail-safe), не кидає винятків назовні')
24
+ if (m.returnsFalsyOnFail) lines.push('За невдачі повертає false/null замість винятку')
25
+ lines.push(m.caches ? 'Кешування: так, у межах прогону' : 'Кешування: НЕМАЄ — не згадуй кеш у гарантіях')
26
+ if (m.network) lines.push('Звертається до мережі')
27
+ else lines.push('Робота з мережею: немає')
28
+ return lines.join('\n')
29
+ }
30
+
31
+ const msgs = (system, user) => [{ role: 'system', content: system }, { role: 'user', content: user }]
32
+
33
+ /**
34
+ * Секційні набори messages з МІНІМАЛЬНИМ контекстом під кожну секцію.
35
+ * Код потрапляє лише в `behavior`; решта секцій — на факт-листі.
36
+ * @returns {Array<{key:string, messages:object[], numPredict:number}>}
37
+ */
38
+ export function sectionMessages(facts, src) {
39
+ const factsTxt = factsSummary(facts)
40
+ const multi = (facts.exports?.length || 0) > 1
41
+ const out = []
42
+
43
+ // Огляд — лише факти (без коду)
44
+ out.push({
45
+ key: 'overview', numPredict: 220,
46
+ messages: msgs(`${STYLE}\n\nВІДОМІ ФАКТИ:\n${factsTxt}`,
47
+ 'Напиши вміст секції «Огляд»: 1-3 речення — що файл робить і навіщо існує (роль у системі). Без заголовка, без переліку функцій.')
48
+ })
49
+
50
+ // Поведінка — ЄДИНА секція, якій потрібен код
51
+ out.push({
52
+ key: 'behavior', numPredict: 500,
53
+ messages: msgs(`${STYLE}\n\nФАЙЛ ${facts.relPath}:\n\`\`\`\n${src}\n\`\`\`\n\nВІДОМІ ФАКТИ:\n${factsTxt}`,
54
+ `Напиши вміст секції «Поведінка»: ${multi ? 'для кожної публічної функції — один короткий пункт «що вона робить»' : 'нумерований алгоритм у бізнес-термінах'}. Якщо у фактах є свідомі пропуски шляхів — згадай їх там, де доречно (не вигадуй інших «не перевіряє»). НЕ пиши аргументи функцій у дужках, без regex.${facts.internalSymbols?.length ? ` НЕ згадуй за іменами службові функції: ${facts.internalSymbols.join(', ')}.` : ''} Без заголовка.`)
55
+ })
56
+
57
+ // API — лише список експортів (без коду)
58
+ if (multi || facts.exports?.some(e => e.desc)) {
59
+ const list = facts.exports.map(e => `- ${e.name}: ${e.desc || '(сформулюй стисло з наміру файлу)'}`).join('\n')
60
+ out.push({
61
+ key: 'api', numPredict: 320,
62
+ messages: msgs(STYLE,
63
+ `Перепиши цей список як стислі маркери «назва — що робить», СВОЇМИ словами (не копіюй дослівно), без типів і сигнатур. Використовуй РІВНО ці назви, не додавай і не прибирай:\n${list}\nБез заголовка.`)
64
+ })
65
+ }
66
+
67
+ // Гарантії — лише markers (без коду)
68
+ out.push({
69
+ key: 'guarantees', numPredict: 300,
70
+ messages: msgs(`${STYLE}\n\nВІДОМІ ФАКТИ:\n${factsTxt}`,
71
+ 'Напиши вміст секції «Гарантії поведінки» як маркери-інваріанти СУВОРО на основі ВІДОМИХ ФАКТІВ (read-only, fail-safe, пропуски). Згадуй кеш ЛИШЕ якщо у фактах прямо є «Кешує». Без сигнатур у дужках і без імен внутрішніх структур/Map-ів/кешів. Не вигадуй гарантій, яких немає у фактах. Без заголовка.')
72
+ })
73
+
74
+ return out
75
+ }
76
+
77
+ /** One-shot messages (база для порівняння). */
78
+ export function oneShotMessages(facts, src) {
79
+ const multi = (facts.exports?.length || 0) > 1
80
+ return msgs(STYLE,
81
+ `Напиши документацію для файлу. Секції: ## Огляд (1-3 речення), ## Поведінка (нумерований/маркований алгоритм), ${multi ? '## Публічний API (назва + що робить), ' : ''}## Гарантії поведінки.\n\nФАЙЛ ${facts.relPath}:\n\`\`\`\n${src}\n\`\`\``)
82
+ }
83
+
84
+ /** Лише текст user-промпту для one-shot (для хмарного fallback через Anthropic SDK). */
85
+ export function oneShotPromptText(facts, src) {
86
+ const multi = (facts.exports?.length || 0) > 1
87
+ return `Напиши документацію для файлу. Секції: ## Огляд (1-3 речення), ## Поведінка (нумерований/маркований алгоритм), ${multi ? '## Публічний API (назва + що робить), ' : ''}## Гарантії поведінки.\n\nФАЙЛ ${facts.relPath}:\n\`\`\`\n${src}\n\`\`\``
88
+ }
@@ -8,42 +8,16 @@ description: >-
8
8
 
9
9
  ## Scope
10
10
 
11
- Цей скіл відповідає **лише за структуру** проєкту: щоб `.cursor/rules/` + `npx @nitra/cursor fix` були задоволені (наявність конфігів, залежностей, скриптів, GitHub workflows, відсутність заборонених файлів). **Лінт-порушення у самому коді** (ESLint, oxlint, jscpd, cspell, knip, sonarjs, stylelint тощо) — **поза скоупом**; їх діагностує й виправляє **`/n-lint`** (`bun run lint`). Не запускай `bun run lint` із цього скілу і не намагайся виправляти його порушення тут — це задача `/n-lint`. Якщо `npx @nitra/cursor fix` чистий, а `bun run lint` лишився червоним — запусти `/n-lint` окремо.
11
+ Цей скіл відповідає **лише за структуру** проєкту: щоб `.cursor/rules/` + `npx @nitra/cursor fix` були задоволені (наявність конфігів, залежностей, скриптів, GitHub workflows, відсутність заборонених файлів). **Лінт-порушення у самому коді** (ESLint, oxlint, jscpd, cspell, knip, sonarjs, stylelint тощо) — **поза скоупом**; їх діагностує й виправляє **`/n-lint`** (`bun run lint`).
12
12
 
13
13
  ## Workflow
14
14
 
15
- 1. **Діагностика** — запусти перевірку через retry-обгортку `n_cursor_npx` (визначена у worktree-preflight, крок 0.1: переживає транзитну CDN-гонку щойно опублікованої версії, а реальний `❌` від `fix` віддає одразу). Прапорець `--json` дає **структурований** результат у stdout, щоб не парсити термінальний текст. За замовчуванням — лише правила з `.cursor/rules/*.mdc`, для яких у пакеті є programmatic check; повний набір — явні аргументи: `n_cursor_npx fix bun ga --json`:
16
-
17
- ```bash
18
- n_cursor_npx fix --json
19
- ```
20
-
21
- 2. **Аналіз** — розбери JSON `{ total, failed, rules: [{ ruleId, ok, output }] }`. Працюй **лише** з елементами `ok:false`; їх `output` містить готові `❌`-повідомлення правила (не парси stdout вручну, не визначай правила з тексту). Якщо `failed === 0` — нічого виправляти.
22
-
23
- 3. **Виправлення** — для кожного `❌` відкрий відповідне правило з `.cursor/rules/` і виправ:
24
- - Створи відсутні конфігураційні файли (`.cspell.json`, `.oxfmtrc.json`, `eslint.config.js`, тощо)
25
- - Додай відсутні залежності до `package.json`
26
- - Створи або оновити `.vscode/settings.json` та `extensions.json`
27
- - Створи відсутні GitHub Actions workflows у `.github/workflows/`
28
- - Видали заборонені файли та залежності (`package-lock.json`, `yarn.lock`, prettier, тощо)
29
- - Оновити скрипти в `package.json`
30
-
31
- 4. **Встановлення** — якщо були змінені залежності:
32
-
33
15
  ```bash
34
- bun i
16
+ n_cursor_npx fix
35
17
  ```
36
18
 
37
- 5. **Форматування**відформатуй змінені файли:
19
+ Exit 0 = чисто, 1 = є unresolved (перевір вивід буде список правил що не закрились після 3 ітерацій).
38
20
 
39
- ```bash
40
- oxfmt .
41
- ```
42
-
43
- 6. **Верифікація** — перевір що все виправлено (та сама retry-обгортка `n_cursor_npx`); чекаєш `failed === 0`:
44
-
45
- ```bash
46
- n_cursor_npx fix --json
47
- ```
21
+ Якщо змінились залежності — `bun i`. Якщо змінились JS/TS файли — `oxfmt .`.
48
22
 
49
- 7. **Результат** `failed` має стати `0` (усі правила `ok:true`). Якщо лишились `ok:false` — повтори кроки 3-6. Лінт-помилки від `bun run lint` тут **не виправляй** — вони на скіл `/n-lint`.
23
+ Для конкретних правил: `n_cursor_npx fix bun ga`.