@nitra/cursor 5.3.0 → 5.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/bin/n-cursor.js +72 -50
- package/lib/models.mjs +1 -1
- package/package.json +1 -1
- 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/scripts/coverage-classify/index.mjs +2 -2
- 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-ignore.mjs +1 -0
- package/skills/doc-files/js/docgen-prompts.mjs +20 -5
- package/skills/doc-files/js/docgen-scan.mjs +1 -1
- 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
|
@@ -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
|
|
|
@@ -11,7 +11,7 @@ import { isDocgenIgnored } from './docgen-ignore.mjs'
|
|
|
11
11
|
import { QUALITY_THRESHOLD, readDocQuality, staleness } from './docgen-crc.mjs'
|
|
12
12
|
|
|
13
13
|
/** Кодові розширення, для яких генеруємо документацію. */
|
|
14
|
-
const SOURCE_EXTENSIONS = new Set(['.js', '.mjs', '.ts', '.vue', '.py'])
|
|
14
|
+
const SOURCE_EXTENSIONS = new Set(['.js', '.mjs', '.ts', '.vue', '.py', '.rs'])
|
|
15
15
|
|
|
16
16
|
/** `*.test.*`, `*.spec.*` — тести, документувати не треба. */
|
|
17
17
|
const TEST_FILE_RE = /\.(?:test|spec)\.[^.]+$/u
|
|
@@ -11,18 +11,74 @@ const DEFAULT_MAX_ITER = 3
|
|
|
11
11
|
const ESCALATE_AFTER = 2
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
|
-
*
|
|
15
|
-
* @param {string}
|
|
16
|
-
* @returns {
|
|
14
|
+
* Парсить `--max-iter N` і збирає rule-filter (позиційні аргументи без прапорців).
|
|
15
|
+
* @param {string[]} args CLI аргументи після 'fix'
|
|
16
|
+
* @returns {{ maxIter: number, ruleFilter: string[] }} ліміт ітерацій і фільтр правил
|
|
17
17
|
*/
|
|
18
|
-
|
|
19
|
-
const { runLlmWorker, MODEL, MODEL_HEAVY } = await import('./llm-worker.mjs')
|
|
20
|
-
|
|
18
|
+
function parseOrchestratorArgs(args) {
|
|
21
19
|
const maxIterIdx = args.indexOf('--max-iter')
|
|
22
20
|
const maxIter =
|
|
23
21
|
maxIterIdx === -1 ? DEFAULT_MAX_ITER : Number(args[maxIterIdx + 1] ?? DEFAULT_MAX_ITER) || DEFAULT_MAX_ITER
|
|
24
22
|
const skipIdxs = new Set(maxIterIdx === -1 ? [] : [maxIterIdx, maxIterIdx + 1])
|
|
25
23
|
const ruleFilter = args.filter((a, i) => !a.startsWith('-') && !skipIdxs.has(i))
|
|
24
|
+
return { maxIter, ruleFilter }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Крок T0-auto: детермінований фікс без LLM, повертає правила, що лишились.
|
|
29
|
+
* @param {string} cwd корінь проєкту
|
|
30
|
+
* @param {string[]} ruleFilter фільтр правил
|
|
31
|
+
* @param {Array<{ ruleId: string }>} failed правила перед кроком
|
|
32
|
+
* @returns {Array<{ ruleId: string, ok: boolean, output: string }>} правила після T0
|
|
33
|
+
*/
|
|
34
|
+
function runT0Step(cwd, ruleFilter, failed) {
|
|
35
|
+
spawnSync('bun', [N_CURSOR_BIN, 'fix-t0', ...ruleFilter], { cwd, stdio: 'pipe' })
|
|
36
|
+
|
|
37
|
+
const afterT0 = runFixCheck(cwd, ruleFilter)
|
|
38
|
+
const failedAfterT0 = afterT0?.rules.filter(r => !r.ok) ?? failed
|
|
39
|
+
const t0Fixed = failed.filter(r => !failedAfterT0.some(f => f.ruleId === r.ruleId))
|
|
40
|
+
|
|
41
|
+
if (t0Fixed.length > 0) {
|
|
42
|
+
console.log(` ⚙️ T0-auto: ${t0Fixed.map(r => r.ruleId).join(', ')}`)
|
|
43
|
+
}
|
|
44
|
+
return failedAfterT0
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Крок T1: LLM через pi для кожного правила, з ескалацією моделі за провалами.
|
|
49
|
+
* @param {Array<{ ruleId: string, output: string }>} failed правила до фіксу
|
|
50
|
+
* @param {string} cwd корінь проєкту
|
|
51
|
+
* @param {Map<string, number>} failCount ruleId → кількість провалів підряд (мутується)
|
|
52
|
+
* @param {{ runLlmWorker: (ruleId: string, output: string, projectRoot: string, opts: {model: string}) => Promise<{ok: boolean, error?: string}>, MODEL: string, MODEL_HEAVY: string }} worker воркер і моделі
|
|
53
|
+
* @returns {Promise<void>}
|
|
54
|
+
*/
|
|
55
|
+
async function runLlmStep(failed, cwd, failCount, { runLlmWorker, MODEL, MODEL_HEAVY }) {
|
|
56
|
+
for (const rule of failed) {
|
|
57
|
+
const prevFails = failCount.get(rule.ruleId) ?? 0
|
|
58
|
+
const model = prevFails >= ESCALATE_AFTER ? MODEL_HEAVY : MODEL
|
|
59
|
+
const label = model || 'pi'
|
|
60
|
+
|
|
61
|
+
const result = await runLlmWorker(rule.ruleId, rule.output, cwd, { model })
|
|
62
|
+
|
|
63
|
+
if (result.ok) {
|
|
64
|
+
console.log(` ⚡ LLM (${label}): ${rule.ruleId}`)
|
|
65
|
+
failCount.delete(rule.ruleId)
|
|
66
|
+
} else {
|
|
67
|
+
failCount.set(rule.ruleId, prevFails + 1)
|
|
68
|
+
const hint = (result.error ?? '').slice(0, 200)
|
|
69
|
+
console.log(` ⚡ LLM (${label}): ${rule.ruleId} ❌ ${hint}`)
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* @param {string[]} args CLI аргументи після 'fix'
|
|
76
|
+
* @param {string} cwd корінь проєкту
|
|
77
|
+
* @returns {Promise<number>} 0 = all clean, 1 = unresolved
|
|
78
|
+
*/
|
|
79
|
+
export async function runOrchestratorCli(args, cwd) {
|
|
80
|
+
const worker = await import('./llm-worker.mjs')
|
|
81
|
+
const { maxIter, ruleFilter } = parseOrchestratorArgs(args)
|
|
26
82
|
|
|
27
83
|
/** @type {Map<string, number>} ruleId → кількість LLM-провалів підряд */
|
|
28
84
|
const failCount = new Map()
|
|
@@ -48,37 +104,10 @@ export async function runOrchestratorCli(args, cwd) {
|
|
|
48
104
|
if (ruleFilter.length) console.log(` filter: ${ruleFilter.join(', ')}`)
|
|
49
105
|
|
|
50
106
|
for (let iter = 1; iter <= maxIter; iter++) {
|
|
51
|
-
|
|
52
|
-
spawnSync('bun', [N_CURSOR_BIN, 'fix-t0', ...ruleFilter], { cwd, stdio: 'pipe' })
|
|
53
|
-
|
|
54
|
-
const afterT0 = runFixCheck(cwd, ruleFilter)
|
|
55
|
-
const failedAfterT0 = afterT0?.rules.filter(r => !r.ok) ?? failed
|
|
56
|
-
const t0Fixed = failed.filter(r => !failedAfterT0.some(f => f.ruleId === r.ruleId))
|
|
57
|
-
|
|
58
|
-
if (t0Fixed.length > 0) {
|
|
59
|
-
console.log(` ⚙️ T0-auto: ${t0Fixed.map(r => r.ruleId).join(', ')}`)
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
failed = failedAfterT0
|
|
107
|
+
failed = runT0Step(cwd, ruleFilter, failed)
|
|
63
108
|
if (failed.length === 0) break
|
|
64
109
|
|
|
65
|
-
|
|
66
|
-
for (const rule of failed) {
|
|
67
|
-
const prevFails = failCount.get(rule.ruleId) ?? 0
|
|
68
|
-
const model = prevFails >= ESCALATE_AFTER ? MODEL_HEAVY : MODEL
|
|
69
|
-
const label = model || 'pi'
|
|
70
|
-
|
|
71
|
-
const result = await runLlmWorker(rule.ruleId, rule.output, cwd, { model })
|
|
72
|
-
|
|
73
|
-
if (result.ok) {
|
|
74
|
-
console.log(` ⚡ LLM (${label}): ${rule.ruleId}`)
|
|
75
|
-
failCount.delete(rule.ruleId)
|
|
76
|
-
} else {
|
|
77
|
-
failCount.set(rule.ruleId, prevFails + 1)
|
|
78
|
-
const hint = (result.error ?? '').slice(0, 200)
|
|
79
|
-
console.log(` ⚡ LLM (${label}): ${rule.ruleId} ❌ ${hint}`)
|
|
80
|
-
}
|
|
81
|
-
}
|
|
110
|
+
await runLlmStep(failed, cwd, failCount, worker)
|
|
82
111
|
|
|
83
112
|
// Перевірка після LLM
|
|
84
113
|
const afterLLM = runFixCheck(cwd, ruleFilter)
|
package/skills/fix/js/t0.mjs
CHANGED
|
@@ -113,6 +113,43 @@ const HERE = dirname(fileURLToPath(import.meta.url))
|
|
|
113
113
|
/** Абсолютний шлях до npm/bin/n-cursor.js відносно цього файлу */
|
|
114
114
|
const N_CURSOR_BIN = join(HERE, '../../../bin/n-cursor.js')
|
|
115
115
|
|
|
116
|
+
/**
|
|
117
|
+
* Запускає `_fix-check` і парсить JSON-результат.
|
|
118
|
+
* @param {string[]} ruleFilter список rule-ids (порожній — усі)
|
|
119
|
+
* @param {string} cwd корінь проєкту
|
|
120
|
+
* @returns {{ rules: Array<{ ruleId: string, ok: boolean, output: string }> } | { _empty: true } | { _badJson: true }} JSON або маркер помилки
|
|
121
|
+
*/
|
|
122
|
+
function fixCheck(ruleFilter, cwd) {
|
|
123
|
+
const r = spawnSync('bun', [N_CURSOR_BIN, '_fix-check', ...ruleFilter], { cwd, encoding: 'utf8', timeout: 120_000 })
|
|
124
|
+
const raw = r.stdout?.trim()
|
|
125
|
+
if (!raw) return { _empty: true, stderr: r.stderr }
|
|
126
|
+
try {
|
|
127
|
+
return JSON.parse(raw)
|
|
128
|
+
} catch {
|
|
129
|
+
return { _badJson: true }
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Застосовує T0-auto до кожного провального правила, розділяючи на applied/skipped.
|
|
135
|
+
* @param {Array<{ ruleId: string, output: string }>} failed провальні правила
|
|
136
|
+
* @param {string} cwd корінь проєкту
|
|
137
|
+
* @returns {{ applied: Array<{ ruleId: string, actions: string[] }>, skipped: string[] }} застосовані й пропущені
|
|
138
|
+
*/
|
|
139
|
+
function applyT0ToFailed(failed, cwd) {
|
|
140
|
+
const applied = []
|
|
141
|
+
const skipped = []
|
|
142
|
+
for (const r of failed) {
|
|
143
|
+
const result = applyT0Auto(r.ruleId, r.output, cwd)
|
|
144
|
+
if (result.applied) {
|
|
145
|
+
applied.push({ ruleId: r.ruleId, actions: result.actions })
|
|
146
|
+
} else {
|
|
147
|
+
skipped.push(r.ruleId)
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return { applied, skipped }
|
|
151
|
+
}
|
|
152
|
+
|
|
116
153
|
/**
|
|
117
154
|
* CLI підкоманда `n-cursor fix-t0 [rule...]`.
|
|
118
155
|
* Запускає `fix --json`, застосовує T0-auto для кожного violation,
|
|
@@ -126,22 +163,13 @@ export function runT0AutoCli(args, cwd) {
|
|
|
126
163
|
const verbose = args.includes('--verbose') || args.includes('-v')
|
|
127
164
|
|
|
128
165
|
// 1. Запустити fix --json
|
|
129
|
-
const
|
|
130
|
-
|
|
131
|
-
encoding: 'utf8',
|
|
132
|
-
timeout: 120_000
|
|
133
|
-
})
|
|
134
|
-
const raw = fixResult.stdout?.trim()
|
|
135
|
-
if (!raw) {
|
|
166
|
+
const fixJson = fixCheck(ruleFilter, cwd)
|
|
167
|
+
if (fixJson._empty) {
|
|
136
168
|
console.error(`n-cursor fix-t0: fix --json повернув порожній stdout`)
|
|
137
|
-
console.error(
|
|
169
|
+
console.error(fixJson.stderr?.slice(0, 300) ?? '')
|
|
138
170
|
return 1
|
|
139
171
|
}
|
|
140
|
-
|
|
141
|
-
let fixJson
|
|
142
|
-
try {
|
|
143
|
-
fixJson = JSON.parse(raw)
|
|
144
|
-
} catch {
|
|
172
|
+
if (fixJson._badJson) {
|
|
145
173
|
console.error(`n-cursor fix-t0: fix --json повернув невалідний JSON`)
|
|
146
174
|
return 1
|
|
147
175
|
}
|
|
@@ -153,16 +181,7 @@ export function runT0AutoCli(args, cwd) {
|
|
|
153
181
|
}
|
|
154
182
|
|
|
155
183
|
// 2. Застосувати T0-auto
|
|
156
|
-
const applied =
|
|
157
|
-
const skipped = []
|
|
158
|
-
for (const r of failed) {
|
|
159
|
-
const result = applyT0Auto(r.ruleId, r.output, cwd)
|
|
160
|
-
if (result.applied) {
|
|
161
|
-
applied.push({ ruleId: r.ruleId, actions: result.actions })
|
|
162
|
-
} else {
|
|
163
|
-
skipped.push(r.ruleId)
|
|
164
|
-
}
|
|
165
|
-
}
|
|
184
|
+
const { applied, skipped } = applyT0ToFailed(failed, cwd)
|
|
166
185
|
|
|
167
186
|
if (applied.length === 0) {
|
|
168
187
|
console.log(`⏭️ fix-t0: T0-auto паттерн не підходить для: ${failed.map(r => r.ruleId).join(', ')}`)
|
|
@@ -176,18 +195,11 @@ export function runT0AutoCli(args, cwd) {
|
|
|
176
195
|
}
|
|
177
196
|
|
|
178
197
|
// 4. Check-gate: перевірити лише ті правила, що ми чіпали
|
|
179
|
-
const
|
|
180
|
-
|
|
181
|
-
encoding: 'utf8',
|
|
182
|
-
timeout: 120_000
|
|
183
|
-
})
|
|
184
|
-
const recheckRaw = recheck.stdout?.trim()
|
|
185
|
-
if (!recheckRaw) {
|
|
198
|
+
const recheckJson = fixCheck(applied.map(a => a.ruleId), cwd)
|
|
199
|
+
if (recheckJson._empty) {
|
|
186
200
|
console.error(`fix-t0: check-gate: fix --json повернув порожній stdout`)
|
|
187
201
|
return 1
|
|
188
202
|
}
|
|
189
|
-
|
|
190
|
-
const recheckJson = JSON.parse(recheckRaw)
|
|
191
203
|
const stillFailed = recheckJson.rules.filter(r => !r.ok)
|
|
192
204
|
|
|
193
205
|
if (verbose) {
|
|
@@ -111,7 +111,7 @@ function diffSideEffects(before, after) {
|
|
|
111
111
|
* Запускає `start` одного воркспейсу з grace-таймаутом і класифікує результат.
|
|
112
112
|
* @param {string} cwd корінь репозиторію
|
|
113
113
|
* @param {string} workspace відносний шлях воркспейсу
|
|
114
|
-
* @param {{graceMs?:number, type?:('server'|'cli'), spawnImpl?:
|
|
114
|
+
* @param {{graceMs?:number, type?:('server'|'cli'), spawnImpl?:typeof import('node:child_process').spawnSync}} [opts] grace-період, тип (інакше з package.json), інʼєкція spawn для тестів
|
|
115
115
|
* @returns {Promise<{workspace:string, type:string, exitCode:number|null, timedOut:boolean, status:('OK'|'FAIL'), ready:boolean, firstError:string|null, logTail:string, sideEffects:{newFiles:string[], changedTracked:string[]}}>} результат прогону
|
|
116
116
|
*/
|
|
117
117
|
export async function runWorkspaceStart(cwd, workspace, opts = {}) {
|