@7n/rules-lang-js 0.7.1 → 0.8.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,11 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.8.0] - 2026-07-20
4
+
5
+ ### Added
6
+
7
+ - doc-files: Vue SFC-екстрактор (`<script setup>`) — extractFactsVue/extractUnitsVue через optional peer vue/compiler-sfc; props/emits/expose/слоти як публічний контракт, юніти зі span-корекцією (ADR 260719-2155)
8
+
3
9
  ## [0.7.1] - 2026-07-20
4
10
 
5
11
  ### 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: 0519b5be
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
@@ -8,3 +8,4 @@ resource: plugins/lang-js/doc-files/
8
8
  | ------------------------------- | --------- |
9
9
  | [extractors.mjs](extractors.md) | JS Module |
10
10
  | [units-js.mjs](units-js.md) | JS Module |
11
+ | [vue.mjs](vue.md) | JS Module |
@@ -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,6 @@
1
1
  /** @see ./docs/extractors.md */
2
2
  import { extractUnitsJs } from './units-js.mjs'
3
+ import { extractFactsVue, extractUnitsVue } from './vue.mjs'
3
4
 
4
5
  /**
5
6
  * Мовний doc-files-екстрактор JS-екосистеми для конвеєра `@7n/rules`
@@ -249,6 +250,17 @@ function extractMarkers(src) {
249
250
  skips: [...skips]
250
251
  }
251
252
  }
253
+ /** JS-хелпери, які Vue-екстрактор переюзає над текстом script-блоку SFC. */
254
+ const VUE_HELPERS = {
255
+ extractFileHeader,
256
+ extractExports,
257
+ extractImports,
258
+ extractInternalSymbols,
259
+ extractLocalSymbols,
260
+ extractMarkers,
261
+ parseJsDoc
262
+ }
263
+
252
264
  /**
253
265
  * Головний екстрактор: код файлу → факт-лист.
254
266
  * @param {string} src вміст файлу
@@ -257,6 +269,7 @@ function extractMarkers(src) {
257
269
  */
258
270
  export function extractFacts(src, relPath) {
259
271
  const lang = relPath.split('.').pop()
272
+ if (lang === 'vue') return extractFactsVue(src, relPath, VUE_HELPERS)
260
273
  if (!['js', 'mjs', 'ts'].includes(lang)) {
261
274
  return { relPath, lang, unsupported: true, header: '', exports: [], imports: {}, markers: {} }
262
275
  }
@@ -272,17 +285,29 @@ export function extractFacts(src, relPath) {
272
285
  }
273
286
  }
274
287
 
288
+ /**
289
+ * Юніти: `.vue` — зі script-блоку SFC (span-и в координатах файлу), решта — oxc AST.
290
+ * @param {string} src вміст файлу
291
+ * @param {string} relPath шлях (вибір мови)
292
+ * @returns {Array<object>|null} юніти або null
293
+ */
294
+ function extractUnits(src, relPath) {
295
+ if (relPath.toLowerCase().endsWith('.vue')) return extractUnitsVue(src, relPath, extractUnitsJs)
296
+ return extractUnitsJs(src, relPath)
297
+ }
298
+
275
299
  /**
276
300
  * 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 }}
301
+ * `.vue` (SFC зі `<script setup>`) парситься через optional peer `vue/compiler-sfc`
302
+ * (ADR 260719-2155): props/emits/expose/слоти факт-лист; без peer чи script-блоку
303
+ * fallback `unsupported` (whole-file шлях, як до впровадження).
304
+ * @type {{ id: string, extensions: string[], extractFacts: typeof extractFacts, extractUnits: typeof extractUnits }}
280
305
  */
281
306
  const jsDocFilesExtractor = {
282
307
  id: 'js',
283
308
  extensions: ['.js', '.mjs', '.ts', '.vue'],
284
309
  extractFacts,
285
- extractUnits: extractUnitsJs
310
+ extractUnits
286
311
  }
287
312
 
288
313
  export default jsDocFilesExtractor
@@ -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.8.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
  }