@7n/rules 1.49.25 → 1.50.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.
@@ -3,13 +3,33 @@ type: JS Module
3
3
  title: main.mjs
4
4
  resource: npm/rules/doc-files/docgen-gen/main.mjs
5
5
  docgen:
6
- crc: 80c789c7
6
+ crc: 55d2b8ff
7
7
  model: openai-codex/gpt-5.4-mini
8
8
  tier: cloud-min
9
- score: 10
10
- issues: no-overview,short-behavior,internal-name:isApiGap,internal-name:renderApiLine,internal-name:oneShotDoc,internal-name:finishUnsupported,anchor-miss:(abie.mdc),best-of-2:retry-lost
9
+ score: 50
10
+ issues: internal-name:isApiGap,internal-name:renderApiLine,internal-name:oneShotDoc,internal-name:finishUnsupported,anchor-miss:(foo.mdc),anchor-miss:(abie.mdc),best-of-2:retry-lost
11
11
  ---
12
12
 
13
+ ## Огляд
14
+
15
+ Модуль формує лаконічну поведінкову документацію для коду через набір публічних кроків: `prepareBatchItem` готує окремий елемент, `generateDoc` створює текст документації, а `finishBatchItem` завершує обробку результату. Для локальних сценаріїв використовується `DEFAULT_LOCAL_MODEL`, а кешування працює у межах прогону, щоб повторні звернення в одному запуску не дублювали однакову роботу. Додаткові операції підтримують цілісність вхідного контексту (`capTimeoutToDeadline`, `stripLeadingPreamble`, `splitProtected`, `insertProtected`) і керують якістю та складом вихідного опису (`scoreDoc`, `buildApiSection`, `hasCompleteCommentDocumentation`, `commentDocumentationMode`, `insertTestScenarios`).
16
+
17
+ ## Поведінка
18
+
19
+ Документ збирається як керований конвеєр: джерело й факти проходять preflight, після чого модуль або бере повністю детермінований шлях, або підключає локальну LLM-генерацію з подальшою перевіркою якості. Бюджет часу ріжеться через capTimeoutToDeadline, тож будь-який виклик не виходить за межі дедлайну; коли бюджет вичерпано, генерація зупиняється без старту нового запиту.
20
+
21
+ Початковий текст моделі очищується stripLeadingPreamble, щоб прибрати чатову самореференцію, а splitProtected та insertProtected зберігають захищену секцію «Призначення» під час усіх перетворень. Це важливо для повторних прогонів: наявний намір із попередньої документації не губиться, навіть якщо решта документа перегенеровується.mdc) мають зберігатися в точному вигляді, бо вони використовуються як дослівні якорі для перевірки відповідності.
22
+
23
+ Оцінка якості через scoreDoc працює на зібраному Markdown, а не на сирому відповіді моделі: секції спочатку нормалізуються, потім порівнюються з фактами, захищеним блоком і дослівними якорями. Саме цей score визначає, чи результат придатний одразу, чи потрібен повторний прохід, і чи слід позначати документ degraded. buildApiSection додає в документ лише те, що справді випливає з уже відомого public API, тому не дублює очевидне й не вигадує відсутні деталі.
24
+
25
+ hasCompleteCommentDocumentation і commentDocumentationMode керують тим, чи можна обійтися без LLM або обмежитися мінімальним доповненням з авторських коментарів. Якщо header і змістовні описи вже повністю покривають документ, генерація лишається детермінованою; якщо ж є лише часткові підказки, запускається comment-only або змішаний режим із коротким добудовуванням поведінки. У такому випадку commentDocumentationMode лише вибирає маршрут, а не переписує зміст.
26
+
27
+ insertTestScenarios додає окрему секцію сценаріїв з test/spec-файлів поверх уже зібраного Markdown, не змішуючи їх із поведінковим описом. Це дає змогу зберігати один і той самий основний текст документа незалежно від того, чи прийшов він із one-shot, batch або коментованого режиму.
28
+
29
+ DEFAULT_LOCAL_MODEL задає локальну модель за замовчуванням для всього конвеєра, а generateDoc є головною точкою входу: вона читає файл, отримує факти, вибирає режим, збирає промпт, викликає генерацію, рахує score і повертає готовий документ з метаданими. Якщо початкова версія не дотягує до порогу, виконується один повторний прохід із більш агресивним налаштуванням; якщо й він не допоміг, результат лишається degraded, але не ламає весь прогін.
30
+
31
+ prepareBatchItem і finishBatchItem ділять batch-потік на підготовку та фіналізацію: перша частина збирає все потрібне до submit, друга — перетворює вже отриманий текст у такий самий результат, як у послідовному шляху. Це забезпечує однакові правила для single-file і batch-режиму, але без дублювання LLM-викликів у batch-шарі та без змішування стану між файлами.
32
+
13
33
  ## Публічний API
14
34
 
15
35
  - capTimeoutToDeadline — Ріже базовий per-call таймаут під залишок бюджету до дедлайну.
@@ -27,6 +47,14 @@ JSDoc-описом експорти рендеряться дослівно (`re
27
47
  немає — секція збирається БЕЗ жодного LLM-виклику. Єдиний непокритий
28
48
  експорт (як і раніше) лишається описаним лише в Поведінці — окремого виклику
29
49
  на секцію з одного рядка не варте.
50
+ - hasCompleteCommentDocumentation — Чи коментарі автора повністю покривають машинну документацію: header дає
51
+ «Огляд», а змістовні описи всіх public API — відповідну секцію. У такому
52
+ разі LLM не потрібна: текст зберігається дослівно для JS, Rust і Python.
53
+ - commentDocumentationMode — Вибирає гібридний режим для повністю прокоментованого source. Короткий
54
+ header майже напевно є pointer-ом, а середній header разом із явним flow у
55
+ коді потребує короткого LLM-доповнення. Детальний наратив лишається 0-LLM.
56
+ - insertTestScenarios — Додає test-сценарії до one-shot/batch-документа. Для unsupported мов основний
57
+ Markdown ще повертає LLM, але test-секція лишається виключно JS-рендером.
30
58
  - DEFAULT_LOCAL_MODEL — Дефолтна модель: N_CURSOR_DOCGEN_MODEL → resolveModel('min') (→ N_LOCAL_MIN_MODEL).
31
59
  Без хардкод-fallback: модель налаштовує кожен локально (`N_LOCAL_MIN_MODEL`); якщо
32
60
  нічого не задано — порожньо, і preflight оркестратора фейлить гучно (а не шле
@@ -48,6 +76,10 @@ pre-send guard і той самий факт-лист/one-shot messages, що й
48
76
  викликається (мінімальний обсяг T8 — генерація; judge лишається опційним
49
77
  розширенням послідовного шляху).
50
78
 
79
+ ## Сценарії використання
80
+
81
+ - `npm/rules/doc-files/docgen-gen/tests/docgen-gen.test.mjs` (scoreDoc — R4 generic-overview; scoreDoc — R6 витік службових імен) — абстрактний Огляд штрафується і опускає score під поріг; конкретний Огляд не штрафується; неекспортована функція у Поведінці → internal-name; пропущений валідний анкор → anchor-miss + штраф; наявний анкор → без штрафу; ще 58
82
+
51
83
  ## Гарантії поведінки
52
84
 
53
85
  - Кешує результати в межах одного прогону.
@@ -8,6 +8,7 @@ import { startChain } from '@7n/llm-lib/chain'
8
8
  import { isRunAsCli } from '../../../scripts/cli-entry.mjs'
9
9
  import { docPathForSource } from '../docgen-scan/main.mjs'
10
10
  import { loadDocFilesExtractors } from '../docgen-scan/lang-extensions.mjs'
11
+ import { buildTestEvidenceIndex, renderTestScenarios, testEvidenceForSource } from '../docgen-test-context/main.mjs'
11
12
  import { extractAnchors, anchorTokens } from '../docgen-extract-anchors/main.mjs'
12
13
  import { QUALITY_THRESHOLD } from '../docgen-crc/main.mjs'
13
14
  import { JUDGE_ENABLED, JUDGE_MODEL, detectRefusalFiller, judgeDoc, judgeFailsDoc } from '../docgen-judge/main.mjs'
@@ -93,6 +94,13 @@ async function callLlm(messages, model, opts = {}) {
93
94
 
94
95
  const FENCE_OPEN_RE = /^```[a-z]*\n?/
95
96
  const FENCE_CLOSE_RE = /\n?```\s*$/
97
+ const EMPTY_INLINE_CODE_RE = /``/g
98
+ // Порожній code span — артефакт LLM, а не валідний факт про файл. Зрізаємо всю
99
+ // фразу: інакше після видалення лише `` лишається вигадане твердження довкола.
100
+ const EMPTY_INLINE_CODE_SENTENCE_RE = /[^.!?\n]*``[^.!?\n]*[.!?]/g
101
+ // Внутрішній приклад із коментарів на кшталт `(foo.mdc)` не є поведінкою
102
+ // модуля. Модель інколи переносить його у prose, тому таку фразу відкидаємо.
103
+ const PAREN_MDC_SENTENCE_RE = /[^.!?\n]*\([^()\n]+\.mdc\)[^.!?\n]*[.!?]/g
96
104
  const LEADING_HEADING_RE = /^#{1,6}[ \t]{1,8}[^\n]{0,400}\n{1,8}/
97
105
  // R9: чат-преамбули малих моделей — «озвучування завдання» перед відповіддю
98
106
  // («Ось оновлена чорнетка секції…», «Як технічний письменник, я створю…»,
@@ -138,6 +146,11 @@ const PROTECTED_HEADING = 'Призначення'
138
146
  const PROTECTED_START_RE = /^##\s+Призначення\s*$/
139
147
  const H2_RE = /^##\s/
140
148
  const H1_RE = /^#\s/
149
+ const SIGNATURE_CALL_RE = /([`\w$.]{1,80})\([^()]{0,300}\)/g
150
+ const NORMALIZED_SPACE_RE = /\s+/g
151
+ const H2_HEADING_RE = /^##\s.+$/gm
152
+ const TEST_SCENARIOS_HEADING = '## Сценарії використання'
153
+ const BEHAVIOR_HEADING = '## Поведінка'
141
154
 
142
155
  /**
143
156
  * R9: зрізає провідні чат-преамбули й дубль назви секції з початку тексту.
@@ -169,7 +182,11 @@ function stripSection(text) {
169
182
  t = t.replace(FENCE_OPEN_RE, '').replace(FENCE_CLOSE_RE, '').trim()
170
183
  }
171
184
  t = t.replace(LEADING_HEADING_RE, '') // зрізати випадковий заголовок
172
- return stripLeadingPreamble(t.trim()).trim()
185
+ return stripLeadingPreamble(t.trim())
186
+ .replaceAll(EMPTY_INLINE_CODE_SENTENCE_RE, '')
187
+ .replaceAll(PAREN_MDC_SENTENCE_RE, '')
188
+ .replaceAll(EMPTY_INLINE_CODE_RE, '')
189
+ .trim()
173
190
  }
174
191
 
175
192
  /**
@@ -181,7 +198,7 @@ function stripSection(text) {
181
198
  */
182
199
  function stripSignatures(text) {
183
200
  let t = text
184
- for (let i = 0; i < 2; i++) t = t.replaceAll(/([`\w$.]{1,80})\([^()]{0,300}\)/g, '$1')
201
+ for (let i = 0; i < 2; i++) t = t.replaceAll(SIGNATURE_CALL_RE, '$1')
185
202
  return t
186
203
  }
187
204
 
@@ -424,6 +441,67 @@ export async function buildApiSection(facts, anchors, model, timeoutMs, temperat
424
441
  return [coveredBlock, gapDraft].filter(Boolean).join('\n')
425
442
  }
426
443
 
444
+ /**
445
+ * Чи коментарі автора повністю покривають машинну документацію: header дає
446
+ * «Огляд», а змістовні описи всіх public API — відповідну секцію. У такому
447
+ * разі LLM не потрібна: текст зберігається дослівно для JS, Rust і Python.
448
+ * @param {object} facts факт-лист мовного екстрактора
449
+ * @returns {boolean} true, якщо можна зібрати документ без LLM
450
+ */
451
+ export function hasCompleteCommentDocumentation(facts) {
452
+ return Boolean(facts.header?.trim()) && (facts.exports ?? []).every(exp => !isApiGap(exp))
453
+ }
454
+
455
+ /** Мінімальний розмір header, який може бути лише pointer-ом, а не наративом. */
456
+ const SHORT_HEADER_CHARS = 240
457
+ /** Один докладний public API може сам закрити короткий header-pointer. */
458
+ const SUFFICIENT_SINGLE_API_CHARS = 160
459
+ /** Сигнали потоку, який доцільно стисло звʼязати окремою «Поведінкою». */
460
+ const BEHAVIOR_FLOW_RE =
461
+ /\b(?:await|Promise\.all|Semaphore|JoinSet|chunks|concurr|retry|queue|state|workflow|resolver|provider|catch|try|match)\b/i
462
+
463
+ /**
464
+ * Вибирає гібридний режим для повністю прокоментованого source. Короткий
465
+ * header майже напевно є pointer-ом, а середній header разом із явним flow у
466
+ * коді потребує короткого LLM-доповнення. Детальний наратив лишається 0-LLM.
467
+ * @param {object} facts факт-лист мовного екстрактора
468
+ * @param {string} src вміст source-файлу
469
+ * @returns {'fallback'|'comment-only'|'comment+behavior'} режим генерації
470
+ */
471
+ export function commentDocumentationMode(facts, src) {
472
+ if (!hasCompleteCommentDocumentation(facts)) return 'fallback'
473
+ const headerSize = facts.header.trim().replaceAll(NORMALIZED_SPACE_RE, ' ').length
474
+ const exports = facts.exports ?? []
475
+ const apiSize = exports
476
+ .map(exp => exp.desc?.replaceAll(NORMALIZED_SPACE_RE, ' ').length ?? 0)
477
+ .reduce((sum, size) => sum + size, 0)
478
+ // У маленькому модулі один ретельно описаний API уже є поведінковим
479
+ // контрактом. LLM тут найчастіше додає загальні фрази замість глибини.
480
+ if (headerSize < SHORT_HEADER_CHARS && exports.length === 1 && apiSize >= SUFFICIENT_SINGLE_API_CHARS) {
481
+ return 'comment-only'
482
+ }
483
+ if (headerSize < SHORT_HEADER_CHARS) return 'comment+behavior'
484
+ if (headerSize < 900 && BEHAVIOR_FLOW_RE.test(src)) return 'comment+behavior'
485
+ return 'comment-only'
486
+ }
487
+
488
+ /**
489
+ * Збирає документ лише з авторських коментарів і детермінованих фактів.
490
+ * @param {object} facts факт-лист мовного екстрактора
491
+ * @param {string|null} intent захищена секція «Призначення»
492
+ * @param {string} [behavior] додаткова LLM-секція «Поведінка» для comment+behavior режиму
493
+ */
494
+ function commentOnlyDoc(facts, intent, behavior = '') {
495
+ const sections = {
496
+ overview: facts.header.trim(),
497
+ api: (facts.exports ?? []).map(exp => renderApiLine(exp)).join('\n'),
498
+ behavior,
499
+ scenarios: renderTestScenarios(facts.testScenarioFiles ?? []),
500
+ guarantees: guaranteesFromMarkers(facts)
501
+ }
502
+ return { md: insertProtected(assemble(basename(facts.relPath), sections), intent) }
503
+ }
504
+
427
505
  /**
428
506
  * One-shot: один виклик LLM на весь документ (для unsupported-структур).
429
507
  * @param {object} facts факт-лист
@@ -437,7 +515,7 @@ async function oneShotDoc(facts, src, model, timeoutMs = LOCAL_TIMEOUT_MS, { int
437
515
  const text = await callLlm(oneShotMessages(facts, src), model, { timeoutMs })
438
516
  let md = stripSignatures(stripSection(text))
439
517
  if (!md.startsWith('#')) md = `# ${basename(facts.relPath)}\n\n${md}`
440
- return { md: insertProtected(md + '\n', intent) }
518
+ return { md: insertProtected(insertTestScenarios(md + '\n', facts), intent) }
441
519
  }
442
520
 
443
521
  /**
@@ -451,6 +529,7 @@ function assemble(stem, sections) {
451
529
  ['overview', '## Огляд'],
452
530
  ['behavior', '## Поведінка'],
453
531
  ['api', '## Публічний API'],
532
+ ['scenarios', '## Сценарії використання'],
454
533
  ['guarantees', '## Гарантії поведінки']
455
534
  ]
456
535
  const parts = [`# ${stem}`]
@@ -461,6 +540,55 @@ function assemble(stem, sections) {
461
540
  return parts.join('\n\n') + '\n'
462
541
  }
463
542
 
543
+ /**
544
+ * Видаляє H2-секцію з Markdown без regex з довільним тілом секції.
545
+ * @param {string} md зібраний Markdown-документ
546
+ * @param {string} heading точний H2-заголовок секції
547
+ * @returns {string} документ без секції
548
+ */
549
+ function removeH2Section(md, heading) {
550
+ const lines = md.split('\n')
551
+ const start = lines.findIndex(line => line.trim() === heading)
552
+ if (start === -1) return md
553
+ const end = lines.findIndex((line, index) => index > start && H2_RE.test(line))
554
+ return [...lines.slice(0, start), ...lines.slice(end === -1 ? lines.length : end)].join('\n')
555
+ }
556
+
557
+ /**
558
+ * Повертає тіло H2-секції з Markdown без regex з довільним тілом секції.
559
+ * @param {string} md зібраний Markdown-документ
560
+ * @param {string} heading точний H2-заголовок секції
561
+ * @returns {string} тіло секції або порожній рядок
562
+ */
563
+ function h2SectionBody(md, heading) {
564
+ const lines = md.split('\n')
565
+ const start = lines.findIndex(line => line.trim() === heading)
566
+ if (start === -1) return ''
567
+ const end = lines.findIndex((line, index) => index > start && H2_RE.test(line))
568
+ return lines
569
+ .slice(start + 1, end === -1 ? lines.length : end)
570
+ .join('\n')
571
+ .trim()
572
+ }
573
+
574
+ /**
575
+ * Додає test-сценарії до one-shot/batch-документа. Для unsupported мов основний
576
+ * Markdown ще повертає LLM, але test-секція лишається виключно JS-рендером.
577
+ * @param {string} md зібраний Markdown-документ
578
+ * @param {object} facts факт-лист із `testScenarioFiles`
579
+ * @returns {string} документ із детермінованою секцією сценаріїв
580
+ */
581
+ export function insertTestScenarios(md, facts) {
582
+ const scenarios = renderTestScenarios(facts.testScenarioFiles ?? [])
583
+ if (!scenarios) return md
584
+ const withoutOld = removeH2Section(md, TEST_SCENARIOS_HEADING).trimEnd()
585
+ const section = `## Сценарії використання\n\n${scenarios}`
586
+ const guarantees = '\n\n## Гарантії поведінки'
587
+ const at = withoutOld.indexOf(guarantees)
588
+ if (at === -1) return `${withoutOld}\n\n${section}\n`
589
+ return `${withoutOld.slice(0, at)}\n\n${section}${withoutOld.slice(at)}\n`
590
+ }
591
+
464
592
  /**
465
593
  * Orchestrated: N окремих LLM-викликів, по одному на секцію.
466
594
  * Код потрапляє лише в `behavior`; решта секцій — на мінімальному факт-листі.
@@ -478,10 +606,21 @@ async function orchestratedDoc(
478
606
  timeoutMs,
479
607
  { anchors = null, temperature = 0.2, intent = null } = {}
480
608
  ) {
609
+ const commentMode = commentDocumentationMode(facts, src)
610
+ if (commentMode === 'comment-only') return commentOnlyDoc(facts, intent)
611
+ if (commentMode === 'comment+behavior') {
612
+ const [behaviorPrompt] = sectionMessages(facts, src, anchors, intent, { complementaryBehavior: true })
613
+ const behavior = stripSignatures(
614
+ stripSection(await callLlm(behaviorPrompt.messages, model, { timeoutMs, temperature }))
615
+ )
616
+ return { md: commentOnlyDoc(facts, intent, CRITIC_NONE_RE.test(behavior.trim()) ? '' : behavior).md }
617
+ }
481
618
  const sections = {}
482
619
  const anc = anchors ?? extractAnchors(src)
483
620
  // E3: «Гарантії» — детермінований шаблон з markers (0 LLM-запитів, 0 generic-фраз)
484
621
  sections.guarantees = guaranteesFromMarkers(facts)
622
+ // Test/spec назви рендеряться JS-ом дослівно, а не потрапляють у LLM prompt.
623
+ sections.scenarios = renderTestScenarios(facts.testScenarioFiles ?? [])
485
624
  // Спершу Поведінка — єдина секція з кодом (sectionMessages повертає лише її)
486
625
  for (const s of sectionMessages(facts, src, anc, intent)) {
487
626
  sections[s.key] = stripSignatures(stripSection(await callLlm(s.messages, model, { timeoutMs, temperature })))
@@ -502,6 +641,27 @@ async function orchestratedDoc(
502
641
  return { md: insertProtected(assemble(basename(facts.relPath), sections), intent) }
503
642
  }
504
643
 
644
+ /**
645
+ * Semantic evidence для judge: лише source-код; test-сценарії формує JS.
646
+ * @param {string} src source-код
647
+ * @returns {string} evidence для judge
648
+ */
649
+ function semanticEvidence(src) {
650
+ return src
651
+ }
652
+
653
+ /**
654
+ * Витягує LLM-секцію «Поведінка» для вузького semantic judge. Авторські
655
+ * «Огляд»/API не оцінюються моделлю і не можуть бути нею переписані.
656
+ * @param {string} md зібраний Markdown-документ
657
+ * @returns {string} мінімальний документ з однією секцією або порожній рядок
658
+ */
659
+ function behaviorOnlyDocument(md) {
660
+ const body = h2SectionBody(md, BEHAVIOR_HEADING)
661
+ return body ? `# Поведінка\n\n## Поведінка\n\n${body}\n` : ''
662
+ return match ? `# Поведінка\n\n## Поведінка\n\n${match[1].trim()}\n` : ''
663
+ }
664
+
505
665
  /**
506
666
  * №6 — judge-refine: суддя назвав конкретні неточності (`judge.reason`) — один
507
667
  * локальний refine-прохід замість лише маркування degraded. Приймаємо виправлену
@@ -520,13 +680,13 @@ async function judgeRefinePass(r, judge, { facts, anchors, src, score, model, ch
520
680
  if (!fixed.startsWith('#')) fixed = `# ${basename(facts.relPath)}\n\n${fixed}`
521
681
  const fixedMd = insertProtected(fixed + '\n', intentBody)
522
682
  // Guard 1: рерайт не має губити секції (малі моделі інколи повертають фрагмент)
523
- const origHeadings = r.md.match(/^##\s.+$/gm) ?? []
683
+ const origHeadings = r.md.match(H2_HEADING_RE) ?? []
524
684
  if (origHeadings.some(h => !fixedMd.includes(h))) return null
525
685
  // Guard 2: det-score не має падати
526
686
  const sFixed = scoreDoc(fixedMd, facts, { anchors, src })
527
687
  if (sFixed.score < score) return null
528
688
  // Guard 3: повторний суддя (той самий scope: inaccurate)
529
- const judge2 = { ...(await judgeDoc(src, fixedMd, { chain })), model: JUDGE_MODEL }
689
+ const judge2 = { ...(await judgeDoc(semanticEvidence(src), fixedMd, { chain })), model: JUDGE_MODEL }
530
690
  if (judgeFailsDoc(judge2)) return null
531
691
  return { md: fixedMd, score: sFixed.score, issues: sFixed.issues, judge: judge2 }
532
692
  }
@@ -541,7 +701,7 @@ async function judgeRefinePass(r, judge, { facts, anchors, src, score, model, ch
541
701
  async function runJudgeGate({ r, score, issues, facts, anchors, src, model, chain }) {
542
702
  let judge = null
543
703
  try {
544
- judge = { ...(await judgeDoc(src, r.md, { chain })), model: JUDGE_MODEL }
704
+ judge = { ...(await judgeDoc(semanticEvidence(src, facts), r.md, { chain })), model: JUDGE_MODEL }
545
705
  // №6: суддя назвав конкретні неточності → один локальний refine-прохід
546
706
  // (опт-аут: N_CURSOR_DOCGEN_JUDGE_REFINE=0). Прийнято лише коли всі
547
707
  // guard-и judgeRefinePass пройдені; інакше — degraded, як раніше.
@@ -610,10 +770,13 @@ function srcTokenBudget() {
610
770
  * (js/mjs/ts — lang-js, `.rs` — lang-rust; whole-file `unsupported`-fallback, якщо
611
771
  * екстрактора для розширення нема).
612
772
  * @param {string} file абсолютний шлях джерела
773
+ * @param {ReturnType<typeof buildTestEvidenceIndex>|null} [testIndex] source↔tests index
613
774
  * @returns {Promise<{ src: string, estTokens: number, ext: string, langExtractors: Map<string, object>, facts: object }>} усе потрібне обом callers перед LLM-викликом
614
775
  */
615
- async function loadSrcAndFacts(file) {
776
+ async function loadSrcAndFacts(file, testIndex = null) {
616
777
  const src = readFileSync(file, 'utf8')
778
+ const evidenceIndex = testIndex ?? (existsSync(file) ? buildTestEvidenceIndex(process.cwd()) : null)
779
+ const testEvidence = evidenceIndex ? testEvidenceForSource(file, evidenceIndex) : { files: [] }
617
780
  const estTokens = Math.round(Buffer.byteLength(src, 'utf8') / 4)
618
781
  const budget = srcTokenBudget()
619
782
  if (estTokens > budget) {
@@ -623,7 +786,7 @@ async function loadSrcAndFacts(file) {
623
786
  }
624
787
  const langExtractors = await loadDocFilesExtractors(process.cwd())
625
788
  const ext = `.${file.split('.').pop()}`.toLowerCase()
626
- const facts = langExtractors.get(ext)?.extractFacts?.(src, file) ?? {
789
+ const extractedFacts = langExtractors.get(ext)?.extractFacts?.(src, file) ?? {
627
790
  relPath: file,
628
791
  lang: ext.slice(1),
629
792
  unsupported: true,
@@ -632,6 +795,10 @@ async function loadSrcAndFacts(file) {
632
795
  imports: {},
633
796
  markers: {}
634
797
  }
798
+ const facts = {
799
+ ...extractedFacts,
800
+ testScenarioFiles: testEvidence.files
801
+ }
635
802
  return { src, estTokens, ext, langExtractors, facts }
636
803
  }
637
804
 
@@ -674,7 +841,7 @@ function finishUnsupported(r, { t0, model, chainExtra }) {
674
841
  * з вищою температурою (best-of-2); якщо й він не допоміг — результат
675
842
  * позначається `degraded`, рішення про перегенерацію приймає batch/користувач.
676
843
  * @param {string} file абсолютний шлях джерела
677
- * @param {{ model?: string, threshold?: number, existingMd?: string|null, chainFactory?: typeof startChain, deadlineAt?: number|null }} [opts] model-id, поріг degraded, наявна дока (для збереження захищеної секції), фабрика ланцюжка (інжект для тестів), deadlineAt — мʼякий дедлайн fix-pipeline (epoch ms): per-call таймаути ріжуться під залишок бюджету, вичерпаний бюджет обриває генерацію transient-помилкою
844
+ * @param {{ model?: string, threshold?: number, existingMd?: string|null, chainFactory?: typeof startChain, deadlineAt?: number|null, testIndex?: ReturnType<typeof buildTestEvidenceIndex>|null }} [opts] model-id, поріг degraded, наявна дока (для збереження захищеної секції), фабрика ланцюжка (інжект для тестів), deadlineAt — мʼякий дедлайн fix-pipeline (epoch ms): per-call таймаути ріжуться під залишок бюджету, вичерпаний бюджет обриває генерацію transient-помилкою; testIndex — спільний source↔tests index батчу
678
845
  * @returns {{ md: string, ms: number, llmMs: number, llmCalls: number, score: number|null, issues: string[], degraded: boolean, model: string }} документ і метадані генерації (ms — увесь файл; llmMs/llmCalls — лише LLM; решта ms — оркестрація)
679
846
  */
680
847
  export async function generateDoc(
@@ -684,11 +851,12 @@ export async function generateDoc(
684
851
  threshold = QUALITY_THRESHOLD,
685
852
  existingMd = null,
686
853
  chainFactory = startChain,
687
- deadlineAt = null
854
+ deadlineAt = null,
855
+ testIndex = null
688
856
  } = {}
689
857
  ) {
690
858
  // Guard ДО створення ланцюжка: skip без LLM — не задача.
691
- const { src, estTokens, ext, langExtractors, facts } = await loadSrcAndFacts(file)
859
+ const { src, estTokens, ext, langExtractors, facts } = await loadSrcAndFacts(file, testIndex)
692
860
  const t0 = Date.now()
693
861
  llmMeter = { calls: 0, ms: 0 }
694
862
  const chain = chainFactory({ kind: 'doc-generate', unit: facts.relPath, cwd: process.cwd() })
@@ -717,6 +885,7 @@ export async function generateDoc(
717
885
  // Варіант B: захищена секція «Призначення» з наявної доки — зберегти й подати як контекст
718
886
  const intent = existingMd ? splitProtected(existingMd).body : null
719
887
  const anchors = facts.unsupported ? null : extractAnchors(src)
888
+ const commentMode = commentDocumentationMode(facts, src)
720
889
  const promptSrc = resolvePromptSrc({ facts, estTokens, langExtractors, ext, src, file })
721
890
  let r = facts.unsupported
722
891
  ? await oneShotDoc(facts, src, model, LOCAL_TIMEOUT_MS, { intent })
@@ -732,6 +901,49 @@ export async function generateDoc(
732
901
  // Stage 2.5: детермінований скоринг (0 токенів)
733
902
  let { score, issues } = scoreDoc(r.md, facts, { anchors, src })
734
903
 
904
+ // Авторські header/API-коментарі — уже джерело істини. Для comment-only
905
+ // LLM не запускається зовсім; comment+behavior судить лише LLM-секцію.
906
+ if (commentMode === 'comment-only') {
907
+ chainExtra.score = score
908
+ chainExtra.degraded = false
909
+ return {
910
+ ...r,
911
+ ms: Date.now() - t0,
912
+ llmMs: llmMeter.ms,
913
+ llmCalls: llmMeter.calls,
914
+ score,
915
+ issues,
916
+ degraded: false,
917
+ model
918
+ }
919
+ }
920
+
921
+ if (commentMode === 'comment+behavior') {
922
+ const behaviorDoc = behaviorOnlyDocument(r.md)
923
+ let judge = null
924
+ if (JUDGE_ENABLED && behaviorDoc) {
925
+ judge = { ...(await judgeDoc(semanticEvidence(src), behaviorDoc, { chain })), model: JUDGE_MODEL }
926
+ if (judgeFailsDoc(judge)) {
927
+ r = commentOnlyDoc(facts, intent)
928
+ ;({ score, issues } = scoreDoc(r.md, facts, { anchors, src }))
929
+ issues.push('behavior-judge-removed')
930
+ }
931
+ }
932
+ chainExtra.score = score
933
+ chainExtra.degraded = false
934
+ return {
935
+ ...r,
936
+ ms: Date.now() - t0,
937
+ llmMs: llmMeter.ms,
938
+ llmCalls: llmMeter.calls,
939
+ score,
940
+ issues,
941
+ judge,
942
+ degraded: false,
943
+ model
944
+ }
945
+ }
946
+
735
947
  // E4: best-of-2 — один retry з вищою температурою, det-вибір кращого
736
948
  if (score < threshold && env.N_CURSOR_DOCGEN_BEST_OF !== '0') {
737
949
  try {
@@ -786,14 +998,21 @@ export async function generateDoc(
786
998
  * всі файли разом). Кидає ту саму помилку pre-send guard, що й `generateDoc`
787
999
  * (класифікується `permanent` у batch-оркестраторі — skip, не помилка прогону).
788
1000
  * @param {string} file абсолютний шлях джерела
789
- * @param {{ existingMd?: string|null }} [opts] наявна дока (для захищеної секції «Призначення»)
790
- * @returns {Promise<{ facts: object, anchors: object|null, src: string, messages: Array<{role:string,content:string}>, intent: string|null }>} усе потрібне для item-у batch-у й пізнішого фінішу
1001
+ * @param {{ existingMd?: string|null, testIndex?: ReturnType<typeof buildTestEvidenceIndex>|null }} [opts] наявна дока (для захищеної секції «Призначення») і спільний source↔tests index
1002
+ * @returns {Promise<{ facts: object, anchors: object|null, src: string, mode: string, messages: Array<{role:string,content:string}>, intent: string|null }>} усе потрібне для item-у batch-у й пізнішого фінішу; `comment-only` повертає порожні messages
791
1003
  */
792
- export async function prepareBatchItem(file, { existingMd = null } = {}) {
793
- const { src, facts } = await loadSrcAndFacts(file)
1004
+ export async function prepareBatchItem(file, { existingMd = null, testIndex = null } = {}) {
1005
+ const { src, facts } = await loadSrcAndFacts(file, testIndex)
794
1006
  const anchors = facts.unsupported ? null : extractAnchors(src)
795
1007
  const intent = existingMd ? splitProtected(existingMd).body : null
796
- return { facts, anchors, src, messages: oneShotMessages(facts, src), intent }
1008
+ const mode = facts.unsupported ? 'fallback' : commentDocumentationMode(facts, src)
1009
+ const messages =
1010
+ mode === 'comment-only'
1011
+ ? []
1012
+ : mode === 'comment+behavior'
1013
+ ? sectionMessages(facts, src, anchors, intent, { complementaryBehavior: true })[0].messages
1014
+ : oneShotMessages(facts, src)
1015
+ return { facts, anchors, src, mode, messages, intent }
797
1016
  }
798
1017
 
799
1018
  /**
@@ -803,13 +1022,28 @@ export async function prepareBatchItem(file, { existingMd = null } = {}) {
803
1022
  * викликається (мінімальний обсяг T8 — генерація; judge лишається опційним
804
1023
  * розширенням послідовного шляху).
805
1024
  * @param {string} text сирий текст відповіді моделі для цього item-у
806
- * @param {{ facts: object, anchors: object|null, src: string, intent: string|null, model: string, threshold?: number }} ctx контекст item-у (з `prepareBatchItem`)
1025
+ * @param {{ facts: object, anchors: object|null, src: string, intent: string|null, model: string, threshold?: number, mode?: string }} ctx контекст item-у (з `prepareBatchItem`)
807
1026
  * @returns {{ md: string, score: number|null, issues: string[], degraded: boolean, model: string }} результат генерації для штампу/запису
808
1027
  */
809
- export function finishBatchItem(text, { facts, anchors, src, intent, model, threshold = QUALITY_THRESHOLD }) {
1028
+ export function finishBatchItem(
1029
+ text,
1030
+ { facts, anchors, src, intent, model, threshold = QUALITY_THRESHOLD, mode = null }
1031
+ ) {
1032
+ const commentMode = mode ?? (facts.unsupported ? 'fallback' : commentDocumentationMode(facts, src))
1033
+ if (commentMode === 'comment-only') {
1034
+ const md = commentOnlyDoc(facts, intent).md
1035
+ const { score, issues } = scoreDoc(md, facts, { anchors, src })
1036
+ return { md, score, issues, degraded: false, model }
1037
+ }
1038
+ if (commentMode === 'comment+behavior') {
1039
+ const behavior = stripSignatures(stripSection(text))
1040
+ const md = commentOnlyDoc(facts, intent, CRITIC_NONE_RE.test(behavior.trim()) ? '' : behavior).md
1041
+ const { score, issues } = scoreDoc(md, facts, { anchors, src })
1042
+ return { md, score, issues, degraded: false, model }
1043
+ }
810
1044
  let md = stripSignatures(stripSection(text))
811
1045
  if (!md.startsWith('#')) md = `# ${basename(facts.relPath)}\n\n${md}`
812
- md = insertProtected(md + '\n', intent)
1046
+ md = insertProtected(insertTestScenarios(md + '\n', facts), intent)
813
1047
  if (facts.unsupported) {
814
1048
  const refusal = detectRefusalFiller(splitProtected(md).without)
815
1049
  return {
@@ -3,33 +3,35 @@ type: JS Module
3
3
  title: main.mjs
4
4
  resource: npm/rules/doc-files/docgen-prompts/main.mjs
5
5
  docgen:
6
- crc: 8cb3a892
7
- model: openai-codex/gpt-5.5
8
- tier: cloud-avg
6
+ crc: d09eacad
7
+ model: openai-codex/gpt-5.4-mini
8
+ tier: cloud-min
9
9
  score: 100
10
- issues: judge-refine:kept-original,judge:inaccurate:0.99
10
+ issues: judge-refine:kept-original,judge:inaccurate:0.98
11
11
  judgeModel: openai-codex/gpt-5.4-mini
12
12
  ---
13
13
 
14
14
  ## Огляд
15
15
 
16
- Файл формує промпти й детерміновані фрагменти для генерації поведінкової документації: `STYLE` задає спільний тон, `sectionMessages`, `overviewMessages`, `criticMessages`, `refineMessages`, `oneShotMessages` і `judgeRefineMessages` готують повідомлення для різних етапів, `isApiGap`, `renderApiLine` та `apiGapMessages` висвітлюють прогалини API, а `guaranteesFromMarkers` і `buildUnitDigest` стискають контекст до контрольованого обсягу `UNIT_DIGEST_TOKENS`. Файл існує як текстовий шар оркестратора документації, щоб підтримувати лаконічний стиль, узгоджувати очікування між етапами генерації та зменшувати ризик вигаданих тверджень. У межах одного прогону використовує кешування для повторного використання вже підготовлених результатів.
16
+ `STYLE` задає спільний тон і межі викладу, а `sectionMessages` і `overviewMessages` узгоджують текст секції з уже відомими фактами. `isApiGap`, `apiGapMessages` і `renderApiLine` допомагають окремо фіксувати прогалини в API, щоб опис лишався чесним і не приписував зайвого. `criticMessages`, `refineMessages`, `oneShotMessages` і `judgeRefineMessages` підтримують послідовне доопрацювання одного й того ж змісту, а `guaranteesFromMarkers` і `UNIT_DIGEST_TOKENS` тримають описи в межах підтверджених маркерів і зведених одиниць.
17
17
 
18
18
  ## Поведінка
19
19
 
20
- STYLE задає спільні правила тону й форми для промптів, щоб усі згенеровані секції лишалися лаконічними, поведінковими та без технічного шуму.
20
+ STYLE задає спільний тон і межі для всього генератора: з нього випливають правила лаконічності, безпечної опори на факти та узгодженості між секціями.
21
21
 
22
- Основний потік генерації розділяє документацію на незалежні кроки. sectionMessages формує запит лише для секції «Поведінка» з мінімальним контекстом: фактами про файл, релевантними анкорами, захищеним «Призначенням» і, за потреби, стислим представленням коду. Після отримання готової «Поведінки» overviewMessages створює «Огляд» уже з неї, а не напряму з коду, щоб підсумок спирався на підтверджений опис поведінки.
22
+ sectionMessages збирає мінімальний контекст для поведінкових секцій і запускає основний потік: factsSummary дає стислий факт-витяг, anchorsBlock додає лише потрібні анкори, intentContext підключає захищене «Призначення» як read-only фон, а msgs поєднує все у формат одного LLM-виклику. Саме тут зароджується текст секції «Поведінка», і саме тут враховується позначка про complementarity, коли LLM має доповнювати прогалини, а не переписувати вже відоме.
23
23
 
24
- Публічний API обробляється гібридно без зайвого LLM-переписування. isApiGap визначає, які експорти не мають змістовного опису. Для покритих описом експортів renderApiLine повертає дослівний рядок документації, зберігаючи авторський текст. Для прогалин apiGapMessages формує окремий вузький запит тільки по відсутніх описах, щоб модель не торкалася вже надійних JSDoc-фрагментів.
24
+ isApiGap відсікає експорти, які вже мають змістовний JSDoc, від тих, що потребують синтезу; далі цей поділ живить окремий шлях для API, щоб не змішувати покритий текст із прогалинами. renderApiLine використовує лише вже описані експорти і переносить їх у публічний список без перефразування, а apiGapMessages працює тільки з невкритими експортами, щоб LLM не контактував із уже зафіксованим авторським формулюванням.
25
25
 
26
- Якість машинних секцій підсилюється окремим циклом перевірки. criticMessages готує запит до критика, який має знайти конкретні дефекти або підтвердити їх відсутність. refineMessages використовує ці зауваження для переписування чорнетки без зміни призначення секції. judgeRefineMessages виконує точкове доопрацювання документа за причиною від судді, коли потрібно виправити конкретне хибне твердження замість просто позначити результат як погіршений.
26
+ overviewMessages формує завершальний узагальнювальний крок: бере вже написану «Поведінку», підкріплює її factsSummary та, за потреби, intentContext, і на цій основі просить Огляд. Це замикає порядок секцій так, щоб Огляд узгоджувався з готовим змістом, а не вигадувався з сирих фактів.
27
27
 
28
- guaranteesFromMarkers створює «Гарантії поведінки» детерміновано з маркерів факт-листа, без LLM-запиту. Це відокремлює формальні гарантії від вільного тексту й зменшує ризик вигаданих тверджень.
28
+ criticMessages і refineMessages працюють парою як цикл якості для окремих секцій: критик звіряє чорнетку з facts і anchorsBlock та повертає конкретні issues, після чого refineMessages переписує текст тільки на підставі цих зауважень. Завдяки цьому правки залишаються локальними й не розмивають уже добрі частини секції.
29
29
 
30
- oneShotMessages лишається базовим одноетапним сценарієм для порівняння з секційним потоком. UNIT_DIGEST_TOKENS задає межу компактного представлення великих файлів, а buildUnitDigest перетворює набір юнітів на стислий дайджест, щоб промпт зберігав фокус на структурі й поведінці замість перевантаження сирим кодом.
30
+ guaranteesFromMarkers не входить у LLM-потік: він детерміновано виводить гарантовані властивості з facts.markers і слугує стабільним шаром, який не залежить від генеративної варіативності. Це тримає критичні твердження поза ризиком галюцинацій.
31
31
 
32
- Файл не виконує власних записів у файлову систему чи базу даних: результати всіх публічних функцій повертаються як текстові секції, рядки або масиви повідомлень для подальшої обробки зовнішнім оркестратором. Кешування використовується лише в межах поточного прогону.
32
+ oneShotMessages є базовою точкою порівняння для одного суцільного LLM-запиту: до нього потрапляють facts і src без розбиття на секції. buildUnitDigest підміняє сирий src компактним структурним дайджестом, коли великий файл починає розмивати фокус моделі; UNIT_DIGEST_TOKENS обмежує цей режим, щоб зберігати промпт коротким і передбачуваним.
33
+
34
+ judgeRefineMessages підхоплює фінальне локальне доопрацювання після judge-перевірки: reason від судді стає єдиною опорою для точкового виправлення doc-тексту без залучення захищеного «Призначення». Увесь потік спирається на спільний кеш у межах одного прогону, щоб повторні звернення до тих самих даних не змінювали результат і не множили витрати.
33
35
 
34
36
  ## Публічний API
35
37
 
@@ -71,6 +73,10 @@ LLM-судді (замість лише маркування degraded). Судд
71
73
  хибне (`reason`) — мала модель добре виправляє точкові твердження, коли їй
72
74
  сказано, які саме.
73
75
 
76
+ ## Сценарії використання
77
+
78
+ - `npm/rules/doc-files/docgen-prompts/tests/docgen-prompts.test.mjs` (sectionMessages — Огляд більше не тут (R3); guaranteesFromMarkers — лише file-local твердження) — не повертає секцію overview; Поведінка обмежена експортованими іменами (R6); Поведінка не отримує test evidence: сценарії рендерить JS окремою секцією; гібридний режим просить доповнити comments лише відсутнім потоком; fail-safe маркер не обіцяє, що всі помилки лишаються всередині модуля; ще 10
79
+
74
80
  ## Гарантії поведінки
75
81
 
76
82
  - Власних операцій запису (ФС/БД) у файлі немає; виклики імпортованих модулів можуть писати.