@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.
- package/CHANGELOG.md +17 -0
- package/bin/n-cursor.js +29 -9
- package/package.json +1 -1
- package/rules/ga/docs/fix.md +16 -149
- package/rules/ga/js/docs/lint.md +12 -93
- package/rules/ga/js/docs/workflows.md +28 -213
- package/rules/ga/lint/docs/lint.md +24 -206
- package/skills/docgen/js/docgen-extract.mjs +158 -0
- package/skills/docgen/js/docgen-gen.mjs +334 -0
- package/skills/docgen/js/docgen-ignore.mjs +3 -1
- package/skills/docgen/js/docgen-prompts.mjs +88 -0
- package/skills/fix/SKILL.md +5 -31
- package/skills/fix/js/llm-worker.mjs +181 -0
- package/skills/fix/js/orchestrator.mjs +128 -0
- package/skills/fix/js/t0.mjs +229 -0
- package/skills/fix/meta.json +1 -1
|
@@ -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
|
+
}
|
package/skills/fix/SKILL.md
CHANGED
|
@@ -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`).
|
|
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
|
-
|
|
16
|
+
n_cursor_npx fix
|
|
35
17
|
```
|
|
36
18
|
|
|
37
|
-
|
|
19
|
+
Exit 0 = чисто, 1 = є unresolved (перевір вивід — буде список правил що не закрились після 3 ітерацій).
|
|
38
20
|
|
|
39
|
-
|
|
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
|
-
|
|
23
|
+
Для конкретних правил: `n_cursor_npx fix bun ga`.
|