@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 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 => console.log(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
- removeAutoCreatedWorktree(worktree.branchArg, cwdArg, spawnSync, line => console.log(line))
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@7n/rules",
3
- "version": "1.39.0",
3
+ "version": "1.40.1",
4
4
  "description": "CLI еталонних правил і skills (префікс n-): синк у репозиторій, дельта-lint, конформність",
5
5
  "keywords": [
6
6
  "cli",
@@ -3,7 +3,7 @@ type: JS Module
3
3
  title: main.mjs
4
4
  resource: npm/rules/doc-files/docgen-extract-anchors/main.mjs
5
5
  docgen:
6
- crc: 1bd4d187
6
+ crc: 0d5bc487
7
7
  model: omlx/gemma-4-e4b-it-OptiQ-4bit
8
8
  score: 95
9
9
  issues: anchor-miss:(rule.mdc),judge:inaccurate:0.99
@@ -111,5 +111,8 @@ export function anchorsToPrompt(a) {
111
111
  blocks.push(`Приклади з документації автора (наведи дослівно у Поведінці):\n${fenced}`)
112
112
  }
113
113
  if (!blocks.length) return ''
114
- return `АНКОРИ ДО ОБОВ'ЯЗКОВОГО ВКЛЮЧЕННЯ:\n${blocks.join('\n')}`
114
+ // «РІВНО один раз»: без цього gemma-подібні моделі «запихають» анкор у кожну
115
+ // секцію (живий кейс efes: URL https://hasura.io/jwt/claims і в Огляді, і в
116
+ // Поведінці, обидва рази незграбно). Скорер (R5) вимагає лише наявність.
117
+ return `АНКОРИ ДО ОБОВ'ЯЗКОВОГО ВКЛЮЧЕННЯ (кожен згадай РІВНО ОДИН раз, у найдоречнішому місці — не повторюй у кількох секціях):\n${blocks.join('\n')}`
115
118
  }
@@ -3,7 +3,7 @@ type: JS Module
3
3
  title: main.mjs
4
4
  resource: npm/rules/doc-files/docgen-gen/main.mjs
5
5
  docgen:
6
- crc: c9c9970b
6
+ crc: c02d3a1f
7
7
  model: omlx/gemma-4-e4b-it-OptiQ-4bit
8
8
  ---
9
9
 
@@ -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 ?? '', anc, intent), model, { timeoutMs, temperature })
494
+ await callLlm(overviewMessages(facts, sections.behavior ?? '', intent), model, { timeoutMs, temperature })
444
495
  )
445
496
  )
446
- overview = await critiqueRefineSection('overview', overview, facts, anc, model, timeoutMs)
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, src, model, LOCAL_TIMEOUT_MS, { anchors, intent })
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, src, model, LOCAL_TIMEOUT_MS, { anchors, temperature: 0.5, intent })
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
- try {
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)
@@ -3,7 +3,7 @@ type: JS Module
3
3
  title: main.mjs
4
4
  resource: npm/rules/doc-files/docgen-prompts/main.mjs
5
5
  docgen:
6
- crc: fb4d1242
6
+ crc: 465da2eb
7
7
  model: omlx/gemma-4-e4b-it-OptiQ-4bit
8
8
  score: 100
9
9
  issues: judge:inaccurate:0.99
@@ -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
- if (m.readOnly) lines.push('Read-only: не пише (ФС/БД)')
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, anchors = null, intent = null) {
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}${anch}${intentContext(intent)}`,
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
- if (m.readOnly) lines.push('- Read-only: не виконує операцій запису (ФС/БД).')
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
+ }
@@ -3,7 +3,7 @@ type: JS Module
3
3
  title: main.mjs
4
4
  resource: npm/rules/text/run-v8r/main.mjs
5
5
  docgen:
6
- crc: 91a9608d
6
+ crc: 10f448a2
7
7
  model: manual
8
8
  ---
9
9