@nitra/cursor 5.2.0 → 5.2.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.
@@ -1,120 +1,27 @@
1
- # fix.mjs — entry-point правила `hasura`
1
+ ---
2
+ docgen:
3
+ source: npm/rules/hasura/fix.mjs
4
+ crc: 12fc1644
5
+ score: 100
6
+ ---
2
7
 
3
- ## Огляд
4
-
5
- Файл `npm/rules/hasura/fix.mjs` — тонкий entry-point для правила з ідентифікатором `hasura` у складі CLI-пакета `@nitra/cursor`. Виконує дві ролі одночасно:
6
-
7
- 1. **Library mode** — експортує функцію `run(ctx)`, яка викликається з оркестратора CLI (наприклад, з `runRuleCli` або з агрегаторів типу `n-fix`), що дозволяє запускати правило в межах загального прогону всіх правил без породження окремого процесу.
8
- 2. **Standalone mode** — якщо файл виконується безпосередньо (через `bun rules/<id>/fix.mjs` або `node rules/<id>/fix.mjs`), він виступає самостійним CLI-входом, повністю еквівалентним `npx @nitra/cursor fix hasura`: підвантажує config, застосовує whitelist, друкує summary та повертає процесний exit-code.
9
-
10
- Уся фактична логіка правила (послідовність кроків `applies → JS-concerns → policy → mdc-refs`) винесена в спільну реалізацію `runStandardRule` із бібліотечного шару `scripts/lib/`. Файл-обгортка несе лише прив'язку правила до власної директорії (через `import.meta.dirname`) та dispatch між двома режимами запуску.
11
-
12
- Відповідає конвенції двороль­ового `fix.mjs`, ухваленій для всіх «стандартних» правил у `npm/rules/*/`, тож має мінімальну поверхню коду й не містить специфіки `hasura`: тип правила, скоупи, перевірки та поліcі описуються деінде (`meta.json`, `hasura.mdc`, `js/`, `policy/`).
13
-
14
- ## Експорти / API
15
-
16
- | Експорт | Тип | Призначення |
17
- | ------- | ---------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
18
- | `run` | `(ctx?: RuleContext) => Promise<number>` | Library-mode прогін правила в спільному CLI orchestration. Повертає `0` при відсутності порушень або `1` при наявності порушень. |
19
-
20
- Файл не має `default`-експорту й не експортує жодних інших символів.
21
-
22
- Поведінка при безпосередньому запуску файлу як скрипта — побічний ефект (top-level `await runRuleCli(...)` + `process.exit(...)`) і не є частиною програмного API.
23
-
24
- ## Функції
25
-
26
- ### `run(ctx)`
27
-
28
- Сигнатура: `export function run(ctx)`.
29
-
30
- - **Параметри:**
31
- - `ctx` (`RuleContext`, опційний) — контекст прогону, що передається з оркестратора. Тип імпортовано (через JSDoc-tag `@param`) з `../../scripts/lib/run-standard-rule.mjs`. Очікувані поля контексту (за конвенцією `runStandardRule`) — спільні для всіх правил, серед них зокрема `walkCache` (кеш обходу файлової системи, щоб не повторювати walk між кількома правилами в одному прогоні). Якщо `ctx` не передано (`undefined`), `runStandardRule` сам ініціалізує мінімально необхідний контекст.
32
- - **Повертає:** `Promise<number>` — exit-code правила:
33
- - `0` — застосовні файли пройшли всі етапи (`applies → JS-concerns → policy → mdc-refs`) без виявлених порушень.
34
- - `1` — знайдено принаймні одне порушення (або зафіксована помилка валідації, що інтерпретується як failure).
35
- - **Side effects:**
36
- - Делегує всю роботу `runStandardRule(import.meta.dirname, ctx)`, який, своєю чергою, читає файли правила (`meta.json`, `*.mdc`, `js/*.mjs`, `policy/*.rego`) із директорії, обходить цільові файли проєкту, друкує діагностичні повідомлення у stdout/stderr та повертає підсумкове число порушень. Сам `run` стану не модифікує.
37
- - Прив'язує правило до своєї директорії саме через `import.meta.dirname`, тож при переміщенні файлу `fix.mjs` правило автоматично «переїде» разом із його артефактами (без хардкоду шляхів).
38
-
39
- ### Безіменна процедура top-level (standalone-блок)
8
+ # fix.mjs
40
9
 
41
- ```js
42
- if (isRunAsCli(import.meta.url)) {
43
- process.exit(await runRuleCli(import.meta.dirname))
44
- }
45
- ```
46
-
47
- - **Тип:** виконується один раз під час завантаження модуля.
48
- - **Параметри:** немає (CLI-аргументи зчитуються всередині `runRuleCli` через `process.argv`).
49
- - **Повертає:** нічого (виконує `process.exit`).
50
- - **Side effects:**
51
- - `isRunAsCli(import.meta.url)` визначає, чи модуль завантажено як прямий entry-point (а не як `import`).
52
- - У випадку прямого запуску викликає `runRuleCli(import.meta.dirname)`, який повертає числовий exit-code, і негайно завершує процес із цим кодом через `process.exit`.
53
- - Через `process.exit` подальші асинхронні задачі (мікротаски, відкриті ресурси) можуть бути обірвані — це свідомо: standalone entry-point повинен повертати чіткий код для CI/IDE-інтеграцій.
54
- - **Локальні правила лінту:**
55
- - Поряд із викликом стоїть прагма `// eslint-disable-next-line n/no-process-exit, unicorn/no-process-exit -- standalone entry-point має повертати exit-code для CI/IDE`, оскільки за загальним правилом `process.exit` заборонений, але для CLI-входів дозволений.
56
-
57
- ## Залежності
58
-
59
- ### Внутрішні (відносні імпорти)
60
-
61
- - `../../scripts/lib/run-rule-cli.mjs`
62
- - `isRunAsCli(metaUrl: string): boolean` — детектор «чи запущено модуль як скрипт». Зазвичай порівнює `import.meta.url` із URL виконуваного скрипта (`process.argv[1]`), щоб розрізнити `import` та прямий запуск.
63
- - `runRuleCli(ruleDir: string): Promise<number>` — повноцінний CLI-runner: парсить аргументи, читає глобальний config (whitelist, severity, опції), запускає правило з директорії `ruleDir` і друкує summary. Повертає exit-code.
64
- - `../../scripts/lib/run-standard-rule.mjs`
65
- - `runStandardRule(ruleDir: string, ctx?: RuleContext): Promise<number>` — стандартна послідовність кроків для «звичайного» правила: `applies → JS-concerns → policy → mdc-refs`. Це публічне ядро правил-обгорток на кшталт `fix.mjs`.
66
- - `RuleContext` (тип) — імпортовано лише в JSDoc для типізації параметра `run`.
67
-
68
- ### Зовнішні
69
-
70
- Прямих залежностей від npm-пакетів файл не має. Усі сторонні бібліотеки, які можуть знадобитися (наприклад, walker, парсер `.mdc`, OPA-обгортки тощо), використовуються транзитивно через `run-standard-rule.mjs` та `run-rule-cli.mjs`.
71
-
72
- ### Залежність від файлів-побратимів у директорії правила
73
-
74
- Хоча `fix.mjs` сам нічого з них не читає, `runStandardRule(import.meta.dirname)` спирається на сусідні артефакти у тій же директорії правила `hasura/`:
75
-
76
- - `meta.json` — метадані правила (id, scope, опції тощо);
77
- - `hasura.mdc` — людинозрозумілий опис правила у форматі Cursor `.mdc`;
78
- - `js/` — JS-перевірки правила (`check-*.mjs`, fix-helpers);
79
- - `policy/` — Rego-полісі для OPA-кроку.
80
-
81
- Тож структурно файл `fix.mjs` має сенс лише як частина повної папки правила.
82
-
83
- ## Потік виконання / Використання
84
-
85
- ### Сценарій A — Library mode (з оркестратора)
10
+ ## Огляд
86
11
 
87
- 1. Оркестратор (`n-cursor` CLI, агрегатор скілів, тести) робить `import { run } from '<path>/npm/rules/hasura/fix.mjs'`.
88
- 2. Викликає `await run(ctx)`, передаючи контекст із попередньо побудованим `walkCache` та іншими опціями.
89
- 3. `run` синхронно повертає результат виклику `runStandardRule(import.meta.dirname, ctx)`.
90
- 4. `runStandardRule`:
91
- - читає `meta.json` із директорії правила;
92
- - проганяє послідовність `applies` (фільтр цільових файлів) → JS-concerns (динамічно підвантажені `js/check-*.mjs`) → policy (OPA з `policy/*.rego`) → mdc-refs (валідація `.mdc`-посилань);
93
- - агрегує знахідки й повертає кількість порушень як exit-code (`0`/`1`).
94
- 5. Оркестратор підсумовує exit-коди всіх правил.
12
+ Цей файл ініціює застосування правил. Він запускає процес, що включає перевірку JS-занепокоєнь та політик. При самостійному запуску скрипт завантажує конфігурацію та вайтлістинг, а також надає звіт про виконання.
95
13
 
96
- ### Сценарій B — Standalone CLI
14
+ ## Поведінка
97
15
 
98
- 1. Користувач/CI виконує `bun npm/rules/hasura/fix.mjs [args...]`.
99
- 2. На завантаженні модуля Node/Bun обчислює `isRunAsCli(import.meta.url)` для прямого запуску результат `true`.
100
- 3. Виконується `await runRuleCli(import.meta.dirname)`:
101
- - парсинг CLI-аргументів (`--whitelist`, `--severity`, прапори `-q`/`-v` тощо — деталі в `run-rule-cli.mjs`);
102
- - підвантаження конфіг-файлу пакета;
103
- - застосування whitelist;
104
- - виклик внутрішнього еквівалента `runStandardRule` для цієї ж директорії;
105
- - друк summary-таблиці результатів.
106
- 4. `process.exit(<exit-code>)` миттєво завершує процес зі значенням, яке повернув `runRuleCli`.
16
+ 1. Викликати функцію `run` для виконання правила. Це ініціює процес застосування правил, включаючи перевірку JS-занепокоєнь, політик та посилань MDC.
17
+ 2. Якщо скрипт виконується як окрема утиліта (standalone), викликати механізм оркестрації для виконання правила. Це забезпечує повну функціональність, включаючи завантаження конфігурації, вайтлістинг та підсумок.
107
18
 
108
- ### Типові випадки використання
19
+ ## Публічний API
109
20
 
110
- - **CI:** `bun npm/rules/hasura/fix.mjs` як standalone-крок у пайплайні; ненульовий exit-code зриває build.
111
- - **Локально:** `npx @nitra/cursor fix hasura` запускає той самий entry-point через диспетчер CLI пакета (а не напряму).
112
- - **Інтеграція в IDE:** редактор може імпортувати `run` і викликати його в окремому процесі для отримання структурованого результату по правилу `hasura` (наприклад, через виклик `bun -e "import('...fix.mjs').then(m => m.run())"`).
113
- - **Агрегатори скілів** (наприклад, `n-fix`): паралельно або послідовно імпортують `run` із усіх правил `npm/rules/*/fix.mjs`, передають спільний `ctx` із `walkCache`, щоб уникнути повторного обходу файлової системи.
21
+ run виконує послідовність дій: застосовує правила, обробляє JavaScript-занепокоєння, перевіряє політику та посилання MDC.
114
22
 
115
- ### Технічні зауваження щодо реалізації
23
+ ## Гарантії поведінки
116
24
 
117
- - `import.meta.dirname` (Node 20.11 / Bun) повертає абсолютний шлях до директорії модуля без потреби в `fileURLToPath(import.meta.url)`. Це поточний рекомендований спосіб локалізації власної директорії в ESM-модулях.
118
- - Прапор `await` у `process.exit(await runRuleCli(...))` працює завдяки top-level await у ESM — додаткової `main()`-обгортки не потрібно.
119
- - Свідомо обираний `process.exit` (а не «м'який» вихід через `process.exitCode = ...`) гарантує детермінований код повернення для CI/IDE навіть якщо хтось у фоновому таску щось «недописав».
120
- - Файл нейтральний щодо помилок: `runStandardRule` та `runRuleCli` самі вирішують, чи виносити exception у консоль і конвертувати її в exit-code; `fix.mjs` не обгортає виклики в `try/catch`.
25
+ - Read-only: файл не виконує операцій запису у файлову систему.
26
+ - Кешує результати в межах одного прогону.
27
+ - Не звертається до мережі.
@@ -15,9 +15,15 @@
15
15
  */
16
16
 
17
17
  const URL_RE = /https?:\/\/[^\s'"`)<>]+/g
18
+ // Після обрізання template-частини URL має лишитися host (R10).
19
+ const STATIC_URL_RE = /^https?:\/\/[^/${]+/
18
20
  const EXPORT_CONST_RE = /export\s+const\s+([A-Z][A-Z0-9_]+)\s*=\s*(['"`])([^'"`]+)\2/g
19
21
  const ERROR_MARKER_RE = /\(([a-z][\w-]*\.mdc)\)/g
20
- const CONFIG_REF_RE = /\b(\.[a-z][\w.-]*\.json)\b/gi
22
+ // Повне ім'я json-конфіга (з опційним провідним дотом). Lookbehind `(?<![\w.])`
23
+ // не дає почати матч усередині складеного імені — інакше `settings.local.json`
24
+ // дало б хибний анкор `.local.json`, а `capacitor.config.json` → `.config.json`,
25
+ // і модель, маючи їх у «обов'язкових анкорах», писала б неіснуючий файл як факт.
26
+ const CONFIG_REF_RE = /(?<![\w.])(\.?[a-z][\w.-]*\.json)\b/gi
21
27
  const FILE_HEADER_RE = /^\s*\/\*\*([\s\S]*?)\*\//
22
28
  const CODE_BLOCK_RE = /```[a-z]{0,12}\n([\s\S]*?)\n[ \t]{0,8}\*?[ \t]{0,8}```/g
23
29
 
@@ -50,7 +56,16 @@ function uniq(arr) {
50
56
  * }} категоризовані анкори файлу
51
57
  */
52
58
  export function extractAnchors(src) {
53
- const urls = uniq(Array.from(src.matchAll(URL_RE), m => m[0]))
59
+ // R10: template-literal URL (`https://h/${expr}/x`) обрізаємо на `${`, лишаючи
60
+ // статичний префікс. Інакше анкор тягне у доку сміття типу `…/${encodeURIComponent(name`.
61
+ const urls = uniq(
62
+ Array.from(src.matchAll(URL_RE), m => m[0])
63
+ .map(u => {
64
+ const i = u.indexOf('${')
65
+ return i === -1 ? u : u.slice(0, i)
66
+ })
67
+ .filter(u => STATIC_URL_RE.test(u))
68
+ )
54
69
 
55
70
  const magicStrings = []
56
71
  const seenNames = new Set()
@@ -73,6 +88,17 @@ export function extractAnchors(src) {
73
88
  return { urls, magicStrings, errorMarkers, configRefs, examples }
74
89
  }
75
90
 
91
+ /**
92
+ * Плоский список анкор-токенів, які мають дослівно зʼявитися в документі (R5):
93
+ * URLs, імена констант-рядків, маркери `(rule.mdc)`, конфіги. Приклади й
94
+ * code-блоки опускаються — їх багаторядковість не звіряється підрядком.
95
+ * @param {ReturnType<typeof extractAnchors>} a анкори файлу
96
+ * @returns {string[]} токени для перевірки покриття/валідності
97
+ */
98
+ export function anchorTokens(a) {
99
+ return [...a.urls, ...a.magicStrings.map(s => s.name), ...a.errorMarkers.map(m => `(${m})`), ...a.configRefs]
100
+ }
101
+
76
102
  /**
77
103
  * Форматує анкори у компактний текст для system-промпта.
78
104
  * Якщо анкорів немає взагалі — повертає порожній рядок (системний блок про
@@ -30,6 +30,9 @@ const RETURNS_LINE_RE = /^@returns?[ \t]{1,8}(?:\{[^}]{0,200}\}[ \t]{1,8})?(.{0,
30
30
  const FILE_HEADER_RE = /^\s*\/\*\*([\s\S]*?)\*\//
31
31
  const PRECEDING_JSDOC_RE = /\/\*\*(?:(?!\*\/)[\s\S])*\*\/\s*$/
32
32
  const EXPORT_DECL_RE = /export\s+(?:async\s+)?(function|const|class)\s+(\w+)/g
33
+ // Top-level function/class декларації (колонка 0) — для R6: службові функції,
34
+ // які не експортуються, не мають протікати у Поведінку/API як «публічні».
35
+ const TOP_FN_DECL_RE = /^(?:export\s+)?(?:default\s+)?(?:async\s+)?(?:function\*?|class)\s+(\w+)/gm
33
36
  const IMPORT_FROM_RE = /^import[ \t]{1,8}[\s\S]{0,300}?from\s{1,8}['"]([^'"]+)['"]/gm
34
37
  const NODE_PREFIX_RE = /^node:/
35
38
  const INTERNAL_IMPORT_RE = /import[ \t]{1,8}([^'"]{0,300}?)from[ \t]{1,8}['"]\.[^'"]{1,300}['"]/g
@@ -41,7 +44,10 @@ const CATCH_RE = /catch\s*\(/
41
44
  const TRY_RE = /\btry\s*\{/
42
45
  const FALSY_RETURN_RE = /return\s+(false|null|''|"")/
43
46
  const NETWORK_RE = /\bfetch\(|https?\.|axios|got\(/
44
- const CACHE_RE = /new Map\(\)|Cache|cache/
47
+ // Кеш лише за ІМЕНОВАНИМ маркером (`cache`/`Cache`/`memoize`), не за будь-яким
48
+ // `new Map()`: акумулятор (напр. `byPath = new Map()`) — не кеш, а хибна гарантія
49
+ // «Кешує результати» гірша за пропуск (фабрикація > мовчання).
50
+ const CACHE_RE = /cache|memoi[sz]e/i
45
51
 
46
52
  /**
47
53
  * Прибирає `/** *​/`-обрамлення й `*`-префікси, повертає чистий текст рядками.
@@ -168,6 +174,22 @@ function extractInternalSymbols(src) {
168
174
  return [...out]
169
175
  }
170
176
 
177
+ /**
178
+ * Імена top-level функцій/класів, які НЕ експортуються (службові помічники).
179
+ * Модель не має подавати їх як «публічні функції» у Поведінці/API (R6).
180
+ * Const-стрілки свідомо не ловимо — менше false-positive на змістовних константах.
181
+ * @param {string} src вміст файлу
182
+ * @returns {Array<string>} список імен неекспортованих функцій/класів
183
+ */
184
+ function extractLocalSymbols(src) {
185
+ const exported = new Set(Array.from(src.matchAll(EXPORT_DECL_RE), m => m[2]))
186
+ const out = new Set()
187
+ for (const m of src.matchAll(TOP_FN_DECL_RE)) {
188
+ if (!exported.has(m[1])) out.add(m[1])
189
+ }
190
+ return [...out]
191
+ }
192
+
171
193
  /**
172
194
  * Поведінкові маркери — евристики регулярками.
173
195
  * @param {string} src вміст файлу
@@ -207,6 +229,7 @@ export function extractFacts(src, relPath) {
207
229
  exports: extractExports(src),
208
230
  imports: extractImports(src),
209
231
  internalSymbols: extractInternalSymbols(src),
232
+ localSymbols: extractLocalSymbols(src),
210
233
  markers: extractMarkers(src)
211
234
  }
212
235
  }
@@ -7,11 +7,12 @@ import { DEFAULT_OMLX_MODEL } from '../../../lib/omlx.mjs'
7
7
  import { callLlm } from '../../../lib/llm.mjs'
8
8
  import { isRunAsCli } from '../../../scripts/cli-entry.mjs'
9
9
  import { extractFacts } from './docgen-extract.mjs'
10
- import { extractAnchors } from './docgen-extract-anchors.mjs'
10
+ import { extractAnchors, anchorTokens } from './docgen-extract-anchors.mjs'
11
11
  import { QUALITY_THRESHOLD } from './docgen-crc.mjs'
12
12
  import {
13
13
  oneShotMessages,
14
14
  sectionMessages,
15
+ overviewMessages,
15
16
  criticMessages,
16
17
  refineMessages,
17
18
  guaranteesFromMarkers
@@ -25,6 +26,15 @@ const SECTION_KEY_CLEAN_RE = /[^а-яіїєґa-z0-9]/gi
25
26
  const CACHE_MENTION_RE = /кеш/i
26
27
  const CACHE_NEGATION_RE = /(?:не|без)\s+(?:\S+\s+)?кеш|немає\s+кеш/i
27
28
  const CRITIC_NONE_RE = /^\s*NONE\s*$/i
29
+ // R4: абстрактні «нічого-не-кажучі» формули, які обходять exact-blocklist і дають score=100
30
+ const GENERIC_RE =
31
+ /відповідност\S*\s+(?:даних\s+)?(?:визначеному\s+)?контракту|валідаці\S*\s+даних|перевірк\S*\s+(?:відповідності\s+)?даних|обробк\S*\s+даних|застосову\S*\s+логіку|інспекту\S*\s+та\s+збира\S*\s+дан/i
32
+ // R7: часті русизми/суржик (курований безпечний список — без false-positive на нормальній мові).
33
+ // Без \b: кирилиця не є ASCII-`\w`, тож межі слова в JS-regex не спрацьовують — терміни специфічні.
34
+ const SURZHIK_RE =
35
+ /пропуская|являється|в залежності|по замовчуванню|на протязі|відповідаюч|слідуюч|наступним разом|приймати участь|у відповідності/i
36
+ const ANCHOR_MISS_PENALTY = 5
37
+ const ANCHOR_MISS_CAP = 20
28
38
 
29
39
  /**
30
40
  * Прибирає код-фенс-обгортку (потрійні бектіки) й випадковий провідний
@@ -86,18 +96,26 @@ function hasName(text, sym) {
86
96
  * Stage 2.5 — детермінований скоринг (0 токенів): перевіряє вихід проти фактів.
87
97
  * @param {string} md зібраний документ
88
98
  * @param {object} facts факт-лист про файл
99
+ * @param {{ anchors?: object|null, src?: string }} [ctx] анкори й джерело для R5
89
100
  * @returns {{ score: number, issues: string[] }} оцінка 0–100 і коди проблем
90
101
  */
91
- function scoreDoc(md, facts) {
102
+ export function scoreDoc(md, facts, { anchors = null, src = '' } = {}) {
92
103
  const s = parseSections(md)
93
104
  let score = 100
94
105
  const issues = []
106
+ const overview = s['огляд'] ?? ''
95
107
 
96
108
  if (!s['огляд']) {
97
109
  score -= 25
98
110
  issues.push('no-overview')
99
111
  }
100
112
 
113
+ // R4: generic-Огляд (парафрази, які обходять exact-blocklist) — як майже-відсутній.
114
+ if (GENERIC_RE.test(overview)) {
115
+ score -= 35
116
+ issues.push('generic-overview')
117
+ }
118
+
101
119
  const behavior = s['поведінка'] ?? ''
102
120
  if (behavior.length < 60) {
103
121
  score -= 20
@@ -113,14 +131,35 @@ function scoreDoc(md, facts) {
113
131
  issues.push('cache-hallucination')
114
132
  }
115
133
 
116
- for (const sym of facts.internalSymbols ?? []) {
117
- const inDoc = hasName(guarantees, sym) || hasName(s['огляд'] ?? '', sym) || hasName(s['поведінка'] ?? '', sym)
134
+ // R6: службові (неекспортовані) функції не мають фігурувати як публічні
135
+ const api = s['публічнийapi'] ?? ''
136
+ for (const sym of [...(facts.internalSymbols ?? []), ...(facts.localSymbols ?? [])]) {
137
+ const inDoc = hasName(guarantees, sym) || hasName(overview, sym) || hasName(behavior, sym) || hasName(api, sym)
118
138
  if (inDoc) {
119
139
  score -= 10
120
140
  issues.push(`internal-name:${sym}`)
121
141
  }
122
142
  }
123
143
 
144
+ // R5: кожен валідний анкор (дослівний підрядок src) має зʼявитися в документі
145
+ if (anchors && src) {
146
+ let missPenalty = 0
147
+ for (const tok of anchorTokens(anchors)) {
148
+ if (!src.includes(tok)) continue // валідність: фейковий анкор не вимагаємо
149
+ if (!md.includes(tok) && missPenalty < ANCHOR_MISS_CAP) {
150
+ missPenalty += ANCHOR_MISS_PENALTY
151
+ issues.push(`anchor-miss:${tok}`)
152
+ }
153
+ }
154
+ score -= missPenalty
155
+ }
156
+
157
+ // R7: суржик/русизми
158
+ if (SURZHIK_RE.test(md)) {
159
+ score -= 10
160
+ issues.push('surzhik')
161
+ }
162
+
124
163
  return { score: Math.max(0, score), issues }
125
164
  }
126
165
 
@@ -205,15 +244,21 @@ function orchestratedDoc(facts, src, model, timeoutMs, { anchors = null, tempera
205
244
  const anc = anchors ?? extractAnchors(src)
206
245
  // E3: «Гарантії» — детермінований шаблон з markers (0 LLM-запитів, 0 generic-фраз)
207
246
  sections.guarantees = guaranteesFromMarkers(facts)
247
+ // Спершу Поведінка (+API) — секції з фактажем
208
248
  for (const s of sectionMessages(facts, src, anc)) {
209
- if (s.key === 'guarantees') continue // вже згенеровано детерміновано
210
249
  let draft = stripSignatures(stripSection(callLlm(s.messages, model, { timeoutMs, temperature })))
211
- // E2 + E3: critique→refine лише для секцій, де мала модель зриває на generic
212
- if (s.key === 'overview' || (s.key === 'api' && apiNeedsRefine(facts))) {
250
+ // E2: critique→refine для API, коли всі описи порожні (модель зриває на generic)
251
+ if (s.key === 'api' && apiNeedsRefine(facts)) {
213
252
  draft = critiqueRefineSection(s.key, draft, facts, anc, model, timeoutMs)
214
253
  }
215
254
  sections[s.key] = draft
216
255
  }
256
+ // R3: «Огляд» — ОСТАННІМ, узагальненням уже написаної Поведінки (не голого факт-листа)
257
+ let overview = stripSignatures(
258
+ stripSection(callLlm(overviewMessages(facts, sections.behavior ?? '', anc), model, { timeoutMs, temperature }))
259
+ )
260
+ overview = critiqueRefineSection('overview', overview, facts, anc, model, timeoutMs)
261
+ sections.overview = overview
217
262
  return { md: assemble(basename(facts.relPath), sections) }
218
263
  }
219
264
 
@@ -253,13 +298,13 @@ export function generateDoc(file, { model = DEFAULT_LOCAL_MODEL, threshold = QUA
253
298
  }
254
299
 
255
300
  // Stage 2.5: детермінований скоринг (0 токенів)
256
- let { score, issues } = scoreDoc(r.md, facts)
301
+ let { score, issues } = scoreDoc(r.md, facts, { anchors, src })
257
302
 
258
303
  // E4: best-of-2 — один retry з вищою температурою, det-вибір кращого
259
304
  if (score < threshold && env.N_CURSOR_DOCGEN_BEST_OF !== '0') {
260
305
  try {
261
306
  const r2 = orchestratedDoc(facts, src, model, LOCAL_TIMEOUT_MS, { anchors, temperature: 0.5 })
262
- const s2 = scoreDoc(r2.md, facts)
307
+ const s2 = scoreDoc(r2.md, facts, { anchors, src })
263
308
  if (s2.score > score) {
264
309
  r = r2
265
310
  score = s2.score
@@ -52,31 +52,26 @@ const msgs = (system, user) => [
52
52
 
53
53
  /**
54
54
  * Секційні набори messages з МІНІМАЛЬНИМ контекстом під кожну секцію.
55
- * Код потрапляє лише в `behavior`; решта секцій на факт-листі.
55
+ * Код потрапляє лише в `behavior`; «Огляд» генерується окремо ОСТАННІМ
56
+ * (`overviewMessages`) з уже написаної Поведінки — тут його немає.
56
57
  * @param {object} facts факт-лист про файл
57
58
  * @param {string} src вміст файлу
58
59
  * @param {object|null} [anchors] анкори файлу для обовʼязкового включення
59
- * @returns {Array<{key:string, messages:object[], numPredict:number}>} набір секційних промптів
60
+ * @returns {Array<{key:string, messages:object[], numPredict:number}>} набір секційних промптів (behavior[, api])
60
61
  */
61
62
  export function sectionMessages(facts, src, anchors = null) {
62
63
  const factsTxt = factsSummary(facts)
63
64
  const anch = anchorsBlock(anchors)
64
65
  const multi = (facts.exports?.length || 0) > 1
65
66
 
66
- // Огляд лише факти (без коду)
67
- const overview = {
68
- key: 'overview',
69
- numPredict: 220,
70
- messages: msgs(
71
- `${STYLE}\n\nВІДОМІ ФАКТИ:\n${factsTxt}${anch}`,
72
- 'Напиши вміст секції «Огляд»: 1-3 речення — що файл робить і навіщо існує (роль у системі). Без заголовка, без переліку функцій. Заборонені generic-фрази типу «забезпечує перевірку», «виконує валідацію» — пиши КОНКРЕТНО що саме і за яким контрактом.'
73
- )
74
- }
75
-
76
- // Поведінка — ЄДИНА секція, якій потрібен код
67
+ // R6: Поведінка описує РІВНО експортовані імена, не службові помічники
68
+ const exportNames = (facts.exports ?? []).map(e => e.name)
77
69
  const behaviorTask = multi
78
70
  ? 'для кожної публічної функції — один короткий пункт «що вона робить»'
79
71
  : 'нумерований алгоритм у бізнес-термінах'
72
+ const onlyExports = exportNames.length
73
+ ? ` Описуй РІВНО ці публічні імена і жодних інших: ${exportNames.join(', ')}.`
74
+ : ''
80
75
  const noInternal = facts.internalSymbols?.length
81
76
  ? ` НЕ згадуй за іменами службові функції: ${facts.internalSymbols.join(', ')}.`
82
77
  : ''
@@ -85,12 +80,12 @@ export function sectionMessages(facts, src, anchors = null) {
85
80
  numPredict: 500,
86
81
  messages: msgs(
87
82
  `${STYLE}\n\nФАЙЛ ${facts.relPath}:\n\`\`\`\n${src}\n\`\`\`\n\nВІДОМІ ФАКТИ:\n${factsTxt}${anch}`,
88
- `Напиши вміст секції «Поведінка»: ${behaviorTask}. Якщо у фактах є свідомі пропуски шляхів — згадай їх там, де доречно (не вигадуй інших «не перевіряє»). НЕ пиши аргументи функцій у дужках, без regex.${noInternal} Без заголовка, без додаткових ## чи # підзаголовків усередині секції.`
83
+ `Напиши вміст секції «Поведінка»: ${behaviorTask}.${onlyExports} Якщо у фактах є свідомі пропуски шляхів — згадай їх там, де доречно (не вигадуй інших «не перевіряє»). НЕ пиши аргументи функцій у дужках, без regex.${noInternal} Без заголовка, без додаткових ## чи # підзаголовків усередині секції.`
89
84
  )
90
85
  }
91
86
 
92
87
  // API — лише список експортів (без коду)
93
- if (!multi && !facts.exports?.some(e => e.desc)) return [overview, behavior]
88
+ if (!multi && !facts.exports?.some(e => e.desc)) return [behavior]
94
89
  const list = facts.exports.map(e => `- ${e.name}: ${e.desc || '(сформулюй стисло з наміру файлу)'}`).join('\n')
95
90
  const api = {
96
91
  key: 'api',
@@ -100,7 +95,24 @@ export function sectionMessages(facts, src, anchors = null) {
100
95
  `Перепиши цей список як стислі маркери «назва — що робить», СВОЇМИ словами (не копіюй дослівно), без типів і сигнатур. Використовуй РІВНО ці назви, не додавай і не прибирай:\n${list}\nБез заголовка. Без generic-фраз «застосовує логіку», «перевіряє коректність» — пиши конкретно ЩО саме застосовує/перевіряє.`
101
96
  )
102
97
  }
103
- return [overview, behavior, api]
98
+ return [behavior, api]
99
+ }
100
+
101
+ /**
102
+ * R3 — «Огляд» ОСТАННІМ: узагальнення вже написаної Поведінки, а не здогад із
103
+ * голого факт-листа. Лікує generic/хибний Огляд на складних файлах.
104
+ * @param {object} facts факт-лист про файл
105
+ * @param {string} behaviorText готовий текст секції «Поведінка»
106
+ * @param {object|null} [anchors] анкори файлу
107
+ * @returns {Array<{role:string,content:string}>} messages-масив для Огляду
108
+ */
109
+ export function overviewMessages(facts, behaviorText, anchors = null) {
110
+ const factsTxt = factsSummary(facts)
111
+ const anch = anchorsBlock(anchors)
112
+ return msgs(
113
+ `${STYLE}\n\nВІДОМІ ФАКТИ:\n${factsTxt}${anch}`,
114
+ `На основі вже написаної секції «Поведінка» (нижче) напиши «Огляд»: 1-3 речення — що файл робить і навіщо існує (роль у системі). Узагальнюй САМЕ описану поведінку, не додавай нових фактів. Без заголовка, без переліку функцій. Заборонені абстрактні формули без конкретики («перевірка/валідація/обробка даних», «відповідність контракту», «застосовує логіку») — пиши, ЩО саме і за яким контрактом.\n\nПОВЕДІНКА:\n${behaviorText}`
115
+ )
104
116
  }
105
117
 
106
118
  /**
@@ -0,0 +1,139 @@
1
+ /** @see ./docs/units-js.md */
2
+
3
+ import { parseProgramOrNull, walkAstWithAncestors } from '../../../scripts/utils/ast-scan-utils.mjs'
4
+
5
+ // JSDoc-блок, що стоїть впритул перед позицією (лише пробіли між ними).
6
+ const JSDOC_BEFORE_RE = /\/\*\*(?:(?!\*\/)[\s\S])*\*\/\s*$/
7
+ const JSDOC_OPEN_RE = /^\s*\/\*\*?/
8
+ const JSDOC_CLOSE_RE = /\*\/\s*$/
9
+ const STAR_PREFIX_RE = /^\s*\*?\s?/
10
+
11
+ /**
12
+ * Очищає JSDoc від обрамлення `/** *​/` і `*`-префіксів.
13
+ * @param {string} raw сирий блок або порожній рядок
14
+ * @returns {string} текст опису без тегів-обрамлення
15
+ */
16
+ function cleanDoc(raw) {
17
+ if (!raw) return ''
18
+ return raw
19
+ .replace(JSDOC_OPEN_RE, '')
20
+ .replace(JSDOC_CLOSE_RE, '')
21
+ .split('\n')
22
+ .map(l => l.replace(STAR_PREFIX_RE, '').trimEnd())
23
+ .join('\n')
24
+ .trim()
25
+ }
26
+
27
+ /**
28
+ * JSDoc, що передує позиції `start` у джерелі (або порожній рядок).
29
+ * @param {string} src вміст файлу
30
+ * @param {number} start зміщення початку декларації
31
+ * @returns {string} очищений опис
32
+ */
33
+ function precedingDoc(src, start) {
34
+ const m = src.slice(0, start).match(JSDOC_BEFORE_RE)
35
+ return cleanDoc(m ? m[0] : '')
36
+ }
37
+
38
+ /**
39
+ * Імʼя функції, що викликається (проста Identifier або `obj.method`).
40
+ * @param {Record<string, unknown>} node CallExpression
41
+ * @returns {string|null} імʼя callee або null
42
+ */
43
+ function calleeName(node) {
44
+ const c = node.callee
45
+ if (!c || typeof c !== 'object') return null
46
+ if (c.type === 'Identifier') return c.name
47
+ if (c.type === 'MemberExpression' && !c.computed && c.property?.type === 'Identifier') return c.property.name
48
+ return null
49
+ }
50
+
51
+ /**
52
+ * Множина імен, що викликаються у тілі вузла (сирі callee — фільтрація на ребра
53
+ * call-graph робиться у `extractUnitsJs` після збору всіх імен юнітів).
54
+ * @param {unknown} node AST-вузол юніта
55
+ * @returns {Set<string>} імена викликів
56
+ */
57
+ function collectCalls(node) {
58
+ const names = new Set()
59
+ walkAstWithAncestors(node, [], n => {
60
+ if (n.type === 'CallExpression') {
61
+ const name = calleeName(n)
62
+ if (name) names.add(name)
63
+ }
64
+ })
65
+ return names
66
+ }
67
+
68
+ /**
69
+ * Будує юніт із декларації, додає у `units`. Розпізнає function/class та
70
+ * const-функції (`const x = () => {}` / `function expression`).
71
+ * @param {Record<string, unknown>} decl декларація (function/class/variable)
72
+ * @param {boolean} exported чи експортується
73
+ * @param {number} docStart зміщення для пошуку JSDoc (зовнішній export-вузол)
74
+ * @param {string} src вміст файлу
75
+ * @param {Array<object>} units акумулятор
76
+ * @returns {void}
77
+ */
78
+ function pushUnits(decl, exported, docStart, src, units) {
79
+ if (!decl || typeof decl !== 'object') return
80
+ const doc = precedingDoc(src, docStart)
81
+ if (decl.type === 'FunctionDeclaration' || decl.type === 'ClassDeclaration') {
82
+ const name = decl.id?.name
83
+ if (!name) return
84
+ units.push({
85
+ name,
86
+ kind: decl.type === 'ClassDeclaration' ? 'class' : 'function',
87
+ exported,
88
+ span: { start: decl.start, end: decl.end },
89
+ body: src.slice(decl.start, decl.end),
90
+ calls: collectCalls(decl),
91
+ doc
92
+ })
93
+ return
94
+ }
95
+ if (decl.type === 'VariableDeclaration') {
96
+ for (const d of decl.declarations ?? []) {
97
+ const init = d.init
98
+ const isFn = init && (init.type === 'ArrowFunctionExpression' || init.type === 'FunctionExpression')
99
+ if (!isFn || d.id?.type !== 'Identifier') continue
100
+ units.push({
101
+ name: d.id.name,
102
+ kind: 'const',
103
+ exported,
104
+ span: { start: init.start, end: init.end },
105
+ body: src.slice(init.start, init.end),
106
+ calls: collectCalls(init),
107
+ doc
108
+ })
109
+ }
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Юніт-шар для js/mjs/ts: top-level функції/класи/const-функції з тілом, JSDoc,
115
+ * прапором експорту і ребрами call-graph (виклики ІНШИХ юнітів у тілі).
116
+ * @param {string} src вміст файлу
117
+ * @param {string} [relPath] шлях (для вибору мови oxc)
118
+ * @returns {Array<{name:string, kind:string, exported:boolean, span:{start:number,end:number}, body:string, calls:string[], doc:string}>|null} юніти або null, якщо файл не парситься
119
+ */
120
+ export function extractUnitsJs(src, relPath = 'scan.ts') {
121
+ const program = parseProgramOrNull(src, relPath)
122
+ if (!program || !Array.isArray(program.body)) return null
123
+
124
+ const units = []
125
+ for (const node of program.body) {
126
+ if (node.type === 'ExportNamedDeclaration' && node.declaration) {
127
+ pushUnits(node.declaration, true, node.start, src, units)
128
+ } else if (node.type === 'ExportDefaultDeclaration' && node.declaration) {
129
+ pushUnits(node.declaration, true, node.start, src, units)
130
+ } else {
131
+ pushUnits(node, false, node.start, src, units)
132
+ }
133
+ }
134
+
135
+ // Ребра call-graph: лишаємо тільки виклики інших внутрішніх юнітів
136
+ const names = new Set(units.map(u => u.name))
137
+ for (const u of units) u.calls = [...u.calls].filter(n => names.has(n) && n !== u.name)
138
+ return units
139
+ }
@@ -0,0 +1,19 @@
1
+ /** @see ./docs/units.md */
2
+
3
+ import { extractUnitsJs } from './units-js.mjs'
4
+
5
+ const JS_EXT = new Set(['js', 'mjs', 'ts', 'jsx', 'tsx', 'cts', 'mts'])
6
+
7
+ /**
8
+ * Мовно-агностичний фасад юніт-шару (Інкремент 1). Диспатчить за розширенням:
9
+ * js/mjs/ts → oxc; vue/py — додаються наступними кроками (поки `null` → виклик
10
+ * відкочується на whole-file шлях, як і раніше).
11
+ * @param {string} src вміст файлу
12
+ * @param {string} relPath шлях файлу
13
+ * @returns {Array<object>|null} юніти або null, якщо мова ще не підтримана / файл не парситься
14
+ */
15
+ export function extractUnits(src, relPath) {
16
+ const ext = (relPath.split('.').pop() || '').toLowerCase()
17
+ if (JS_EXT.has(ext)) return extractUnitsJs(src, relPath)
18
+ return null
19
+ }