@7n/rules 1.39.0 → 1.40.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 +12 -0
- package/bin/n-rules.js +22 -3
- package/package.json +1 -1
- package/rules/doc-files/docgen-extract-anchors/docs/main.md +1 -1
- package/rules/doc-files/docgen-extract-anchors/main.mjs +4 -1
- package/rules/doc-files/docgen-gen/docs/main.md +1 -1
- package/rules/doc-files/docgen-gen/main.mjs +153 -14
- package/rules/doc-files/docgen-prompts/docs/main.md +1 -1
- package/rules/doc-files/docgen-prompts/main.mjs +77 -9
- package/rules/text/run-v8r/docs/main.md +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.40.1] - 2026-07-22
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- `lint --full`: загублений `await` перед `ensureRunningInWorktree` ламав кожен прогін (у т.ч. зсередини `.worktrees/`) з `TypeError [ERR_INVALID_ARG_TYPE]`; заодно — guard проти видалення auto-created worktree при частковому провалі перенесення змін назад, і stack trace для programmer-помилок у top-level catch
|
|
8
|
+
|
|
9
|
+
## [1.40.0] - 2026-07-22
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
|
|
13
|
+
- doc-files: пакет покращень генерації на малих локальних моделях (gemma-4, живий бенч на efes/backend) — R9 дет-зрізання чат-преамбул + штраф скорера; Behavior-наратив замість дубля «Публічного API»; STYLE-заборони мета-фраз; анкор лише в Behavior-промпті (без дублю в Огляді); scoped read-only гарантія (без over-claim, який валив LLM-суддя); юніт-дайджест замість сирого src для великих файлів (`N_CURSOR_DOCGEN_DIGEST_TOKENS`); judge-refine — один локальний фікс за зауваженнями судді з guard-ами (`N_CURSOR_DOCGEN_JUDGE_REFINE=0` — опт-аут)
|
|
14
|
+
|
|
3
15
|
## [1.39.0] - 2026-07-21
|
|
4
16
|
|
|
5
17
|
### Added
|
package/bin/n-rules.js
CHANGED
|
@@ -1848,7 +1848,7 @@ try {
|
|
|
1848
1848
|
// дереві задачі, worktree-ізоляція зламала б саму суть дельти) — пропускаємо.
|
|
1849
1849
|
const needsWorktreeIsolation = full && !noFix
|
|
1850
1850
|
const worktree = needsWorktreeIsolation
|
|
1851
|
-
? ensureRunningInWorktree(cwdArg, spawnSync, line => console.log(line), {
|
|
1851
|
+
? await ensureRunningInWorktree(cwdArg, spawnSync, line => console.log(line), {
|
|
1852
1852
|
suffix: 'lint',
|
|
1853
1853
|
description: 'n-rules lint --full: worktree-only full-repo run'
|
|
1854
1854
|
})
|
|
@@ -1881,14 +1881,26 @@ try {
|
|
|
1881
1881
|
} finally {
|
|
1882
1882
|
// Лише для АВТОстворених worktree (лінт уже сидів у своєму — не наш, не чіпаємо).
|
|
1883
1883
|
if (worktree.autoCreated) {
|
|
1884
|
+
let bringBackFailed = true
|
|
1884
1885
|
try {
|
|
1885
|
-
await bringChangesBackToOriginal(runCwd, cwdArg, spawnSync, line =>
|
|
1886
|
+
const bringBackResult = await bringChangesBackToOriginal(runCwd, cwdArg, spawnSync, line =>
|
|
1887
|
+
console.log(line)
|
|
1888
|
+
)
|
|
1889
|
+
bringBackFailed = bringBackResult.failed
|
|
1886
1890
|
} catch (error) {
|
|
1887
1891
|
console.log(
|
|
1888
1892
|
`⚠️ Перенесення змін назад провалилось: ${error instanceof Error ? error.message : String(error)}`
|
|
1889
1893
|
)
|
|
1890
1894
|
}
|
|
1891
|
-
|
|
1895
|
+
// Прибираємо worktree лише якщо перенесення точно вдалось — інакше
|
|
1896
|
+
// не перенесені зміни згорять разом з деревом.
|
|
1897
|
+
if (bringBackFailed) {
|
|
1898
|
+
console.log(
|
|
1899
|
+
`⚠️ Перенесення назад не підтверджено — worktree "${worktree.branchArg}" лишається для ручного розбору.`
|
|
1900
|
+
)
|
|
1901
|
+
} else {
|
|
1902
|
+
removeAutoCreatedWorktree(worktree.branchArg, cwdArg, spawnSync, line => console.log(line))
|
|
1903
|
+
}
|
|
1892
1904
|
}
|
|
1893
1905
|
}
|
|
1894
1906
|
|
|
@@ -1964,8 +1976,15 @@ try {
|
|
|
1964
1976
|
}
|
|
1965
1977
|
}
|
|
1966
1978
|
} catch (error) {
|
|
1979
|
+
// TypeError/RangeError/ReferenceError сигналять баг у самому коді (не навмисне
|
|
1980
|
+
// user-facing повідомлення) — друкуємо stack одразу, інакше діагностика вимагає
|
|
1981
|
+
// патчити node_modules вручну (як під час діагностики цього ж класу вад).
|
|
1982
|
+
const isProgrammerError = error instanceof TypeError || error instanceof RangeError || error instanceof ReferenceError
|
|
1967
1983
|
if (error instanceof ReexecHandoff) {
|
|
1968
1984
|
process.exitCode = error.code
|
|
1985
|
+
} else if (isProgrammerError && error.stack) {
|
|
1986
|
+
console.error(error.stack)
|
|
1987
|
+
process.exitCode = 1
|
|
1969
1988
|
} else if (error instanceof Error && error.message) {
|
|
1970
1989
|
console.error(error.message)
|
|
1971
1990
|
process.exitCode = 1
|
package/package.json
CHANGED
|
@@ -111,5 +111,8 @@ export function anchorsToPrompt(a) {
|
|
|
111
111
|
blocks.push(`Приклади з документації автора (наведи дослівно у Поведінці):\n${fenced}`)
|
|
112
112
|
}
|
|
113
113
|
if (!blocks.length) return ''
|
|
114
|
-
|
|
114
|
+
// «РІВНО один раз»: без цього gemma-подібні моделі «запихають» анкор у кожну
|
|
115
|
+
// секцію (живий кейс efes: URL https://hasura.io/jwt/claims і в Огляді, і в
|
|
116
|
+
// Поведінці, обидва рази незграбно). Скорер (R5) вимагає лише наявність.
|
|
117
|
+
return `АНКОРИ ДО ОБОВ'ЯЗКОВОГО ВКЛЮЧЕННЯ (кожен згадай РІВНО ОДИН раз, у найдоречнішому місці — не повторюй у кількох секціях):\n${blocks.join('\n')}`
|
|
115
118
|
}
|
|
@@ -20,7 +20,10 @@ import {
|
|
|
20
20
|
guaranteesFromMarkers,
|
|
21
21
|
isApiGap,
|
|
22
22
|
renderApiLine,
|
|
23
|
-
apiGapMessages
|
|
23
|
+
apiGapMessages,
|
|
24
|
+
buildUnitDigest,
|
|
25
|
+
UNIT_DIGEST_TOKENS,
|
|
26
|
+
judgeRefineMessages
|
|
24
27
|
} from '../docgen-prompts/main.mjs'
|
|
25
28
|
|
|
26
29
|
/** Облік LLM-викликів і часу в них у межах однієї генерації (скидається на старті generateDoc). */
|
|
@@ -91,6 +94,23 @@ async function callLlm(messages, model, opts = {}) {
|
|
|
91
94
|
const FENCE_OPEN_RE = /^```[a-z]*\n?/
|
|
92
95
|
const FENCE_CLOSE_RE = /\n?```\s*$/
|
|
93
96
|
const LEADING_HEADING_RE = /^#{1,6}[ \t]{1,8}[^\n]{0,400}\n{1,8}/
|
|
97
|
+
// R9: чат-преамбули малих моделей — «озвучування завдання» перед відповіддю
|
|
98
|
+
// («Ось оновлена чорнетка секції…», «Як технічний письменник, я створю…»,
|
|
99
|
+
// «Оновлений текст секції:»). Живі приклади — прогін gemma-4 по efes/backend
|
|
100
|
+
// 2026-07-21: 4 з 10 доків мали такі рядки; R8 (refusal) їх не ловить, бо далі
|
|
101
|
+
// йде реальний контент. Зрізаються ЛИШЕ провідні рядки секції (мета-нарація
|
|
102
|
+
// стоїть попереду), щоб не зачепити легітимний текст усередині.
|
|
103
|
+
const PREAMBLE_LINE_RES = [
|
|
104
|
+
/^Ось (?:оновлен|переписан|виправлен|готов|вміст|текст|чорнетк|секці)/i,
|
|
105
|
+
/^Оновлен(?:ий|а|е|о) (?:текст|чорнетк|секці|вміст|версі)/i,
|
|
106
|
+
/^Як технічний письменник/i,
|
|
107
|
+
/^(?:Я )?(?:створю|напишу|перепишу|підготую) /i,
|
|
108
|
+
/^(?:Звісно|Гаразд|Добре)[,.!]/i,
|
|
109
|
+
/^(?:Нижче наведено|Нижче — )/i
|
|
110
|
+
]
|
|
111
|
+
// Дубль назви секції першим рядком тіла («Поведінка:» всередині секції Поведінка).
|
|
112
|
+
// Рядок перед перевіркою вже пройшов trim (див. stripLeadingPreamble) — без \s*-країв.
|
|
113
|
+
const SECTION_LABEL_LINE_RE = /^(?:Огляд|Поведінка|Публічний API|Гарантії поведінки):?$/
|
|
94
114
|
const SECTION_HEADING_RE = /^##\s+(.+)/
|
|
95
115
|
const SECTION_KEY_CLEAN_RE = /[^а-яіїєґa-z0-9]/gi
|
|
96
116
|
const CACHE_MENTION_RE = /кеш/i
|
|
@@ -120,8 +140,26 @@ const H2_RE = /^##\s/
|
|
|
120
140
|
const H1_RE = /^#\s/
|
|
121
141
|
|
|
122
142
|
/**
|
|
123
|
-
*
|
|
124
|
-
*
|
|
143
|
+
* R9: зрізає провідні чат-преамбули й дубль назви секції з початку тексту.
|
|
144
|
+
* Ітерується, поки перший непорожній рядок лишається мета-нарацією — модель
|
|
145
|
+
* інколи ставить дві поспіль («Як технічний письменник…» + «Ось оновлений…»).
|
|
146
|
+
* @param {string} t текст після базового очищення
|
|
147
|
+
* @returns {string} текст без провідних мета-рядків
|
|
148
|
+
*/
|
|
149
|
+
export function stripLeadingPreamble(t) {
|
|
150
|
+
let out = t
|
|
151
|
+
for (;;) {
|
|
152
|
+
const nl = out.indexOf('\n')
|
|
153
|
+
const first = (nl === -1 ? out : out.slice(0, nl)).trim()
|
|
154
|
+
const isMeta = SECTION_LABEL_LINE_RE.test(first) || PREAMBLE_LINE_RES.some(re => re.test(first))
|
|
155
|
+
if (!first || !isMeta || nl === -1) return isMeta && nl === -1 ? '' : out
|
|
156
|
+
out = out.slice(nl + 1).trimStart()
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Прибирає код-фенс-обгортку (потрійні бектіки), випадковий провідний
|
|
162
|
+
* `##`-заголовок і чат-преамбули (R9) із секції.
|
|
125
163
|
* @param {string} text сирий вихід моделі
|
|
126
164
|
* @returns {string} очищений текст секції
|
|
127
165
|
*/
|
|
@@ -131,7 +169,7 @@ function stripSection(text) {
|
|
|
131
169
|
t = t.replace(FENCE_OPEN_RE, '').replace(FENCE_CLOSE_RE, '').trim()
|
|
132
170
|
}
|
|
133
171
|
t = t.replace(LEADING_HEADING_RE, '') // зрізати випадковий заголовок
|
|
134
|
-
return t.trim()
|
|
172
|
+
return stripLeadingPreamble(t.trim()).trim()
|
|
135
173
|
}
|
|
136
174
|
|
|
137
175
|
/**
|
|
@@ -276,6 +314,19 @@ export function scoreDoc(md, facts, { anchors = null, src = '' } = {}) {
|
|
|
276
314
|
issues.push('refusal-filler')
|
|
277
315
|
}
|
|
278
316
|
|
|
317
|
+
// R9: чат-преамбула в тілі («Ось оновлена чорнетка…», «Як технічний письменник…»)
|
|
318
|
+
// — на відміну від R8, далі є реальний контент, тож не 0, а відчутний штраф:
|
|
319
|
+
// best-of-2 обере чистий драфт, а стійке сміття помітить degraded-доретрай.
|
|
320
|
+
// stripSection зрізає провідні мета-рядки на генерації; скорер — страховка для
|
|
321
|
+
// one-shot шляху і преамбул усередині секції (після першого рядка).
|
|
322
|
+
for (const line of splitProtected(md).without.split('\n')) {
|
|
323
|
+
if (PREAMBLE_LINE_RES.some(re => re.test(line.trim()))) {
|
|
324
|
+
score -= 25
|
|
325
|
+
issues.push('chat-preamble')
|
|
326
|
+
break
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
279
330
|
if (!s['огляд']) {
|
|
280
331
|
score -= 25
|
|
281
332
|
issues.push('no-overview')
|
|
@@ -440,15 +491,103 @@ async function orchestratedDoc(
|
|
|
440
491
|
// R3: «Огляд» — ОСТАННІМ, узагальненням уже написаної Поведінки (не голого факт-листа)
|
|
441
492
|
let overview = stripSignatures(
|
|
442
493
|
stripSection(
|
|
443
|
-
await callLlm(overviewMessages(facts, sections.behavior ?? '',
|
|
494
|
+
await callLlm(overviewMessages(facts, sections.behavior ?? '', intent), model, { timeoutMs, temperature })
|
|
444
495
|
)
|
|
445
496
|
)
|
|
446
|
-
|
|
497
|
+
// №8: анкори лише в Behavior — критик Огляду без анкор-блоку, інакше refine
|
|
498
|
+
// «поверне» анкор у Огляд і в документі він знову зʼявиться двічі.
|
|
499
|
+
overview = await critiqueRefineSection('overview', overview, facts, null, model, timeoutMs)
|
|
447
500
|
sections.overview = overview
|
|
448
501
|
// Варіант B: дослівно повертаємо захищений блок у фіксовану позицію
|
|
449
502
|
return { md: insertProtected(assemble(basename(facts.relPath), sections), intent) }
|
|
450
503
|
}
|
|
451
504
|
|
|
505
|
+
/**
|
|
506
|
+
* №6 — judge-refine: суддя назвав конкретні неточності (`judge.reason`) — один
|
|
507
|
+
* локальний refine-прохід замість лише маркування degraded. Приймаємо виправлену
|
|
508
|
+
* версію ТІЛЬКИ якщо: det-score не впав, усі ## заголовки збережені, і повторний
|
|
509
|
+
* суддя більше не каже inaccurate. Інакше — оригінал і degraded, як раніше.
|
|
510
|
+
* Cap: рівно одна ітерація (без петель самопереконання).
|
|
511
|
+
* @param {{ md: string }} r поточний результат генерації
|
|
512
|
+
* @param {{ reason: string }} judge вердикт судді (inaccurate)
|
|
513
|
+
* @param {{ facts: object, anchors: object|null, src: string, score: number, model: string, chain: object }} ctx контекст генерації
|
|
514
|
+
* @returns {Promise<{ md: string, score: number, issues: string[], judge: object }|null>} прийнята виправлена версія або null (лишаємо оригінал)
|
|
515
|
+
*/
|
|
516
|
+
async function judgeRefinePass(r, judge, { facts, anchors, src, score, model, chain }) {
|
|
517
|
+
const { body: intentBody, without } = splitProtected(r.md)
|
|
518
|
+
const fixedRaw = await callLlm(judgeRefineMessages(without, judge.reason), model, { timeoutMs: LOCAL_TIMEOUT_MS })
|
|
519
|
+
let fixed = stripSection(fixedRaw)
|
|
520
|
+
if (!fixed.startsWith('#')) fixed = `# ${basename(facts.relPath)}\n\n${fixed}`
|
|
521
|
+
const fixedMd = insertProtected(fixed + '\n', intentBody)
|
|
522
|
+
// Guard 1: рерайт не має губити секції (малі моделі інколи повертають фрагмент)
|
|
523
|
+
const origHeadings = r.md.match(/^##\s.+$/gm) ?? []
|
|
524
|
+
if (origHeadings.some(h => !fixedMd.includes(h))) return null
|
|
525
|
+
// Guard 2: det-score не має падати
|
|
526
|
+
const sFixed = scoreDoc(fixedMd, facts, { anchors, src })
|
|
527
|
+
if (sFixed.score < score) return null
|
|
528
|
+
// Guard 3: повторний суддя (той самий scope: inaccurate)
|
|
529
|
+
const judge2 = { ...(await judgeDoc(src, fixedMd, { chain })), model: JUDGE_MODEL }
|
|
530
|
+
if (judgeFailsDoc(judge2)) return null
|
|
531
|
+
return { md: fixedMd, score: sFixed.score, issues: sFixed.issues, judge: judge2 }
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* Judge-гейт цілком (виклик судді + опційний №6 refine): обгортка для
|
|
536
|
+
* generateDocCore, щоб тримати його cognitive complexity в межах. Помилки судді
|
|
537
|
+
* не валять генерацію — лише issue-маркер, як і раніше.
|
|
538
|
+
* @param {{ r: {md: string}, score: number, issues: string[], facts: object, anchors: object|null, src: string, model: string, chain: object }} ctx стан генерації
|
|
539
|
+
* @returns {Promise<{ judge: object|null, r: {md: string}, score: number, issues: string[] }>} оновлений стан
|
|
540
|
+
*/
|
|
541
|
+
async function runJudgeGate({ r, score, issues, facts, anchors, src, model, chain }) {
|
|
542
|
+
let judge = null
|
|
543
|
+
try {
|
|
544
|
+
judge = { ...(await judgeDoc(src, r.md, { chain })), model: JUDGE_MODEL }
|
|
545
|
+
// №6: суддя назвав конкретні неточності → один локальний refine-прохід
|
|
546
|
+
// (опт-аут: N_CURSOR_DOCGEN_JUDGE_REFINE=0). Прийнято лише коли всі
|
|
547
|
+
// guard-и judgeRefinePass пройдені; інакше — degraded, як раніше.
|
|
548
|
+
if (judgeFailsDoc(judge) && env.N_CURSOR_DOCGEN_JUDGE_REFINE !== '0') {
|
|
549
|
+
const refined = await judgeRefinePass(r, judge, { facts, anchors, src, score, model, chain })
|
|
550
|
+
if (refined) {
|
|
551
|
+
r = { ...r, md: refined.md }
|
|
552
|
+
score = refined.score
|
|
553
|
+
issues = [...refined.issues, 'judge-refine:won']
|
|
554
|
+
judge = refined.judge
|
|
555
|
+
} else {
|
|
556
|
+
issues = [...issues, 'judge-refine:kept-original']
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
if (judgeFailsDoc(judge)) issues = [...issues, `judge:inaccurate:${judge.confidence}`]
|
|
560
|
+
} catch (error) {
|
|
561
|
+
issues = [...issues, `judge:error: ${error.message.slice(0, 80)}`]
|
|
562
|
+
}
|
|
563
|
+
return { judge, r, score, issues }
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/**
|
|
567
|
+
* №5 (бенч gemma-4): текст «коду файлу» для Behavior-промпта. Великий src
|
|
568
|
+
* (понад UNIT_DIGEST_TOKENS) → юніт-дайджест (імʼя + JSDoc + call-graph + тіло
|
|
569
|
+
* лише для непокритих юнітів) замість сирого коду: на ~6k токенів сирцю мала
|
|
570
|
+
* модель втрачає фокус і пише водянисто. Анкори/CRC — завжди від повного src
|
|
571
|
+
* (дайджест лише для промпта). units нема (парсинг упав чи мова без юніт-шару)
|
|
572
|
+
* — повний src, як раніше.
|
|
573
|
+
* @param {{ facts: object, estTokens: number, langExtractors: Map<string, object>, ext: string, src: string, file: string }} ctx контекст генерації
|
|
574
|
+
* @returns {string} повний src або юніт-дайджест
|
|
575
|
+
*/
|
|
576
|
+
function resolvePromptSrc({ facts, estTokens, langExtractors, ext, src, file }) {
|
|
577
|
+
if (facts.unsupported || estTokens <= UNIT_DIGEST_TOKENS) return src
|
|
578
|
+
const units = langExtractors.get(ext)?.extractUnits?.(src, file)
|
|
579
|
+
if (!units?.length) return src
|
|
580
|
+
// Гейт змістовності (фінальний бенч, upsert-order 23KB): дайджест виграє лише
|
|
581
|
+
// коли файл СТРУКТУРОВАНИЙ (декілька юнітів — call-graph несе інформацію) і
|
|
582
|
+
// більшість юнітів покриті JSDoc. Інакше він вироджений: (а) юніти без JSDoc →
|
|
583
|
+
// обрізані тіла без описів → Поведінка стискається до generic (246 знаків
|
|
584
|
+
// проти 1300+ на повному src, score 65); (б) один гігантський юніт → дайджест
|
|
585
|
+
// = один рядок JSDoc, вся логіка невидима. В обох випадках — повний src.
|
|
586
|
+
const covered = units.filter(u => u.doc).length
|
|
587
|
+
const structured = units.length >= 4 && covered / units.length >= 0.6
|
|
588
|
+
return structured ? buildUnitDigest(units) : src
|
|
589
|
+
}
|
|
590
|
+
|
|
452
591
|
/** Максимальний час генерації одного LLM-виклику. */
|
|
453
592
|
const LOCAL_TIMEOUT_MS = 5 * 60 * 1000
|
|
454
593
|
|
|
@@ -569,9 +708,10 @@ export async function generateDoc(
|
|
|
569
708
|
// Варіант B: захищена секція «Призначення» з наявної доки — зберегти й подати як контекст
|
|
570
709
|
const intent = existingMd ? splitProtected(existingMd).body : null
|
|
571
710
|
const anchors = facts.unsupported ? null : extractAnchors(src)
|
|
711
|
+
const promptSrc = resolvePromptSrc({ facts, estTokens, langExtractors, ext, src, file })
|
|
572
712
|
let r = facts.unsupported
|
|
573
713
|
? await oneShotDoc(facts, src, model, LOCAL_TIMEOUT_MS, { intent })
|
|
574
|
-
: await orchestratedDoc(facts,
|
|
714
|
+
: await orchestratedDoc(facts, promptSrc, model, LOCAL_TIMEOUT_MS, { anchors, intent })
|
|
575
715
|
|
|
576
716
|
// unsupported (vue/py до юніт-шару): скорер не застосовний — score=null, не degraded
|
|
577
717
|
// (окрім refusal-пре-гейта — див. finishUnsupported).
|
|
@@ -586,7 +726,11 @@ export async function generateDoc(
|
|
|
586
726
|
// E4: best-of-2 — один retry з вищою температурою, det-вибір кращого
|
|
587
727
|
if (score < threshold && env.N_CURSOR_DOCGEN_BEST_OF !== '0') {
|
|
588
728
|
try {
|
|
589
|
-
const r2 = await orchestratedDoc(facts,
|
|
729
|
+
const r2 = await orchestratedDoc(facts, promptSrc, model, LOCAL_TIMEOUT_MS, {
|
|
730
|
+
anchors,
|
|
731
|
+
temperature: 0.5,
|
|
732
|
+
intent
|
|
733
|
+
})
|
|
590
734
|
const s2 = scoreDoc(r2.md, facts, { anchors, src })
|
|
591
735
|
if (s2.score > score) {
|
|
592
736
|
r = r2
|
|
@@ -604,12 +748,7 @@ export async function generateDoc(
|
|
|
604
748
|
// доках, що ПРОЙШЛИ det-скорер (там ховаються false-positives). Scope: inaccurate.
|
|
605
749
|
let judge = null
|
|
606
750
|
if (JUDGE_ENABLED && score >= threshold) {
|
|
607
|
-
|
|
608
|
-
judge = { ...(await judgeDoc(src, r.md, { chain })), model: JUDGE_MODEL }
|
|
609
|
-
if (judgeFailsDoc(judge)) issues = [...issues, `judge:inaccurate:${judge.confidence}`]
|
|
610
|
-
} catch (error) {
|
|
611
|
-
issues = [...issues, `judge:error: ${error.message.slice(0, 80)}`]
|
|
612
|
-
}
|
|
751
|
+
;({ judge, r, score, issues } = await runJudgeGate({ r, score, issues, facts, anchors, src, model, chain }))
|
|
613
752
|
}
|
|
614
753
|
|
|
615
754
|
const degraded = score < threshold || judgeFailsDoc(judge)
|
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
/** @see ./docs/docgen-prompts.md */
|
|
2
2
|
|
|
3
|
+
import { env } from 'node:process'
|
|
4
|
+
|
|
3
5
|
import { anchorsToPrompt } from '../docgen-extract-anchors/main.mjs'
|
|
4
6
|
|
|
5
7
|
export const STYLE = [
|
|
6
8
|
'Ти технічний письменник. Пишеш лаконічну ПОВЕДІНКОВУ документацію до коду українською, чистим Markdown.',
|
|
7
9
|
'Пиши ЩО і НАВІЩО, не ЯК. Без вступів і висновків. Не обгортай у ```-блок.',
|
|
8
|
-
'Заборонено: сигнатури, типи, параметри функцій; перелік stdlib-модулів; опис regex чи внутрішніх приватних імен.'
|
|
10
|
+
'Заборонено: сигнатури, типи, параметри функцій; перелік stdlib-модулів; опис regex чи внутрішніх приватних імен.',
|
|
11
|
+
// R9-профілактика: gemma-подібні малі моделі «озвучують завдання» перед відповіддю;
|
|
12
|
+
// явна заборона з прикладами різко знижує частоту (дет-зрізання у stripSection — страховка).
|
|
13
|
+
'Виведи ЛИШЕ текст секції. ЗАБОРОНЕНО починати з мета-фраз на кшталт «Ось оновлена чорнетка…», «Оновлений текст секції:», «Як технічний письменник, я створю…» — одразу перший змістовний рядок.'
|
|
9
14
|
].join(' ')
|
|
10
15
|
|
|
11
16
|
/**
|
|
@@ -32,7 +37,9 @@ function factsSummary(facts) {
|
|
|
32
37
|
if (m.skips?.length) lines.push(`Свідомо пропускає шляхи: ${m.skips.join(', ')}`)
|
|
33
38
|
// «Фабрикація > мовчання»: лише ПОЗИТИВНІ high-confidence сигнали; жодних дефолтних
|
|
34
39
|
// негативів (read-only «ні», «мережа: немає») — модель echo-їть їх як хибну гарантію.
|
|
35
|
-
|
|
40
|
+
// Scoped-формулювання readOnly (як у guaranteesFromMarkers): маркер file-local,
|
|
41
|
+
// безумовне «не пише» модель розганяє до хибного «гарантує безпечність» в Огляді.
|
|
42
|
+
if (m.readOnly) lines.push('Власних операцій запису (ФС/БД) у файлі немає (імпортовані модулі не аналізувались)')
|
|
36
43
|
if (m.network) lines.push('Звертається до мережі')
|
|
37
44
|
if (m.catchesErrors) lines.push('Перехоплює помилки (fail-safe), не кидає винятків назовні')
|
|
38
45
|
if (m.returnsFalsyOnFail) lines.push('За певних помилок повертає порожнє значення (напр. null) замість винятку')
|
|
@@ -81,10 +88,14 @@ export function sectionMessages(facts, src, anchors = null, intent = null) {
|
|
|
81
88
|
const intentCtx = intentContext(intent)
|
|
82
89
|
const multi = (facts.exports?.length || 0) > 1
|
|
83
90
|
|
|
84
|
-
// R6: Поведінка описує РІВНО експортовані імена, не службові
|
|
91
|
+
// R6: Поведінка описує РІВНО експортовані імена, не службові помічники.
|
|
92
|
+
// Мульти-експорт: «Публічний API» вже містить одно-рядкові описи кожної функції
|
|
93
|
+
// (Stage 1 — дослівно з JSDoc), тож пер-функційні пункти в Поведінці дублювали б
|
|
94
|
+
// його іншими словами. Натомість — крос-функціональний наратив: те, чого
|
|
95
|
+
// немає в жодному окремому JSDoc за визначенням.
|
|
85
96
|
const exportNames = (facts.exports ?? []).map(e => e.name)
|
|
86
97
|
const behaviorTask = multi
|
|
87
|
-
? '
|
|
98
|
+
? 'крос-функціональний потік: у якому порядку і як функції взаємодіють між собою, звідки приходять дані і куди йдуть результати, спільні правила чи стан. НЕ переказуй кожну функцію окремим пунктом — одно-рядкові описи вже є в секції «Публічний API»'
|
|
88
99
|
: 'нумерований алгоритм у бізнес-термінах'
|
|
89
100
|
const onlyExports = exportNames.length
|
|
90
101
|
? ` Описуй РІВНО ці публічні імена і жодних інших: ${exportNames.join(', ')}.`
|
|
@@ -149,18 +160,20 @@ export function apiGapMessages(gapExports, anchors = null) {
|
|
|
149
160
|
/**
|
|
150
161
|
* R3 — «Огляд» ОСТАННІМ: узагальнення вже написаної Поведінки, а не здогад із
|
|
151
162
|
* голого факт-листа. Лікує generic/хибний Огляд на складних файлах.
|
|
163
|
+
* Анкор-блок сюди НЕ підставляється (№8, бенч gemma-4): секції — окремі
|
|
164
|
+
* LLM-виклики, і коли анкори бачили обидва, кожен чесно вставляв «рівно один
|
|
165
|
+
* раз» → у документі виходило двічі (незграбні «посилаючись на…» в Огляді).
|
|
166
|
+
* Анкори живуть лише в Behavior-промпті; скорер R5 перевіряє документ цілком.
|
|
152
167
|
* @param {object} facts факт-лист про файл
|
|
153
168
|
* @param {string} behaviorText готовий текст секції «Поведінка»
|
|
154
|
-
* @param {object|null} [anchors] анкори файлу
|
|
155
169
|
* @param {string|null} [intent] захищена секція «Призначення» як read-only контекст
|
|
156
170
|
* @returns {Array<{role:string,content:string}>} messages-масив для Огляду
|
|
157
171
|
*/
|
|
158
|
-
export function overviewMessages(facts, behaviorText,
|
|
172
|
+
export function overviewMessages(facts, behaviorText, intent = null) {
|
|
159
173
|
const factsTxt = factsSummary(facts)
|
|
160
|
-
const anch = anchorsBlock(anchors)
|
|
161
174
|
const dedup = intent ? ' Не дублюй секцію «Призначення».' : ''
|
|
162
175
|
return msgs(
|
|
163
|
-
`${STYLE}\n\nВІДОМІ ФАКТИ:\n${factsTxt}${
|
|
176
|
+
`${STYLE}\n\nВІДОМІ ФАКТИ:\n${factsTxt}${intentContext(intent)}`,
|
|
164
177
|
`На основі вже написаної секції «Поведінка» (нижче) напиши «Огляд»: 1-3 речення — що файл робить і навіщо існує (роль у системі). Узагальнюй САМЕ описану поведінку, не додавай нових фактів. Без заголовка, без переліку функцій. Заборонені абстрактні формули без конкретики («перевірка/валідація/обробка даних», «відповідність контракту», «застосовує логіку») — пиши, ЩО саме і за яким контрактом.${dedup}\n\nПОВЕДІНКА:\n${behaviorText}`
|
|
165
178
|
)
|
|
166
179
|
}
|
|
@@ -230,7 +243,13 @@ export function guaranteesFromMarkers(facts) {
|
|
|
230
243
|
const lines = []
|
|
231
244
|
// «Фабрикація > мовчання»: лише ПОЗИТИВНІ high-confidence гарантії. Жодних
|
|
232
245
|
// негативів/дефолтів (no-network, determinism) — їх не довести file-local аналізом.
|
|
233
|
-
|
|
246
|
+
// readOnly — SCOPED-формулювання: маркер file-local (немає write-патернів у ЦЬОМУ
|
|
247
|
+
// файлі), але файл може викликати імпортовані модулі, які пишуть. Безумовне
|
|
248
|
+
// «Read-only: не виконує операцій запису» LLM-суддя (cloud-min) стабільно валив
|
|
249
|
+
// як inaccurate на всіх бенч-файлах efes 2026-07-21 — і мав рацію: це over-claim,
|
|
250
|
+
// який file-local аналіз не може підтвердити. Обмежене твердження — може.
|
|
251
|
+
if (m.readOnly)
|
|
252
|
+
lines.push('- Власних операцій запису (ФС/БД) у файлі немає; виклики імпортованих модулів можуть писати.')
|
|
234
253
|
if (m.catchesErrors) lines.push('- Перехоплює помилки і не пропускає винятків назовні (fail-safe).')
|
|
235
254
|
if (m.returnsFalsyOnFail) lines.push('- За певних помилок повертає порожнє значення (напр. `null`) замість винятку.')
|
|
236
255
|
if (m.caches) lines.push('- Кешує результати в межах одного прогону.')
|
|
@@ -254,3 +273,52 @@ export function oneShotMessages(facts, src) {
|
|
|
254
273
|
`Напиши документацію для файлу. Секції: ## Огляд (1-3 речення), ## Поведінка (нумерований/маркований алгоритм), ${multi ? '## Публічний API (назва + що робить), ' : ''}## Гарантії поведінки.\n\nФАЙЛ ${facts.relPath}:\n\`\`\`\n${src}\n\`\`\``
|
|
255
274
|
)
|
|
256
275
|
}
|
|
276
|
+
|
|
277
|
+
/** Поріг (у токенах, ~4 байти/токен), після якого сирий src замінюється юніт-дайджестом. */
|
|
278
|
+
export const UNIT_DIGEST_TOKENS = Number(env.N_CURSOR_DOCGEN_DIGEST_TOKENS ?? 2000) || 2000
|
|
279
|
+
|
|
280
|
+
/** Скільки перших рядків тіла юніта потрапляє в дайджест, коли JSDoc порожній. */
|
|
281
|
+
const DIGEST_BODY_LINES = 12
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* №5 (бенч gemma-4): стислий юніт-дайджест великого файлу замість сирого src у
|
|
285
|
+
* Behavior-промпті. На ~6k токенів сирцю мала модель втрачає фокус (водянисті
|
|
286
|
+
* формулювання); дайджест подає структуру — імʼя, JSDoc, call-graph, тіло лише
|
|
287
|
+
* для непокритих JSDoc юнітів (перші рядки) — і тримає промпт компактним.
|
|
288
|
+
* @param {Array<{name:string, kind:string, exported:boolean, doc:string, calls:string[], body:string}>} units юніти файлу (extractUnits)
|
|
289
|
+
* @returns {string} текстовий дайджест для вставки замість повного src
|
|
290
|
+
*/
|
|
291
|
+
export function buildUnitDigest(units) {
|
|
292
|
+
const parts = [
|
|
293
|
+
'СТИСЛИЙ ДАЙДЖЕСТ ФАЙЛУ (повний код не подано — файл завеликий; описуй ЛИШЕ те, що видно з дайджесту):'
|
|
294
|
+
]
|
|
295
|
+
for (const u of units) {
|
|
296
|
+
const head = `### ${u.name} (${u.exported ? 'export ' : ''}${u.kind})`
|
|
297
|
+
const lines = [head]
|
|
298
|
+
if (u.doc) lines.push(`JSDoc: ${u.doc}`)
|
|
299
|
+
if (u.calls?.length) lines.push(`викликає: ${u.calls.join(', ')}`)
|
|
300
|
+
if (!u.doc && u.body) {
|
|
301
|
+
const bodyLines = u.body.split('\n')
|
|
302
|
+
const trimmed = bodyLines.slice(0, DIGEST_BODY_LINES).join('\n')
|
|
303
|
+
lines.push('```', trimmed + (bodyLines.length > DIGEST_BODY_LINES ? '\n…' : ''), '```')
|
|
304
|
+
}
|
|
305
|
+
parts.push(lines.join('\n'))
|
|
306
|
+
}
|
|
307
|
+
return parts.join('\n\n')
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* №6 — judge-refine: один локальний refine-прохід за конкретними зауваженнями
|
|
312
|
+
* LLM-судді (замість лише маркування degraded). Суддя вже сформулював, ЩО саме
|
|
313
|
+
* хибне (`reason`) — мала модель добре виправляє точкові твердження, коли їй
|
|
314
|
+
* сказано, які саме.
|
|
315
|
+
* @param {string} doc машинні секції доки (без захищеного «Призначення»)
|
|
316
|
+
* @param {string} reason зауваження судді (verdict.reason)
|
|
317
|
+
* @returns {Array<{role:string,content:string}>} messages-масив для LLM
|
|
318
|
+
*/
|
|
319
|
+
export function judgeRefineMessages(doc, reason) {
|
|
320
|
+
return msgs(
|
|
321
|
+
STYLE,
|
|
322
|
+
`Рецензент знайшов у документації неточності:\n${reason}\n\nВиправ ЛИШЕ хибні твердження — прибери або переформулюй їх так, щоб вони відповідали дійсності. Збережи структуру (усі ## заголовки), мову й решту тексту без змін. Поверни ПОВНИЙ виправлений markdown-документ, без преамбул.\n\nДОКУМЕНТ:\n${doc}`
|
|
323
|
+
)
|
|
324
|
+
}
|