@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.
- package/CHANGELOG.md +13 -0
- package/bin/n-cursor.js +72 -50
- package/lib/llm.mjs +60 -47
- package/lib/models.mjs +1 -1
- package/lib/omlx-trace.mjs +158 -0
- package/lib/omlx.mjs +49 -11
- package/package.json +1 -1
- package/rules/js-bun-db/js-bun-db.mdc +7 -7
- package/rules/js-lint/js-lint.mdc +14 -1
- package/rules/js-run/js-run.mdc +16 -16
- package/rules/k8s/js/manifests.mjs +144 -82
- package/rules/npm-module/js/header_doc_pointer.mjs +72 -27
- package/rules/npm-module/js/rule_meta.mjs +72 -36
- package/rules/npm-module/js/skill_meta.mjs +59 -35
- package/rules/style-lint/js/tooling.mjs +13 -4
- package/rules/style-lint/style-lint.mdc +1 -1
- package/rules/test/js/data/stryker_config/stryker.config.baseline.mjs +1 -1
- package/rules/test/js/data/stryker_config/stryker.config.vue.baseline.mjs +1 -1
- package/rules/test/js/stryker_config.mjs +33 -5
- package/rules/test/js/vitest-config-pool-forks.mjs +11 -7
- package/rules/test/test.mdc +9 -9
- package/rules/vue/vue.mdc +6 -6
- package/scripts/coverage-classify/index.mjs +5 -17
- package/scripts/coverage-classify/verdict-schema.mjs +1 -1
- package/scripts/lib/assert-project-root.mjs +1 -1
- package/scripts/lib/discover-check-rules-from-cursor.mjs +1 -1
- package/scripts/lib/rule-predicates.mjs +30 -18
- package/scripts/lib/run-rule-cli.mjs +1 -1
- package/scripts/lib/run-standard-rule.mjs +1 -1
- package/scripts/post-tool-use-fix.mjs +3 -3
- package/scripts/skills-cli.mjs +5 -5
- package/scripts/worktree-cli.mjs +5 -5
- package/skills/doc-files/js/docgen-extract.mjs +1 -1
- package/skills/doc-files/js/docgen-files-batch.mjs +65 -34
- package/skills/doc-files/js/docgen-gen.mjs +121 -36
- package/skills/doc-files/js/docgen-prompts.mjs +20 -5
- package/skills/fix/js/llm-worker.mjs +10 -22
- package/skills/fix/js/orchestrator.mjs +64 -35
- package/skills/fix/js/t0.mjs +44 -32
- package/skills/start-check/js/check.mjs +1 -1
package/scripts/worktree-cli.mjs
CHANGED
|
@@ -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:
|
|
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:
|
|
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:
|
|
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:
|
|
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?:
|
|
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
|
-
* Опис (без @-тегів) + параметри з
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
31
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 речення — що файл робить і навіщо існує (роль у системі). Узагальнюй САМЕ описану поведінку, не додавай нових фактів. Без заголовка, без переліку функцій. Заборонені абстрактні формули без конкретики («перевірка/валідація/обробка даних», «відповідність контракту», «застосовує логіку») — пиши, ЩО саме і за яким
|
|
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 {
|
|
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
|
-
*
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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:
|
|
112
|
+
return { text: '', error: msg }
|
|
124
113
|
}
|
|
125
|
-
return { text: r.stdout?.trim() ?? '' }
|
|
126
114
|
}
|
|
127
115
|
|
|
128
116
|
/**
|