@7n/rules-lang-js 0.7.1 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.9.0] - 2026-07-20
4
+
5
+ ### Added
6
+
7
+ - doc-files: Vue SFC-екстрактор (`.vue` через optional peer `vue/compiler-sfc`) — props/emits/exposed як псевдо-експорти, слоти з `@slot`-коментарів шаблону, юніти зі зміщеними у файл офсетами
8
+
9
+ ### Fixed
10
+
11
+ - doc-files: JSDoc-атрибуція експортів/юнітів через реальні AST-коментарі парсера (не regex по сирому тексту) — усуває false positive, коли '/**'-подібний текст трапляється всередині // -коментаря чи рядкового літералу
12
+
13
+ ## [0.8.0] - 2026-07-20
14
+
15
+ ### Added
16
+
17
+ - doc-files: Vue SFC-екстрактор (`<script setup>`) — extractFactsVue/extractUnitsVue через optional peer vue/compiler-sfc; props/emits/expose/слоти як публічний контракт, юніти зі span-корекцією (ADR 260719-2155)
18
+
3
19
  ## [0.7.1] - 2026-07-20
4
20
 
5
21
  ### Fixed
@@ -3,9 +3,8 @@ type: JS Module
3
3
  title: extractors.mjs
4
4
  resource: plugins/lang-js/doc-files/extractors.mjs
5
5
  docgen:
6
- crc: 5ba406e8
6
+ crc: ec2c4b59
7
7
  model: openai-codex/gpt-5.4-mini
8
- tier: cloud-min
9
8
  score: 100
10
9
  issues: judge:inaccurate:0.98
11
10
  judgeModel: openai-codex/gpt-5.4-mini
@@ -7,4 +7,6 @@ resource: plugins/lang-js/doc-files/
7
7
  | Файл | Тип |
8
8
  | ------------------------------- | --------- |
9
9
  | [extractors.mjs](extractors.md) | JS Module |
10
+ | [js-facts.mjs](js-facts.md) | JS Module |
10
11
  | [units-js.mjs](units-js.md) | JS Module |
12
+ | [vue.mjs](vue.md) | JS Module |
@@ -0,0 +1,47 @@
1
+ ---
2
+ type: JS Module
3
+ title: js-facts.mjs
4
+ resource: plugins/lang-js/doc-files/js-facts.mjs
5
+ docgen:
6
+ crc: c0e84691
7
+ model: openai-codex/gpt-5.4-mini
8
+ score: 100
9
+ issues: judge:inaccurate:0.98
10
+ judgeModel: openai-codex/gpt-5.4-mini
11
+ ---
12
+
13
+ ## Огляд
14
+
15
+ Модуль формує поведінковий профіль для `parseJsDoc`, `extractFileHeader`, `precedingJsDoc`, `extractExports`, `extractImports`, `extractInternalSymbols`, `extractLocalSymbols` і `extractMarkers`: зчитує JSDoc, заголовок файлу, імпорти, експорти, локальні та внутрішні символи, щоб описувати публічну поверхню й службові частини коду. Під час обходу свідомо пропускає `.github`, `.git`, `node_modules`, `base/`, `ua/` і `.firebase`. Модуль звертається до мережі, використовує кешування в межах одного прогону та за окремих помилок повертає порожнє значення, зокрема `null`, замість винятку.
16
+
17
+ ## Поведінка
18
+
19
+ - parseJsDoc — розбирає JSDoc у читабельний опис, окремо виділяє параметри й текст повернення.
20
+ - extractFileHeader — бере провідний блок-коментар файлу як намір, якщо він стоїть на початку до будь-якого коду чи import.
21
+ - precedingJsDoc — знаходить найближчий JSDoc-блок, що стоїть впритул перед потрібною позицією.
22
+ - extractExports — збирає експортовані оголошення разом із пов’язаним JSDoc, щоб описати публічну поверхню модуля.
23
+ - extractImports — розкладає імпорти на stdlib, npm та internal; внутрішні шляхи не змішує з зовнішніми.
24
+ - extractInternalSymbols — витягує імена символів із внутрішніх імпортів, щоб їх не подавати як зовнішній API.
25
+ - extractLocalSymbols — знаходить неекспортовані top-level функції й класи як службові елементи модуля.
26
+ - extractMarkers — визначає поведінкові ознаки коду, зокрема мережеві звернення, кешування, обробку помилок, читання-only та свідомі пропуски шляхів `.github`, `.git`, `node_modules`, `base/`, `ua/`, `.firebase`.
27
+
28
+ ## Публічний API
29
+
30
+ - parseJsDoc — Опис (без @-тегів) + параметри з `@param` як «name — опис».
31
+ - extractFileHeader — Провідний блок-коментар файлу (намір), якщо він перед першим import/кодом.
32
+ - precedingJsDoc — Блок-коментар, що стоїть ВПРИТУЛ перед позицією (лише пробіли між ними).
33
+ `(?:(?!\*​/)[\s\S])*` гарантує, що тіло не містить `*​/`, тож захоплюється рівно один
34
+ найближчий блок — без жадібного «перестрибування» через імпорти/код.
35
+ - extractExports — Експорти + JSDoc, що безпосередньо передує кожному.
36
+ - extractImports — Імпорти, класифіковані на stdlib / npm / internal.
37
+ - extractInternalSymbols — Імена символів, імпортованих із внутрішніх модулів — їх модель не має згадувати.
38
+ - extractLocalSymbols — Імена top-level функцій/класів, які НЕ експортуються (службові помічники).
39
+ Модель не має подавати їх як «публічні функції» у Поведінці/API (R6).
40
+ Const-стрілки свідомо не ловимо — менше false-positive на змістовних константах.
41
+ - extractMarkers — Поведінкові маркери — евристики регулярками.
42
+
43
+ ## Гарантії поведінки
44
+
45
+ - За певних помилок повертає порожнє значення (напр. `null`) замість винятку.
46
+ - Кешує результати в межах одного прогону.
47
+ - Свідомо пропускає шляхи: `.github`, `.git`, `node_modules`, `base/`, `ua/`, `.firebase`.
@@ -3,9 +3,8 @@ type: JS Module
3
3
  title: units-js.mjs
4
4
  resource: plugins/lang-js/doc-files/units-js.mjs
5
5
  docgen:
6
- crc: 9a13a64e
6
+ crc: d6ca02fb
7
7
  model: openai-codex/gpt-5.4-mini
8
- tier: cloud-min
9
8
  score: 100
10
9
  issues: judge:inaccurate:0.94
11
10
  judgeModel: openai-codex/gpt-5.4-mini
@@ -0,0 +1,34 @@
1
+ ---
2
+ type: JS Module
3
+ title: vue.mjs
4
+ resource: plugins/lang-js/doc-files/vue.mjs
5
+ docgen:
6
+ crc: 8651a381
7
+ model: openai-codex/gpt-5.4-mini
8
+ tier: cloud-min
9
+ score: 100
10
+ issues: judge:inaccurate:0.98
11
+ judgeModel: openai-codex/gpt-5.4-mini
12
+ ---
13
+
14
+ ## Огляд
15
+
16
+ `vueScriptBlock`, `extractFactsVue` і `extractUnitsVue` працюють із Vue SFC як з джерелом для подальшого витягування фактів і юнітів у координатах оригінального `.vue`. Вони підтримують fail-safe поведінку: перехоплюють помилки, не кидають винятків назовні та за окремих збоїв повертають порожнє значення, зокрема `null`, замість помилки.
17
+
18
+ ## Поведінка
19
+
20
+ - `vueScriptBlock` — розбирає `.vue` як SFC і повертає `script setup` або `script` блок із дескриптором; якщо peer `vue` відсутній, SFC битий або script-блоку немає, повертає порожній результат замість помилки.
21
+ - `extractFactsVue` — формує факт-лист для Vue SFC на основі `script`-блоку: виділяє публічний контракт компонента через props, emits, expose і slots, а також додає JS-факти з `script`; якщо `vue` недоступний, SFC битий або script-блоку немає, повертає `unsupported`.
22
+ - `extractUnitsVue` — витягує JS/TS-юніти зі `script`-блоку Vue-файла і переносить їхні span-позиції в координати оригінального `.vue`; якщо `vue` недоступний, SFC битий або script-блоку немає, повертає порожній результат.
23
+
24
+ ## Публічний API
25
+
26
+ - vueScriptBlock — Дістає script-блок із SFC, віддаючи пріоритет `<script setup>`, разом із дескриптором.
27
+ - extractFactsVue — Збирає публічний контракт Vue SFC: props, emits, expose і slots, а також повторно використовує JS-хелпери для header, imports і markers із тексту script-блоку; `<template>` і `<style>` у факти не потрапляють. Без peer `vue` або на битому SFC чи без script-блоку повертає `unsupported` у whole-file режимі, як до впровадження.
28
+ - extractUnitsVue — Будує JS-юніти з script-блоку `.vue` і зсуває span-и на позиції в оригінальному `.vue` файлі, щоб anchors і CRC вказували саме на вихідний SFC, а не на вирізаний фрагмент.
29
+
30
+ ## Гарантії поведінки
31
+
32
+ - Read-only: не виконує операцій запису (ФС/БД).
33
+ - Перехоплює помилки і не пропускає винятків назовні (fail-safe).
34
+ - За певних помилок повертає порожнє значення (напр. `null`) замість винятку.
@@ -1,5 +1,8 @@
1
1
  /** @see ./docs/extractors.md */
2
+ import { parseProgramAndCommentsOrNull } from '@7n/rules/scripts/utils/ast-scan-utils.mjs'
2
3
  import { extractUnitsJs } from './units-js.mjs'
4
+ import { extractFactsVue, extractUnitsVue } from './vue.mjs'
5
+ import { jsDocCommentBefore } from './js-facts.mjs'
3
6
 
4
7
  /**
5
8
  * Мовний doc-files-екстрактор JS-екосистеми для конвеєра `@7n/rules`
@@ -35,8 +38,14 @@ const JSDOC_CLOSE_RE = /\*\/\s*$/
35
38
  const STAR_PREFIX_RE = /^\s*\*?\s?/
36
39
  const PARAM_LINE_RE = /^@param[ \t]{1,8}(?:\{[^}]{0,200}\}[ \t]{1,8})?\[?([\w.]{1,80})\]?[ \t]{0,8}(.{0,400})$/
37
40
  const RETURNS_LINE_RE = /^@returns?[ \t]{1,8}(?:\{[^}]{0,200}\}[ \t]{1,8})?(.{0,400})$/
38
- const FILE_HEADER_RE = /^\s*\/\*\*([\s\S]*?)\*\//
39
- const PRECEDING_JSDOC_RE = /\/\*\*(?:(?!\*\/)[\s\S])*\*\/\s*$/
41
+ // `(?!\/)` одразу після відкриття — без нього glob-рядок на кшталт `'src/**/linux.rs'`
42
+ // (символи `/`,`*`,`*`,`/`) читається як порожній коментар-відкриття `/**/`, і жадібний
43
+ // пошук найближчого `*/` «протікає» аж до наступного РЕАЛЬНОГО закриття JSDoc, змішуючи
44
+ // код між ними у `desc`. Справжній JSDoc ніколи не має `/` одразу після `/**`. Regex-фолбек
45
+ // для випадків без `comments` від парсера (див. `jsDocCommentBefore` — надійніший шлях,
46
+ // коли парсинг вдався, бо AST уже коректно розрізняє справжні коментарі й `//`-текст).
47
+ const FILE_HEADER_RE = /^\s*\/\*\*(?!\/)([\s\S]*?)\*\//
48
+ const PRECEDING_JSDOC_RE = /\/\*\*(?!\/)(?:(?!\*\/)[\s\S])*\*\/\s*$/
40
49
  const EXPORT_DECL_RE = /export\s+(?:async\s+)?(function|const|class)\s+(\w+)/g
41
50
  // Top-level function/class декларації (колонка 0) — для R6: службові функції,
42
51
  // які не експортуються, не мають протікати у Поведінку/API як «публічні».
@@ -101,6 +110,63 @@ function cleanJsDoc(raw) {
101
110
  .trim()
102
111
  }
103
112
 
113
+ // Заголовок `\@param`/`\@returns` із незакритим на тому ж рядку типом (`\@param {{`
114
+ // на початку багаторядкового object-type). `.*` без `s`-прапора — навмисно: `l`
115
+ // уже без `\n` (рядки з `text.split('\n')`), тож `.` природно зупиняється на межі рядка.
116
+ const TAG_HEAD_RE = /^@(param|returns?)\b[ \t]*(\{.*)?$/
117
+
118
+ /**
119
+ * @param {string} s текст
120
+ * @param {string} ch односимвольний рядок для підрахунку
121
+ * @returns {number} кількість входжень `ch` у `s`
122
+ */
123
+ function countOccurrences(s, ch) {
124
+ return s.split(ch).length - 1
125
+ }
126
+
127
+ /**
128
+ * Просуває стан пропуску багаторядкового object-type (`\@param {{ ... }}`) на один
129
+ * рядок: рахує баланс дужок, і коли він сходиться в 0 — домальовує рядок як
130
+ * звичайний `\@param name опис`/`\@returns опис` (текст після останньої `}`).
131
+ * @param {{tag:'param'|'returns', depth:number}} braceSkip стан пропуску (мутується)
132
+ * @param {string} l поточний рядок
133
+ * @returns {{line:string|null}} `line:null` — рядок ще всередині типу (пропустити); інакше — реконструйований рядок
134
+ */
135
+ function advanceBraceSkip(braceSkip, l) {
136
+ braceSkip.depth += countOccurrences(l, '{') - countOccurrences(l, '}')
137
+ if (braceSkip.depth > 0) return { line: null }
138
+ return { line: `@${braceSkip.tag} ${l.slice(l.lastIndexOf('}') + 1).trim()}` }
139
+ }
140
+
141
+ /**
142
+ * Виявляє старт багаторядкового `\@param {{`/`\@returns {{` (тип не закрився на
143
+ * цьому ж рядку — більше `{`, ніж `}`).
144
+ * @param {string} l поточний рядок
145
+ * @returns {{tag:'param'|'returns', depth:number}|null} стан пропуску або null, якщо не старт
146
+ */
147
+ function detectMultilineTagStart(l) {
148
+ const tagHead = l.match(TAG_HEAD_RE)
149
+ if (!tagHead?.[2]) return null
150
+ const opens = countOccurrences(tagHead[2], '{')
151
+ const closes = countOccurrences(tagHead[2], '}')
152
+ if (opens <= closes) return null
153
+ return { tag: tagHead[1].startsWith('return') ? 'returns' : 'param', depth: opens - closes }
154
+ }
155
+
156
+ /**
157
+ * Дописує continuation-рядок (обгорнутий хвіст) до відповідного \@param/\@returns.
158
+ * @param {'returns'|{kind:'param', idx:number}} continuation активний тег
159
+ * @param {Array<{name:string, desc:string}>} params накопичені параметри (мутуються)
160
+ * @param {string} ret поточний текст `@returns`
161
+ * @param {string} tail новий текст для дописування
162
+ * @returns {string} оновлений `ret` (для `returns`; для `param` — вхідний `ret` без змін)
163
+ */
164
+ function appendContinuation(continuation, params, ret, tail) {
165
+ if (continuation === 'returns') return `${ret} ${tail}`.trim()
166
+ params[continuation.idx].desc = `${params[continuation.idx].desc} ${tail}`.trim()
167
+ return ret
168
+ }
169
+
104
170
  /**
105
171
  * Опис (без @-тегів) + параметри з `@param` як «name — опис».
106
172
  * @param {string} raw сирий JSDoc-блок
@@ -112,20 +178,52 @@ function parseJsDoc(raw) {
112
178
  const descLines = []
113
179
  const params = []
114
180
  let ret = ''
115
- for (const l of lines) {
181
+ // Рядок без `@` на початку — це або (до першого тегу) частина `desc`, або (після
182
+ // @param/@returns) обгорнутий на новий рядок «хвіст» ЦЬОГО тегу. Без відстеження
183
+ // continuation такий хвіст мовчки падав у `descLines`, змішуючи текст @returns/
184
+ // @param у загальний опис (напр. довге `@returns` на 2 рядки).
185
+ let continuation = null // null | 'desc' | 'returns' | { kind: 'param', idx: number }
186
+ // Багаторядковий `@param {{ ... }}`/`@returns {{ ... }}` (складний object-type,
187
+ // не закритий на тому ж рядку): тіло типу пропускаємо (не тягнемо в desc/params/ret),
188
+ // рахуючи баланс дужок по рядках через `advanceBraceSkip`.
189
+ let braceSkip = null // null | { tag: 'param'|'returns', depth: number }
190
+ for (const rawLine of lines) {
191
+ let l = rawLine
192
+ if (braceSkip) {
193
+ const advanced = advanceBraceSkip(braceSkip, l)
194
+ if (advanced.line === null) continue
195
+ l = advanced.line
196
+ braceSkip = null
197
+ }
116
198
  const pm = l.match(PARAM_LINE_RE)
117
199
  if (pm) {
118
200
  const desc = pm[2].trim()
119
201
  // «опис.» — JSDoc-заглушка без сенсу; не тягнемо її як факт
120
202
  params.push({ name: pm[1], desc: desc === 'опис.' ? '' : desc })
203
+ continuation = { kind: 'param', idx: params.length - 1 }
121
204
  continue
122
205
  }
123
206
  const rm = l.match(RETURNS_LINE_RE)
124
207
  if (rm) {
125
208
  ret = rm[1].trim()
209
+ continuation = 'returns'
210
+ continue
211
+ }
212
+ const multilineStart = detectMultilineTagStart(l)
213
+ if (multilineStart) {
214
+ braceSkip = multilineStart
215
+ continuation = null
216
+ continue
217
+ }
218
+ if (l.startsWith('@')) {
219
+ continuation = null // невідомий/непідтримуваний тег — не продовжуємо в нього
220
+ continue
221
+ }
222
+ if (continuation && continuation !== 'desc' && l.trim()) {
223
+ ret = appendContinuation(continuation, params, ret, l.trim())
126
224
  continue
127
225
  }
128
- if (l.startsWith('@')) continue
226
+ continuation = 'desc'
129
227
  descLines.push(l)
130
228
  }
131
229
  return { desc: descLines.join('\n').trim(), params, ret }
@@ -133,10 +231,22 @@ function parseJsDoc(raw) {
133
231
 
134
232
  /**
135
233
  * Провідний блок-коментар файлу (намір), якщо він перед першим import/кодом.
234
+ * `comments` (з парсера, `parseProgramAndCommentsOrNull`) — точний шлях: перший
235
+ * коментар файлу має бути саме ним. Без `comments` (парсинг не вдався, або
236
+ * виклик над фрагментом без AST — напр. Vue script-блок через `VUE_HELPERS`)
237
+ * — regex-фолбек на сирому тексті.
136
238
  * @param {string} src вміст файлу
239
+ * @param {Array<{type:string, value:string, start:number, end:number}>|null} [comments] список коментарів парсера або null
137
240
  * @returns {string} текст header-коментаря або порожній рядок
138
241
  */
139
- function extractFileHeader(src) {
242
+ function extractFileHeader(src, comments = null) {
243
+ if (comments) {
244
+ const first = comments[0]
245
+ const isLeadingJsDoc = first?.type === 'Block' && first.value.startsWith('*')
246
+ if (isLeadingJsDoc && src.slice(0, first.start).trim() === '')
247
+ return parseJsDoc(src.slice(first.start, first.end)).desc
248
+ return ''
249
+ }
140
250
  const m = src.match(FILE_HEADER_RE)
141
251
  if (!m) return ''
142
252
  // має бути на самому початку (до import/код)
@@ -146,8 +256,11 @@ function extractFileHeader(src) {
146
256
 
147
257
  /**
148
258
  * Блок-коментар, що стоїть ВПРИТУЛ перед позицією (лише пробіли між ними).
149
- * `(?:(?!\*​/)[\s\S])*` гарантує, що тіло не містить `*​/`, тож захоплюється рівно один
150
- * найближчий блок без жадібного «перестрибування» через імпорти/код.
259
+ * Regex-фолбек для випадків без `comments` від парсера (див. `jsDocCommentBefore`
260
+ * надійніший AST-based шлях, коли парсинг вдався). `(?:(?!\*​/)[\s\S])*` гарантує,
261
+ * що тіло не містить `*​/`, тож захоплюється рівно один найближчий блок — без
262
+ * жадібного «перестрибування» через імпорти/код (окрім залишкового класу false
263
+ * positive усередині `//`-коментарів, який і закриває `jsDocCommentBefore`).
151
264
  * @param {string} prefix вміст файлу до позиції експорту
152
265
  * @returns {string|null} JSDoc-блок або null якщо немає
153
266
  */
@@ -157,15 +270,18 @@ function precedingJsDoc(prefix) {
157
270
  }
158
271
 
159
272
  /**
160
- * Експорти + JSDoc, що безпосередньо передує кожному.
273
+ * Експорти + JSDoc, що безпосередньо передує кожному. З `comments` (парсер) —
274
+ * точна AST-based атрибуція (`jsDocCommentBefore`); без них (парсинг не вдався,
275
+ * або виклик над фрагментом без AST) — regex-фолбек (`precedingJsDoc`).
161
276
  * @param {string} src вміст файлу
277
+ * @param {Array<{type:string, value:string, start:number, end:number}>|null} [comments] список коментарів парсера або null
162
278
  * @returns {Array<object>} список експортів із метаданими
163
279
  */
164
- function extractExports(src) {
280
+ function extractExports(src, comments = null) {
165
281
  const out = []
166
282
  for (const m of src.matchAll(EXPORT_DECL_RE)) {
167
283
  const [, kind, name] = m
168
- const jsdocRaw = precedingJsDoc(src.slice(0, m.index))
284
+ const jsdocRaw = comments ? jsDocCommentBefore(comments, src, m.index) : precedingJsDoc(src.slice(0, m.index))
169
285
  out.push({ name, kind, ...(jsdocRaw ? parseJsDoc(jsdocRaw) : { desc: '', params: [], ret: '' }) })
170
286
  }
171
287
  return out
@@ -249,22 +365,42 @@ function extractMarkers(src) {
249
365
  skips: [...skips]
250
366
  }
251
367
  }
368
+ /** JS-хелпери, які Vue-екстрактор переюзає над текстом script-блоку SFC. */
369
+ const VUE_HELPERS = {
370
+ extractFileHeader,
371
+ extractExports,
372
+ extractImports,
373
+ extractInternalSymbols,
374
+ extractLocalSymbols,
375
+ extractMarkers,
376
+ parseJsDoc
377
+ }
378
+
252
379
  /**
253
380
  * Головний екстрактор: код файлу → факт-лист.
381
+ * Коментарі беруться з реального AST-парсера (`parseProgramAndCommentsOrNull`),
382
+ * не regex по сирому тексту — усуває клас false positive, де "/**"-подібний
383
+ * текст усередині `//`-коментаря чи рядкового літералу (напр. glob-патерн)
384
+ * помилково читається як відкриття JSDoc. Парсинг не вдався (синтаксична
385
+ * помилка) → `comments: null`, `extractFileHeader`/`extractExports` падають
386
+ * назад на свій regex-шлях (той самий, що й до цієї зміни).
254
387
  * @param {string} src вміст файлу
255
388
  * @param {string} relPath шлях (для контексту/мови екстрактора)
256
389
  * @returns {{relPath:string, lang:string, header:string, exports:Array, imports:object, markers:object}} структура фактів про файл
257
390
  */
258
391
  export function extractFacts(src, relPath) {
259
392
  const lang = relPath.split('.').pop()
393
+ if (lang === 'vue') return extractFactsVue(src, relPath, VUE_HELPERS)
260
394
  if (!['js', 'mjs', 'ts'].includes(lang)) {
261
395
  return { relPath, lang, unsupported: true, header: '', exports: [], imports: {}, markers: {} }
262
396
  }
397
+ const parsed = parseProgramAndCommentsOrNull(src, relPath)
398
+ const comments = parsed?.comments ?? null
263
399
  return {
264
400
  relPath,
265
401
  lang,
266
- header: extractFileHeader(src),
267
- exports: extractExports(src),
402
+ header: extractFileHeader(src, comments),
403
+ exports: extractExports(src, comments),
268
404
  imports: extractImports(src),
269
405
  internalSymbols: extractInternalSymbols(src),
270
406
  localSymbols: extractLocalSymbols(src),
@@ -272,17 +408,29 @@ export function extractFacts(src, relPath) {
272
408
  }
273
409
  }
274
410
 
411
+ /**
412
+ * Юніти: `.vue` — зі script-блоку SFC (span-и в координатах файлу), решта — oxc AST.
413
+ * @param {string} src вміст файлу
414
+ * @param {string} relPath шлях (вибір мови)
415
+ * @returns {Array<object>|null} юніти або null
416
+ */
417
+ function extractUnits(src, relPath) {
418
+ if (relPath.toLowerCase().endsWith('.vue')) return extractUnitsVue(src, relPath, extractUnitsJs)
419
+ return extractUnitsJs(src, relPath)
420
+ }
421
+
275
422
  /**
276
423
  * Default-експорт для handler-модуля extension-point `doc-files`.
277
- * `.vue` у списку розширень (файл кандидат на доку), але `extractFacts` для нього
278
- * повертає `unsupported` генерація йде whole-file шляхом, як і до винесення.
279
- * @type {{ id: string, extensions: string[], extractFacts: typeof extractFacts, extractUnits: typeof extractUnitsJs }}
424
+ * `.vue` (SFC зі `<script setup>`) парситься через optional peer `vue/compiler-sfc`
425
+ * (ADR 260719-2155): props/emits/expose/слоти факт-лист; без peer чи script-блоку
426
+ * fallback `unsupported` (whole-file шлях, як до впровадження).
427
+ * @type {{ id: string, extensions: string[], extractFacts: typeof extractFacts, extractUnits: typeof extractUnits }}
280
428
  */
281
429
  const jsDocFilesExtractor = {
282
430
  id: 'js',
283
431
  extensions: ['.js', '.mjs', '.ts', '.vue'],
284
432
  extractFacts,
285
- extractUnits: extractUnitsJs
433
+ extractUnits
286
434
  }
287
435
 
288
436
  export default jsDocFilesExtractor
@@ -0,0 +1,28 @@
1
+ /** @see ./docs/js-facts.md */
2
+
3
+ /**
4
+ * JSDoc-коментар (Block, `/** ... *​/`), що стоїть ВПРИТУЛ перед позицією (лише
5
+ * пробіли між ними) — з реального списку коментарів парсера (`comments` від
6
+ * `parseProgramAndCommentsOrNull`), не regex по сирому тексту. Спільна для
7
+ * `extractors.mjs` (експорти js/mjs/ts) і `units-js.mjs` (юніти) — усуває клас
8
+ * false positive, де "/**"-подібний текст трапляється всередині `//`-коментаря
9
+ * чи рядкового літералу (напр. glob `'src/**​/x.rs'` чи `// приклад: /** ... *​/`)
10
+ * — токенізатор там уже коректно визначив межі справжніх коментарів, а
11
+ * regex-сканер такого тексту не бачить окремо і жадібно «протікає» до
12
+ * наступного реального `*​/`, змішуючи проміжний код в опис. Винесена в окремий
13
+ * модуль (без залежностей на `extractors.mjs`/`units-js.mjs`), щоб обидва могли
14
+ * її імпортувати без циклічного імпорту між собою.
15
+ * @param {Array<{type:string, value:string, start:number, end:number}>} comments список коментарів парсера (у порядку файлу)
16
+ * @param {string} src вміст файлу (для перевірки, що проміжок — лише пробіли)
17
+ * @param {number} pos позиція, перед якою шукаємо коментар
18
+ * @returns {string|null} дослівний `/** ... *​/`-текст або null, якщо немає
19
+ */
20
+ export function jsDocCommentBefore(comments, src, pos) {
21
+ let best = null
22
+ for (const c of comments) {
23
+ if (c.type !== 'Block' || !c.value.startsWith('*') || c.end > pos) continue
24
+ if (!best || c.end > best.end) best = c
25
+ }
26
+ if (!best || src.slice(best.end, pos).trim() !== '') return null
27
+ return src.slice(best.start, best.end)
28
+ }
@@ -1,9 +1,15 @@
1
1
  /** @see ./docs/units-js.md */
2
2
 
3
- import { parseProgramOrNull, walkAstWithAncestors } from '@7n/rules/scripts/utils/ast-scan-utils.mjs'
3
+ import { parseProgramAndCommentsOrNull, walkAstWithAncestors } from '@7n/rules/scripts/utils/ast-scan-utils.mjs'
4
+ import { jsDocCommentBefore } from './js-facts.mjs'
4
5
 
5
- // JSDoc-блок, що стоїть впритул перед позицією (лише пробіли між ними).
6
- const JSDOC_BEFORE_RE = /\/\*\*(?:(?!\*\/)[\s\S])*\*\/\s*$/
6
+ // Regex-фолбек для випадків без `comments` від парсера (парсинг не вдався
7
+ // не мало б статися тут, бо `extractUnitsJs` і так вимагає успішний `program`,
8
+ // але `parsed.comments` теоретично може бути порожнім масивом на дивному вході).
9
+ // `(?!\/)` одразу після відкриття — без нього glob-рядок `'src/**/linux.rs'`
10
+ // читається як порожній `/**/`, і жадібний пошук найближчого `*/` протікає до
11
+ // наступного реального закриття JSDoc, змішуючи проміжний код у витягнутий опис.
12
+ const JSDOC_BEFORE_RE = /\/\*\*(?!\/)(?:(?!\*\/)[\s\S])*\*\/\s*$/
7
13
  const JSDOC_OPEN_RE = /^\s*\/\*\*?/
8
14
  const JSDOC_CLOSE_RE = /\*\/\s*$/
9
15
  const STAR_PREFIX_RE = /^\s*\*?\s?/
@@ -25,12 +31,19 @@ function cleanDoc(raw) {
25
31
  }
26
32
 
27
33
  /**
28
- * JSDoc, що передує позиції `start` у джерелі (або порожній рядок).
34
+ * JSDoc, що передує позиції `start` у джерелі (або порожній рядок). З `comments`
35
+ * (реальний список від парсера) — точна AST-based атрибуція через
36
+ * `jsDocCommentBefore` (js-facts.mjs): усуває клас false positive, де
37
+ * "/**"-подібний текст усередині `//`-коментаря чи рядкового літералу (напр.
38
+ * glob-патерн) помилково читається regex-ом як відкриття JSDoc. Порожній
39
+ * `comments` (немає жодного коментаря в файлі) — фолбек на `JSDOC_BEFORE_RE`.
29
40
  * @param {string} src вміст файлу
30
41
  * @param {number} start зміщення початку декларації
42
+ * @param {Array<{type:string, value:string, start:number, end:number}>} comments список коментарів парсера
31
43
  * @returns {string} очищений опис
32
44
  */
33
- function precedingDoc(src, start) {
45
+ function precedingDoc(src, start, comments) {
46
+ if (comments.length) return cleanDoc(jsDocCommentBefore(comments, src, start))
34
47
  const m = src.slice(0, start).match(JSDOC_BEFORE_RE)
35
48
  return cleanDoc(m ? m[0] : '')
36
49
  }
@@ -75,11 +88,12 @@ function collectCalls(node) {
75
88
  * @param {number} docStart зміщення для пошуку JSDoc (зовнішній export-вузол)
76
89
  * @param {string} src вміст файлу
77
90
  * @param {Array<object>} units акумулятор
91
+ * @param {Array<{type:string, value:string, start:number, end:number}>} comments список коментарів парсера
78
92
  * @returns {void}
79
93
  */
80
- function pushUnits(decl, exported, docStart, src, units) {
94
+ function pushUnits(decl, exported, docStart, src, units, comments) {
81
95
  if (!decl || typeof decl !== 'object') return
82
- const doc = precedingDoc(src, docStart)
96
+ const doc = precedingDoc(src, docStart, comments)
83
97
  if (decl.type === 'FunctionDeclaration' || decl.type === 'ClassDeclaration') {
84
98
  const name = decl.id?.name
85
99
  if (!name) return
@@ -120,17 +134,19 @@ function pushUnits(decl, exported, docStart, src, units) {
120
134
  * @returns {Array<{name:string, kind:string, exported:boolean, span:{start:number,end:number}, body:string, calls:string[], doc:string}>|null} юніти або null, якщо файл не парситься
121
135
  */
122
136
  export function extractUnitsJs(src, relPath = 'scan.ts') {
123
- const program = parseProgramOrNull(src, relPath)
137
+ const parsed = parseProgramAndCommentsOrNull(src, relPath)
138
+ const program = parsed?.program
124
139
  if (!program || !Array.isArray(program.body)) return null
140
+ const comments = parsed.comments
125
141
 
126
142
  const units = []
127
143
  for (const node of program.body) {
128
144
  const isExport =
129
145
  (node.type === 'ExportNamedDeclaration' || node.type === 'ExportDefaultDeclaration') && node.declaration
130
146
  if (isExport) {
131
- pushUnits(node.declaration, true, node.start, src, units)
147
+ pushUnits(node.declaration, true, node.start, src, units, comments)
132
148
  } else {
133
- pushUnits(node, false, node.start, src, units)
149
+ pushUnits(node, false, node.start, src, units, comments)
134
150
  }
135
151
  }
136
152
 
@@ -0,0 +1,215 @@
1
+ /** @see ./docs/vue.md */
2
+
3
+ // Optional peer `vue`: компілятор резолвиться один раз при завантаженні модуля.
4
+ // Свідомо НЕ статичний import — без установленого peer модуль має лишитися
5
+ // робочим (extractFactsVue → unsupported), а не завалити весь handler lang-js
6
+ // (catch у loadDocFilesExtractors мовчки прибрав би і JS/TS-екстрактор).
7
+ let compilerSfc = null
8
+ try {
9
+ compilerSfc = await import('vue/compiler-sfc')
10
+ } catch {
11
+ /* peer `vue` не встановлено — .vue лишається unsupported (whole-file шлях) */
12
+ }
13
+
14
+ const QUOTED_NAME_RE = /'([^']{1,80})'|"([^"]{1,80})"/g
15
+ // Object-style defineEmits<{ save: [id: number] }> — ключ вимагає tuple-значення
16
+ // (`:\s*[`), щоб не ловити label-и всередині tuple (`[id: number]`) як імена подій.
17
+ const EMIT_OBJECT_KEY_RE = /([\w$-]{1,80})\s*:\s*\[/g
18
+ const EMITS_GENERIC_RE = /defineEmits<([^>]{1,2000})>/
19
+ const EMITS_ARRAY_RE = /defineEmits\(\s*\[([^\]]{0,2000})\]/
20
+ const EXPOSE_CALL_RE = /defineExpose\s*\(\s*\{([^()]{0,2000})\}\s*\)/
21
+ // Провідний ідентифікатор одного елемента defineExpose (`focus`, `...rest`, `b: c`).
22
+ const EXPOSE_ITEM_RE = /^(?:\.{3})?([\w$]{1,80})/
23
+ // HTML-коментар template: [^>] гарантує зупинку на першому `>` (кінець `-->`).
24
+ const HTML_COMMENT_RE = /<!--([^>]{0,300})>/g
25
+ const SLOT_NAME_RE = /^[\w-]{1,80}/
26
+ const SLOT_LEAD_MARK_RE = /^[:—-]{1,3}/
27
+ // Канонічний патерн JSDoc-блоку без зворотного перебору.
28
+ const JSDOC_BLOCK_RE = /\/\*\*[^*]*(?:\*(?!\/)[^*]*)*\*\//g
29
+ const WORD_RE = /[\w$-]{1,80}/g
30
+ // Модифікатори/ключові слова, які стоять між JSDoc-блоком і власне іменем
31
+ // (декларації, `(e: '…'`-префікс сигнатури emit-події).
32
+ const SKIP_WORDS = new Set(['readonly', 'export', 'async', 'function', 'const', 'class', 'e'])
33
+
34
+ /**
35
+ * Порожній факт-лист unsupported-фолбеку (peer відсутній / битий SFC / без script).
36
+ * @param {string} relPath шлях файлу
37
+ * @returns {object} факт-лист із `unsupported: true`
38
+ */
39
+ function unsupportedFacts(relPath) {
40
+ return { relPath, lang: 'vue', unsupported: true, header: '', exports: [], imports: {}, markers: {} }
41
+ }
42
+
43
+ /**
44
+ * Розбирає SFC і повертає script-блок (`<script setup>` пріоритетно) з дескриптором.
45
+ * @param {string} src вміст `.vue` файлу
46
+ * @param {string} relPath шлях (filename для компілятора)
47
+ * @returns {{ block: object, descriptor: object }|null} блок+дескриптор або null (нема компілятора / битий SFC / нема script)
48
+ */
49
+ export function vueScriptBlock(src, relPath) {
50
+ if (!compilerSfc) return null
51
+ let descriptor
52
+ try {
53
+ ;({ descriptor } = compilerSfc.parse(src, { filename: relPath }))
54
+ } catch {
55
+ return null
56
+ }
57
+ const block = descriptor.scriptSetup ?? descriptor.script
58
+ if (!block?.content?.trim()) return null
59
+ return { block, descriptor }
60
+ }
61
+
62
+ /**
63
+ * Мапа «імʼя → JSDoc-опис» для всього script-блоку: кожен JSDoc-блок
64
+ * привʼязується до першого ідентифікатора одразу після нього (interface-member,
65
+ * ключ обʼєкта, декларація function/const для defineExpose-shorthand, сигнатура
66
+ * emit-події). Одна статична регулярка замість динамічних per-name.
67
+ * @param {string} content текст script-блоку
68
+ * @param {(raw: string) => {desc: string}} parseJsDoc парсер JSDoc з extractors
69
+ * @returns {Map<string, string>} імʼя → опис
70
+ */
71
+ function jsDocMap(content, parseJsDoc) {
72
+ const map = new Map()
73
+ for (const m of content.matchAll(JSDOC_BLOCK_RE)) {
74
+ const after = content.slice(m.index + m[0].length, m.index + m[0].length + 120)
75
+ for (const w of after.matchAll(WORD_RE)) {
76
+ if (SKIP_WORDS.has(w[0])) continue
77
+ if (!map.has(w[0])) map.set(w[0], parseJsDoc(m[0]).desc)
78
+ break
79
+ }
80
+ }
81
+ return map
82
+ }
83
+
84
+ /**
85
+ * Імена props через `compileScript().bindings` — канонічний резолв і обʼєктної
86
+ * форми, і generic `defineProps<Props>()` з interface у тому ж блоці. Якщо
87
+ * компіляція впала (битий TS/макрос) — props не витягуються (порожній список).
88
+ * @param {object} descriptor SFC-дескриптор
89
+ * @param {string} relPath шлях (id для compileScript)
90
+ * @returns {string[]} імена props
91
+ */
92
+ function vuePropNames(descriptor, relPath) {
93
+ try {
94
+ const compiled = compilerSfc.compileScript(descriptor, { id: relPath })
95
+ return Object.entries(compiled.bindings ?? {})
96
+ .filter(([, type]) => type === 'props')
97
+ .map(([name]) => name)
98
+ } catch {
99
+ return []
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Імена подій із defineEmits: масив-форма (`['save']`), function-signature
105
+ * generic (`(e: 'save'): void`) — лапковані літерали; object-style generic
106
+ * (`{ save: [id: number] }`) — ключі з tuple-значенням.
107
+ * @param {string} content текст script-блоку
108
+ * @returns {string[]} імена подій
109
+ */
110
+ function vueEmitNames(content) {
111
+ const m = content.match(EMITS_GENERIC_RE) ?? content.match(EMITS_ARRAY_RE)
112
+ if (!m) return []
113
+ const body = m[1]
114
+ const quoted = Array.from(body.matchAll(QUOTED_NAME_RE), q => q[1] ?? q[2])
115
+ if (quoted.length) return [...new Set(quoted)]
116
+ return [...new Set(Array.from(body.matchAll(EMIT_OBJECT_KEY_RE), k => k[1]))]
117
+ }
118
+
119
+ /**
120
+ * Імена публічно виставлених через defineExpose полів (обʼєктна форма).
121
+ * @param {string} content текст script-блоку
122
+ * @returns {string[]} імена exposed-полів
123
+ */
124
+ function vueExposeNames(content) {
125
+ const m = content.match(EXPOSE_CALL_RE)
126
+ if (!m) return []
127
+ const names = m[1]
128
+ .split(',')
129
+ .map(item => item.trim().match(EXPOSE_ITEM_RE)?.[1])
130
+ .filter(Boolean)
131
+ return [...new Set(names)]
132
+ }
133
+
134
+ /**
135
+ * Слоти з маркер-коментарів slot у template (імʼя + опційний опис).
136
+ * Розбір двокроковий: спершу HTML-коментарі однією простою регуляркою,
137
+ * далі — плоский JS-парсинг тексту (без складних патернів).
138
+ * @param {string} template текст template-блоку
139
+ * @returns {Array<{name: string, desc: string}>} слоти
140
+ */
141
+ function vueSlots(template) {
142
+ const slots = []
143
+ for (const m of template.matchAll(HTML_COMMENT_RE)) {
144
+ // m[1] — вміст до першого `>`, тобто разом із хвостовим `--` від `-->`
145
+ const text = m[1].endsWith('--') ? m[1].slice(0, -2).trim() : m[1].trim()
146
+ if (!text.startsWith('@slot')) continue
147
+ const rest = text.slice('@slot'.length).trim()
148
+ const name = rest.match(SLOT_NAME_RE)?.[0]
149
+ if (!name) continue
150
+ const desc = rest.slice(name.length).trim().replace(SLOT_LEAD_MARK_RE, '').trim()
151
+ slots.push({ name, desc })
152
+ }
153
+ return slots
154
+ }
155
+
156
+ /**
157
+ * Факт-лист для Vue SFC (`<script setup>` пріоритетно): props/emits/expose/слоти
158
+ * як публічний контракт компонента + переюз JS-хелперів (header, imports,
159
+ * markers) над текстом script-блоку — `<template>`/`<style>` у факти не течуть.
160
+ * Без peer `vue`, на битому SFC чи без script-блоку — `unsupported` (whole-file
161
+ * шлях, як до впровадження).
162
+ * @param {string} src вміст `.vue` файлу
163
+ * @param {string} relPath шлях файлу
164
+ * @param {object} h JS-хелпери з extractors (extractFileHeader, extractExports, extractImports, extractInternalSymbols, extractLocalSymbols, extractMarkers, parseJsDoc)
165
+ * @returns {object} факт-лист (`lang: 'vue'`)
166
+ */
167
+ export function extractFactsVue(src, relPath, h) {
168
+ const sb = vueScriptBlock(src, relPath)
169
+ if (!sb) return unsupportedFacts(relPath)
170
+ const { block, descriptor } = sb
171
+ const content = block.content
172
+ const docs = jsDocMap(content, h.parseJsDoc)
173
+ const entry = kind => name => ({ name, kind, desc: docs.get(name) ?? '', params: [], ret: '' })
174
+
175
+ return {
176
+ relPath,
177
+ lang: 'vue',
178
+ header: h.extractFileHeader(content),
179
+ exports: [
180
+ ...vuePropNames(descriptor, relPath).map(entry('prop')),
181
+ ...vueEmitNames(content).map(entry('emit')),
182
+ ...vueExposeNames(content).map(entry('expose')),
183
+ ...h.extractExports(content)
184
+ ],
185
+ slots: vueSlots(descriptor.template?.content ?? ''),
186
+ imports: h.extractImports(content),
187
+ internalSymbols: h.extractInternalSymbols(content),
188
+ localSymbols: h.extractLocalSymbols(content),
189
+ markers: h.extractMarkers(content)
190
+ }
191
+ }
192
+
193
+ /**
194
+ * Юніт-шар для `.vue`: JS-юніти зі script-блоку з корекцією span-ів на offset
195
+ * блоку в файлі — anchors/CRC мають вказувати на позиції ОРИГІНАЛЬНОГО `.vue`,
196
+ * а не script-фрагмента.
197
+ * @param {string} src вміст `.vue` файлу
198
+ * @param {string} relPath шлях файлу
199
+ * @param {(src: string, relPath: string) => Array<object>|null} unitsJs екстрактор юнітів js/ts
200
+ * @returns {Array<object>|null} юніти або null (нема компілятора / script / не парситься)
201
+ */
202
+ export function extractUnitsVue(src, relPath, unitsJs) {
203
+ const sb = vueScriptBlock(src, relPath)
204
+ if (!sb) return null
205
+ const { block } = sb
206
+ // relPath завжди закінчується на .vue (диспетчер викликає лише для нього)
207
+ const pseudoPath = relPath.slice(0, -'.vue'.length) + (block.lang === 'ts' ? '.ts' : '.js')
208
+ const units = unitsJs(block.content, pseudoPath)
209
+ if (!units) return null
210
+ const offset = block.loc.start.offset
211
+ for (const u of units) {
212
+ u.span = { start: u.span.start + offset, end: u.span.end + offset }
213
+ }
214
+ return units
215
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@7n/rules-lang-js",
3
- "version": "0.7.1",
3
+ "version": "0.9.0",
4
4
  "description": "Плагін @7n/rules: JS/npm/bun-екосистема — lint-правила (js/bun/vue/js-run/npm-module/db), taze-провайдер (package.json, bunx taze) і doc-files-екстрактори (oxc AST)",
5
5
  "keywords": [
6
6
  "javascript",
@@ -62,7 +62,8 @@
62
62
  "stylelint": "^17.6.0"
63
63
  },
64
64
  "peerDependencies": {
65
- "@7n/rules": ">=1.27.0"
65
+ "@7n/rules": ">=1.27.0",
66
+ "vue": "^3.0.0"
66
67
  },
67
68
  "publishConfig": {
68
69
  "access": "public"
@@ -70,5 +71,10 @@
70
71
  "engines": {
71
72
  "bun": ">=1.3",
72
73
  "node": ">=24"
74
+ },
75
+ "peerDependenciesMeta": {
76
+ "vue": {
77
+ "optional": true
78
+ }
73
79
  }
74
80
  }