@7n/rules 1.38.0 → 1.40.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- 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/scripts/utils/docs/index.md +1 -0
- package/scripts/utils/docs/uv-workspace.md +34 -0
- package/scripts/utils/uv-workspace.mjs +56 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.40.0] - 2026-07-22
|
|
4
|
+
|
|
5
|
+
### Changed
|
|
6
|
+
|
|
7
|
+
- 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` — опт-аут)
|
|
8
|
+
|
|
9
|
+
## [1.39.0] - 2026-07-21
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- workspace_root: канон одного кореневого uv workspace на репозиторій (дзеркало rust/workspace_root), main.json auto-glob розширено на вкладені pyproject.toml
|
|
14
|
+
|
|
3
15
|
## [1.38.0] - 2026-07-21
|
|
4
16
|
|
|
5
17
|
### Fixed
|
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
|
+
}
|
|
@@ -20,6 +20,7 @@ resource: npm/scripts/utils/
|
|
|
20
20
|
| [resolve-js-root.mjs](resolve-js-root.md) | JS Module |
|
|
21
21
|
| [spawn-async.mjs](spawn-async.md) | JS Module |
|
|
22
22
|
| [test-helpers.mjs](test-helpers.md) | JS Module |
|
|
23
|
+
| [uv-workspace.mjs](uv-workspace.md) | JS Module |
|
|
23
24
|
| [walk-cache.mjs](walk-cache.md) | JS Module |
|
|
24
25
|
| [walkDir.mjs](walkDir.md) | JS Module |
|
|
25
26
|
| [with-lock.mjs](with-lock.md) | JS Module |
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
---
|
|
2
|
+
type: JS Module
|
|
3
|
+
title: uv-workspace.mjs
|
|
4
|
+
resource: npm/scripts/utils/uv-workspace.mjs
|
|
5
|
+
docgen:
|
|
6
|
+
crc: b7602da3
|
|
7
|
+
model: openai-codex/gpt-5.4-mini
|
|
8
|
+
tier: cloud-min
|
|
9
|
+
score: 100
|
|
10
|
+
issues: judge:inaccurate:0.98
|
|
11
|
+
judgeModel: openai-codex/gpt-5.4-mini
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
## Огляд
|
|
15
|
+
|
|
16
|
+
`readPyprojectManifest` читає `pyproject.toml` і повертає маніфест uv workspace, а `resolveUvWorkspaceMemberDirs` резолвить каталоги з `[tool.uv.workspace].members` та `.exclude` за тією самою glob-семантикою, що й у Cargo workspaces. Це read-only, fail-safe набір спільних T0-утиліт: він не пише у ФС чи БД, не кидає винятків назовні та для частини помилок повертає порожнє значення замість помилки.
|
|
17
|
+
|
|
18
|
+
## Поведінка
|
|
19
|
+
|
|
20
|
+
- `readPyprojectManifest` — читає `pyproject.toml` для uv workspace і повертає розпарсений manifest або `null`, якщо файл відсутній чи TOML невалідний.
|
|
21
|
+
- `resolveUvWorkspaceMemberDirs` — перетворює `members` і `exclude` uv workspace на список абсолютних каталогів із власним `pyproject.toml`, враховуючи літеральні шляхи та прості glob-патерни.
|
|
22
|
+
|
|
23
|
+
## Публічний API
|
|
24
|
+
|
|
25
|
+
- readPyprojectManifest — Розпарсений pyproject.toml або null (файл відсутній чи невалідний TOML).
|
|
26
|
+
- resolveUvWorkspaceMemberDirs — Резолвить `[tool.uv.workspace].members`/`.exclude`-патерни (літеральні шляхи й прості
|
|
27
|
+
glob з `*`) відносно `rootDir` у список абсолютних каталогів, що мають власний
|
|
28
|
+
pyproject.toml. Без повної glob-семантики uv — лише `*`-сегменти й літерали.
|
|
29
|
+
|
|
30
|
+
## Гарантії поведінки
|
|
31
|
+
|
|
32
|
+
- Read-only: не виконує операцій запису (ФС/БД).
|
|
33
|
+
- Перехоплює помилки і не пропускає винятків назовні (fail-safe).
|
|
34
|
+
- За певних помилок повертає порожнє значення (напр. `null`) замість винятку.
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Спільні T0 (без spawn `uv`) утиліти для роботи з uv workspace-структурою: читання
|
|
3
|
+
* `pyproject.toml`, резолв `[tool.uv.workspace].members`/`.exclude`-glob-патернів у
|
|
4
|
+
* каталоги. Дзеркалить `cargo-workspace.mjs` (rust/workspace_root) — uv workspaces
|
|
5
|
+
* навмисно змодельовані на Cargo workspaces, glob-семантика `members`/`exclude` та сама.
|
|
6
|
+
* Не імпортується напряму з rust-боку (окремі плагіни) — дублювання свідоме.
|
|
7
|
+
*/
|
|
8
|
+
import { existsSync } from 'node:fs'
|
|
9
|
+
import { readFile } from 'node:fs/promises'
|
|
10
|
+
import { dirname, resolve } from 'node:path'
|
|
11
|
+
|
|
12
|
+
import { parse as parseToml } from 'smol-toml'
|
|
13
|
+
|
|
14
|
+
import { scanGlob } from './glob-compat.mjs'
|
|
15
|
+
|
|
16
|
+
const TRAILING_SLASH_RE = /\/$/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Розпарсений pyproject.toml або null (файл відсутній чи невалідний TOML).
|
|
20
|
+
* @param {string} absPath абсолютний шлях до pyproject.toml
|
|
21
|
+
* @returns {Promise<Record<string, unknown>|null>} розпарсений маніфест або null
|
|
22
|
+
*/
|
|
23
|
+
export async function readPyprojectManifest(absPath) {
|
|
24
|
+
if (!existsSync(absPath)) return null
|
|
25
|
+
try {
|
|
26
|
+
return parseToml(await readFile(absPath, 'utf8'))
|
|
27
|
+
} catch {
|
|
28
|
+
return null
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Резолвить `[tool.uv.workspace].members`/`.exclude`-патерни (літеральні шляхи й прості
|
|
34
|
+
* glob з `*`) відносно `rootDir` у список абсолютних каталогів, що мають власний
|
|
35
|
+
* pyproject.toml. Без повної glob-семантики uv — лише `*`-сегменти й літерали.
|
|
36
|
+
* @param {string} rootDir корінь workspace (каталог з pyproject.toml, де живе `[tool.uv.workspace]`)
|
|
37
|
+
* @param {string[]} patterns патерни з `members`/`exclude`
|
|
38
|
+
* @returns {Promise<string[]>} абсолютні шляхи (без дублікатів)
|
|
39
|
+
*/
|
|
40
|
+
export async function resolveUvWorkspaceMemberDirs(rootDir, patterns) {
|
|
41
|
+
const found = new Set()
|
|
42
|
+
for (const pattern of patterns ?? []) {
|
|
43
|
+
const norm = pattern.replace(TRAILING_SLASH_RE, '')
|
|
44
|
+
if (norm.includes('*')) {
|
|
45
|
+
// Патерн для pyproject.toml напряму (не для каталогів) — деякі раннери (Bun.Glob)
|
|
46
|
+
// за замовчуванням матчать лише файли, тож патерн на голий каталог даватиме 0 збігів.
|
|
47
|
+
for await (const relManifest of scanGlob(`${norm}/pyproject.toml`, rootDir)) {
|
|
48
|
+
found.add(resolve(rootDir, dirname(relManifest)))
|
|
49
|
+
}
|
|
50
|
+
continue
|
|
51
|
+
}
|
|
52
|
+
const abs = resolve(rootDir, norm)
|
|
53
|
+
if (existsSync(`${abs}/pyproject.toml`)) found.add(abs)
|
|
54
|
+
}
|
|
55
|
+
return [...found]
|
|
56
|
+
}
|