@7n/rules-lang-js 0.8.0 → 0.10.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.
Files changed (58) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/doc-files/docs/extractors.md +1 -1
  3. package/doc-files/docs/index.md +1 -0
  4. package/doc-files/docs/js-facts.md +47 -0
  5. package/doc-files/docs/units-js.md +1 -2
  6. package/doc-files/extractors.mjs +135 -12
  7. package/doc-files/js-facts.mjs +28 -0
  8. package/doc-files/units-js.mjs +26 -10
  9. package/package.json +1 -1
  10. package/rules/bun/package_json/package_json.mdc +3 -1
  11. package/rules/bun/package_json/package_json.rego +30 -1
  12. package/rules/npm-module/npm_package_json/npm_package_json.mdc +18 -3
  13. package/rules/npm-module/npm_package_json/npm_package_json.rego +42 -5
  14. package/rules/storybook/adopt/docs/index.md +9 -0
  15. package/rules/storybook/adopt/docs/main.md +46 -0
  16. package/rules/storybook/adopt/main.mjs +379 -0
  17. package/rules/storybook/hygiene/concern.json +4 -0
  18. package/rules/storybook/hygiene/docs/index.md +9 -0
  19. package/rules/storybook/hygiene/docs/main.md +45 -0
  20. package/rules/storybook/hygiene/main.mjs +254 -0
  21. package/rules/storybook/main.json +1 -0
  22. package/rules/storybook/main.mdc +60 -0
  23. package/rules/storybook/mocking/concern.json +3 -0
  24. package/rules/storybook/mocking/mocking.mdc +108 -0
  25. package/rules/storybook/scaffold/concern.json +5 -0
  26. package/rules/storybook/scaffold/docs/fix-scaffold.md +29 -0
  27. package/rules/storybook/scaffold/docs/index.md +10 -0
  28. package/rules/storybook/scaffold/docs/main.md +36 -0
  29. package/rules/storybook/scaffold/fix-scaffold.mjs +150 -0
  30. package/rules/storybook/scaffold/main.mjs +164 -0
  31. package/rules/storybook/scaffold/template/docs/index.md +10 -0
  32. package/rules/storybook/scaffold/template/docs/main.md +30 -0
  33. package/rules/storybook/scaffold/template/docs/preview.md +46 -0
  34. package/rules/storybook/scaffold/template/main.js +45 -0
  35. package/rules/storybook/scaffold/template/mocks/docs/gql-sse.md +36 -0
  36. package/rules/storybook/scaffold/template/mocks/docs/index.md +9 -0
  37. package/rules/storybook/scaffold/template/mocks/gql-sse.js +25 -0
  38. package/rules/storybook/scaffold/template/preview.js +47 -0
  39. package/rules/storybook/scope/concern.json +4 -0
  40. package/rules/storybook/scope/docs/index.md +9 -0
  41. package/rules/storybook/scope/docs/main.md +62 -0
  42. package/rules/storybook/scope/main.mjs +202 -0
  43. package/rules/storybook/vitest-config/concern.json +8 -0
  44. package/rules/storybook/vitest-config/docs/fix-vitest-config.md +38 -0
  45. package/rules/storybook/vitest-config/docs/index.md +10 -0
  46. package/rules/storybook/vitest-config/docs/main.md +72 -0
  47. package/rules/storybook/vitest-config/fix-vitest-config.mjs +340 -0
  48. package/rules/storybook/vitest-config/main.mjs +358 -0
  49. package/rules/storybook/vitest-config/template/docs/index.md +12 -0
  50. package/rules/storybook/vitest-config/template/docs/storybook-project-entry.md +34 -0
  51. package/rules/storybook/vitest-config/template/docs/unit-project-entry.md +30 -0
  52. package/rules/storybook/vitest-config/template/docs/vitest.config.baseline.md +29 -0
  53. package/rules/storybook/vitest-config/template/docs/vitest.stryker.config.baseline.md +31 -0
  54. package/rules/storybook/vitest-config/template/storybook-project-entry.js +22 -0
  55. package/rules/storybook/vitest-config/template/unit-project-entry.js +5 -0
  56. package/rules/storybook/vitest-config/template/vitest.config.baseline.mjs +37 -0
  57. package/rules/storybook/vitest-config/template/vitest.stryker.config.baseline.mjs +19 -0
  58. package/rules/storybook/vitest-config/vitest-config.mdc +33 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,28 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.10.0] - 2026-07-21
4
+
5
+ ### Added
6
+
7
+ - storybook: канон Storybook хвилі 1 для Vue-компонентних бібліотек — детекція скоупу (isVueComponentLibraryPkg, поріг ≥3 .vue, opt-out), канонічний скафолд .storybook/main.js+preview.js+mocks/gql-sse.js, package.json#scripts.storybook (ADR канон-storybook-для-vue-компонентних-бібліотек)
8
+ - npm-module/bun: governance-виняток канону Storybook (кластер 7 ADR канон-storybook-для-vue-компонентних-бібліотек) — npm_package_json.rego дозволяє канонічні Storybook-devDeps (storybook, @storybook/vue3-vite, @storybook/vue3, msw, msw-storybook-addon) у npm/package.json із зафіксованою точною версією (deny на неканонічний пакет або неканонічну версію); bun/package_json.rego розширює root-only test peers на @vitest/browser + playwright (browser-mode provider для named vitest project "storybook", лише chromium) та @storybook/addon-vitest (storybookTest-плагін того самого vitest-конфіга) — Storybook-identity-пакети у корінь свідомо не додаються
9
+ - storybook: vitest-config-концерн хвилі 1 (ADR Кластер 5) — canonical test.projects unit+storybook (browser-mode, лише chromium, stories-glob) дописується поверх наявного vitest-конфіга, ізольований vitest.stryker.config генерується поруч (Stryker крашиться на browser-mode projects)
10
+ - storybook: концерни mocking (docs-only рецепти router/tfm/Apollo-MSW/Pinia/page-story) і hygiene (undeclared third-party imports у .vue, auto-detect sassVariables) — ADR Кластер 3/6
11
+
12
+ ### Fixed
13
+
14
+ - storybook: підключено concern-и scope/scaffold/vitest-config до unified lint-рушія (lint-блок у concern.json — check:true без lint мовчки ігнорувався run-detectors.mjs), додано --adopt-режим (adopt/main.mjs) і скіл n-storybook
15
+
16
+ ## [0.9.0] - 2026-07-20
17
+
18
+ ### Added
19
+
20
+ - doc-files: Vue SFC-екстрактор (`.vue` через optional peer `vue/compiler-sfc`) — props/emits/exposed як псевдо-експорти, слоти з `@slot`-коментарів шаблону, юніти зі зміщеними у файл офсетами
21
+
22
+ ### Fixed
23
+
24
+ - doc-files: JSDoc-атрибуція експортів/юнітів через реальні AST-коментарі парсера (не regex по сирому тексту) — усуває false positive, коли '/**'-подібний текст трапляється всередині // -коментаря чи рядкового літералу
25
+
3
26
  ## [0.8.0] - 2026-07-20
4
27
 
5
28
  ### Added
@@ -3,7 +3,7 @@ type: JS Module
3
3
  title: extractors.mjs
4
4
  resource: plugins/lang-js/doc-files/extractors.mjs
5
5
  docgen:
6
- crc: 0519b5be
6
+ crc: ec2c4b59
7
7
  model: openai-codex/gpt-5.4-mini
8
8
  score: 100
9
9
  issues: judge:inaccurate:0.98
@@ -7,5 +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 |
11
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
@@ -1,6 +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'
3
4
  import { extractFactsVue, extractUnitsVue } from './vue.mjs'
5
+ import { jsDocCommentBefore } from './js-facts.mjs'
4
6
 
5
7
  /**
6
8
  * Мовний doc-files-екстрактор JS-екосистеми для конвеєра `@7n/rules`
@@ -36,8 +38,14 @@ const JSDOC_CLOSE_RE = /\*\/\s*$/
36
38
  const STAR_PREFIX_RE = /^\s*\*?\s?/
37
39
  const PARAM_LINE_RE = /^@param[ \t]{1,8}(?:\{[^}]{0,200}\}[ \t]{1,8})?\[?([\w.]{1,80})\]?[ \t]{0,8}(.{0,400})$/
38
40
  const RETURNS_LINE_RE = /^@returns?[ \t]{1,8}(?:\{[^}]{0,200}\}[ \t]{1,8})?(.{0,400})$/
39
- const FILE_HEADER_RE = /^\s*\/\*\*([\s\S]*?)\*\//
40
- 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*$/
41
49
  const EXPORT_DECL_RE = /export\s+(?:async\s+)?(function|const|class)\s+(\w+)/g
42
50
  // Top-level function/class декларації (колонка 0) — для R6: службові функції,
43
51
  // які не експортуються, не мають протікати у Поведінку/API як «публічні».
@@ -102,6 +110,63 @@ function cleanJsDoc(raw) {
102
110
  .trim()
103
111
  }
104
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
+
105
170
  /**
106
171
  * Опис (без @-тегів) + параметри з `@param` як «name — опис».
107
172
  * @param {string} raw сирий JSDoc-блок
@@ -113,20 +178,52 @@ function parseJsDoc(raw) {
113
178
  const descLines = []
114
179
  const params = []
115
180
  let ret = ''
116
- 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
+ }
117
198
  const pm = l.match(PARAM_LINE_RE)
118
199
  if (pm) {
119
200
  const desc = pm[2].trim()
120
201
  // «опис.» — JSDoc-заглушка без сенсу; не тягнемо її як факт
121
202
  params.push({ name: pm[1], desc: desc === 'опис.' ? '' : desc })
203
+ continuation = { kind: 'param', idx: params.length - 1 }
122
204
  continue
123
205
  }
124
206
  const rm = l.match(RETURNS_LINE_RE)
125
207
  if (rm) {
126
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 // невідомий/непідтримуваний тег — не продовжуємо в нього
127
220
  continue
128
221
  }
129
- if (l.startsWith('@')) continue
222
+ if (continuation && continuation !== 'desc' && l.trim()) {
223
+ ret = appendContinuation(continuation, params, ret, l.trim())
224
+ continue
225
+ }
226
+ continuation = 'desc'
130
227
  descLines.push(l)
131
228
  }
132
229
  return { desc: descLines.join('\n').trim(), params, ret }
@@ -134,10 +231,22 @@ function parseJsDoc(raw) {
134
231
 
135
232
  /**
136
233
  * Провідний блок-коментар файлу (намір), якщо він перед першим import/кодом.
234
+ * `comments` (з парсера, `parseProgramAndCommentsOrNull`) — точний шлях: перший
235
+ * коментар файлу має бути саме ним. Без `comments` (парсинг не вдався, або
236
+ * виклик над фрагментом без AST — напр. Vue script-блок через `VUE_HELPERS`)
237
+ * — regex-фолбек на сирому тексті.
137
238
  * @param {string} src вміст файлу
239
+ * @param {Array<{type:string, value:string, start:number, end:number}>|null} [comments] список коментарів парсера або null
138
240
  * @returns {string} текст header-коментаря або порожній рядок
139
241
  */
140
- 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
+ }
141
250
  const m = src.match(FILE_HEADER_RE)
142
251
  if (!m) return ''
143
252
  // має бути на самому початку (до import/код)
@@ -147,8 +256,11 @@ function extractFileHeader(src) {
147
256
 
148
257
  /**
149
258
  * Блок-коментар, що стоїть ВПРИТУЛ перед позицією (лише пробіли між ними).
150
- * `(?:(?!\*​/)[\s\S])*` гарантує, що тіло не містить `*​/`, тож захоплюється рівно один
151
- * найближчий блок без жадібного «перестрибування» через імпорти/код.
259
+ * Regex-фолбек для випадків без `comments` від парсера (див. `jsDocCommentBefore`
260
+ * надійніший AST-based шлях, коли парсинг вдався). `(?:(?!\*​/)[\s\S])*` гарантує,
261
+ * що тіло не містить `*​/`, тож захоплюється рівно один найближчий блок — без
262
+ * жадібного «перестрибування» через імпорти/код (окрім залишкового класу false
263
+ * positive усередині `//`-коментарів, який і закриває `jsDocCommentBefore`).
152
264
  * @param {string} prefix вміст файлу до позиції експорту
153
265
  * @returns {string|null} JSDoc-блок або null якщо немає
154
266
  */
@@ -158,15 +270,18 @@ function precedingJsDoc(prefix) {
158
270
  }
159
271
 
160
272
  /**
161
- * Експорти + JSDoc, що безпосередньо передує кожному.
273
+ * Експорти + JSDoc, що безпосередньо передує кожному. З `comments` (парсер) —
274
+ * точна AST-based атрибуція (`jsDocCommentBefore`); без них (парсинг не вдався,
275
+ * або виклик над фрагментом без AST) — regex-фолбек (`precedingJsDoc`).
162
276
  * @param {string} src вміст файлу
277
+ * @param {Array<{type:string, value:string, start:number, end:number}>|null} [comments] список коментарів парсера або null
163
278
  * @returns {Array<object>} список експортів із метаданими
164
279
  */
165
- function extractExports(src) {
280
+ function extractExports(src, comments = null) {
166
281
  const out = []
167
282
  for (const m of src.matchAll(EXPORT_DECL_RE)) {
168
283
  const [, kind, name] = m
169
- const jsdocRaw = precedingJsDoc(src.slice(0, m.index))
284
+ const jsdocRaw = comments ? jsDocCommentBefore(comments, src, m.index) : precedingJsDoc(src.slice(0, m.index))
170
285
  out.push({ name, kind, ...(jsdocRaw ? parseJsDoc(jsdocRaw) : { desc: '', params: [], ret: '' }) })
171
286
  }
172
287
  return out
@@ -263,6 +378,12 @@ const VUE_HELPERS = {
263
378
 
264
379
  /**
265
380
  * Головний екстрактор: код файлу → факт-лист.
381
+ * Коментарі беруться з реального AST-парсера (`parseProgramAndCommentsOrNull`),
382
+ * не regex по сирому тексту — усуває клас false positive, де "/**"-подібний
383
+ * текст усередині `//`-коментаря чи рядкового літералу (напр. glob-патерн)
384
+ * помилково читається як відкриття JSDoc. Парсинг не вдався (синтаксична
385
+ * помилка) → `comments: null`, `extractFileHeader`/`extractExports` падають
386
+ * назад на свій regex-шлях (той самий, що й до цієї зміни).
266
387
  * @param {string} src вміст файлу
267
388
  * @param {string} relPath шлях (для контексту/мови екстрактора)
268
389
  * @returns {{relPath:string, lang:string, header:string, exports:Array, imports:object, markers:object}} структура фактів про файл
@@ -273,11 +394,13 @@ export function extractFacts(src, relPath) {
273
394
  if (!['js', 'mjs', 'ts'].includes(lang)) {
274
395
  return { relPath, lang, unsupported: true, header: '', exports: [], imports: {}, markers: {} }
275
396
  }
397
+ const parsed = parseProgramAndCommentsOrNull(src, relPath)
398
+ const comments = parsed?.comments ?? null
276
399
  return {
277
400
  relPath,
278
401
  lang,
279
- header: extractFileHeader(src),
280
- exports: extractExports(src),
402
+ header: extractFileHeader(src, comments),
403
+ exports: extractExports(src, comments),
281
404
  imports: extractImports(src),
282
405
  internalSymbols: extractInternalSymbols(src),
283
406
  localSymbols: extractLocalSymbols(src),
@@ -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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@7n/rules-lang-js",
3
- "version": "0.8.0",
3
+ "version": "0.10.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",
@@ -9,6 +9,8 @@ Rego-пакет: `bun.package_json`
9
9
  Gate виносить два класи deny:
10
10
 
11
11
  1. **Заборонені top-level поля** — будь-яке поле з `package.json.deny.json` присутнє у файлі (навіть із порожнім значенням `{}`).
12
- 2. **devDependencies не з білого списку** — дозволені лише `@nitra/*`/`@7n/*` та root-only тестові peer/tools (`vitest`, `@vitest/coverage-v8`, `@stryker-mutator/vitest-runner`, `@playwright/test`). Будь-який інший пакет → deny. CLI-тули, які `n-rules lint` спавнить через `bunx` (oxlint, jscpd, v8r, github-actionlint тощо), у root не пінуються — вони приїжджають як `dependencies` пакета `@7n/rules` (npm-module.mdc).
12
+ 2. **devDependencies не з білого списку** — дозволені лише `@nitra/*`/`@7n/*` та root-only тестові peer/tools (`vitest`, `@vitest/coverage-v8`, `@vitest/browser`, `@stryker-mutator/vitest-runner`, `@stryker-mutator/core`, `@playwright/test`, `playwright`, `@storybook/addon-vitest`, `@7n/test`). Будь-який інший пакет → deny. CLI-тули, які `n-rules lint` спавнить через `bunx` (oxlint, jscpd, v8r, github-actionlint тощо), у root не пінуються — вони приїжджають як `dependencies` пакета `@7n/rules` (npm-module.mdc).
13
+
14
+ `@vitest/browser`/`playwright`/`@storybook/addon-vitest` — додані для named vitest project "storybook" (канон Storybook, кластер 5: browser-mode, лише chromium) — той самий root vitest.config, що й `unit`-проект; `@storybook/addon-vitest` постачає `storybookTest`-плагін для цього vitest-конфіга (канонічний template правила `storybook`). **Межа з `npm-module.mdc`**: Storybook-специфічні identity-пакети (`storybook`, `@storybook/vue3-vite`, `@storybook/vue3`, `msw`, `msw-storybook-addon`) у цей allowlist **не** додаються — вони живуть у `npm/package.json` консюмер-пакета (канон Storybook, кластер 7 Governance), бо `isStorybookRoot()` у `@7n/test` детектує Storybook-скоуп саме за тим файлом, не кореневим. `@storybook/addon-vitest` — виняток із цієї межі: він test-tooling (vitest-плагін), а не identity-маркер, тож root, як і решта vitest-peer'ів.
13
15
 
14
16
  Перевірки, що потребують FS або cross-file контексту (наприклад наявність `yarn.lock`), лишаються у JS-шарі.
@@ -8,6 +8,15 @@
8
8
  # - `devDependencies` лише `@nitra/*` + root-only тестові peer/tools для `@7n/test coverage`
9
9
  # (правило `test` enabled завжди — див. `test/auto.md`; published workspace-и не мають
10
10
  # `devDependencies` за `npm-module.mdc`)
11
+ # - `@vitest/browser`/`playwright`/`@storybook/addon-vitest` (browser-mode provider +
12
+ # `storybookTest`-плагін для named vitest project "storybook", лише chromium — канон
13
+ # Storybook кластер 5) теж root-only test peers: той самий vitest.config, що й
14
+ # `unit`-проект, живе в корені монорепо-споживача. Storybook-специфічні
15
+ # identity-пакети (`storybook`, `@storybook/vue3*`, `msw*`) НЕ сюди — вони живуть у
16
+ # `npm/package.json` (канон Storybook кластер 7, `npm-module.mdc`), бо
17
+ # `isStorybookRoot()` @7n/test читає саме той файл, не кореневий package.json.
18
+ # `@storybook/addon-vitest` — виняток із цього правила: це test-tooling (плагін
19
+ # vitest-конфіга), а не Storybook-identity-маркер, тож root, а не npm/package.json.
11
20
  #
12
21
  # Перевірки, які потребують FS / cross-file контексту, лишаються у JS.
13
22
  package bun.package_json
@@ -50,7 +59,27 @@ deny contains msg if {
50
59
  # @stryker-mutator/core — обов'язковий exact-pin peer vitest-runner@9+ (раніше тягнувся транзитивно)
51
60
  # @7n/test — оркестратор `coverage` (npx @7n/test coverage); devDependency, щоб npx резолвив
52
61
  # локально без мережевого fetch щоразу.
53
- allowed_root_test_deps := {"vitest", "@vitest/coverage-v8", "@stryker-mutator/vitest-runner", "@stryker-mutator/core", "@playwright/test", "@7n/test"}
62
+ # @vitest/browser + playwright — провайдер browser-mode для named vitest project
63
+ # "storybook" (канон Storybook кластер 5: лише chromium, PR — швидкий
64
+ # --project=storybook). `playwright` (не `@playwright/test`) — сирий driver, який
65
+ # @vitest/browser використовує як provider; `@playwright/test` лишається окремо для
66
+ # змістовних E2E-сценаріїв (n-vue.mdc).
67
+ # @storybook/addon-vitest — постачає `storybookTest` для vitest-плагіна в канонічному
68
+ # vitest.config named-проекту "storybook" (той самий канон Storybook кластер 5); версія
69
+ # з лінійки Storybook 9.x (узгоджена з `storybook`@9.1.10, запіненим у
70
+ # npm_package_json.rego) — allowlist тут за іменем, точний пінінг версії root-tooling
71
+ # не робимо (на відміну від Storybook-identity-пакетів у npm/package.json).
72
+ allowed_root_test_deps := {
73
+ "vitest",
74
+ "@vitest/coverage-v8",
75
+ "@vitest/browser",
76
+ "@stryker-mutator/vitest-runner",
77
+ "@stryker-mutator/core",
78
+ "@playwright/test",
79
+ "playwright",
80
+ "@storybook/addon-vitest",
81
+ "@7n/test",
82
+ }
54
83
 
55
84
  allowed_root_dev_dependency(name) if {
56
85
  startswith(name, "@nitra/")
@@ -16,10 +16,13 @@ Rego-пакет: `npm-module.npm_package_json`
16
16
  - Обовʼязкове, має бути непорожнім масивом.
17
17
  - Subset-of перевірка: кожне значення з канонічного сніпету має бути присутнє у `files`. За замовчуванням — `"types"` обовʼязковий.
18
18
 
19
- **Поле `devDependencies`** (inverse-pattern, логіка в rego):
19
+ **Поле `devDependencies`** (inverse-pattern + Storybook-виняток, логіка в rego):
20
20
 
21
21
  - Не публікуються користувачам пакета — має бути відсутнє або порожнє `{}`.
22
- - Наявність будь-яких devDeps deny з переліком залежностей. Dev-інструментарій переноситься у кореневий `package.json`; CLI-тули, які пакет спавнить через `bunx` у репозиторіях-споживачах (пінінг версій),у `dependencies` (кореневе bun-правило `package_json` такі пакети в root devDeps не пускає).
22
+ - **Виняток канонічні Storybook-пакети** (канон Storybook, кластер 7 Governance: `docs/adr/канон-storybook-для-vue-компонентних-бібліотек.md`): `storybook`, `@storybook/vue3-vite`, `@storybook/vue3`, `msw`, `msw-storybook-addon` дозволені як devDeps саме тут, у `npm/package.json` консюмер-пакета **не** в кореневому `package.json`. Обґрунтування: майбутній `isStorybookRoot()` у `@7n/test` читає саме цей файл, щоб визначити Storybook-скоуп workspace-пакета, тож маркер-пакети мають бути видимі тут, а не в кореневих tooling-deps. Версія кожного канонічного пакета зафіксована точно (map `storybook_canon_dev_deps` у rego) — присутність пакета з іншою версією теж deny (окреме повідомлення, не плутати з allowlist-забороною).
23
+ - Будь-який інший devDep (не з канонічного Storybook-списку) → deny з переліком імен. Dev-інструментарій переноситься у кореневий `package.json`; CLI-тули, які пакет спавнить через `bunx` у репозиторіях-споживачах (пінінг версій), — у `dependencies` (кореневе bun-правило `package_json` такі пакети в root devDeps не пускає).
24
+
25
+ Канон Storybook-devDeps та їхні версії — static map у `npm_package_json.rego` (не template-driven: це опційний allowlist, а не mandatory-presence дані, тож генеричний T0-fix-writer цього concern-а їх у кожен `package.json` не мерджить — див. коментар на початку rego-файлу).
23
26
 
24
27
  Канонічний сніпет `files`: [package.json.snippet.json](./template/package.json.snippet.json)
25
28
 
@@ -50,8 +53,20 @@ FS-перевірки (наявність файлу зі шляху `types`, с
50
53
  { "files": ["bin", "mdc"] }
51
54
  ```
52
55
 
53
- ✗ Неправильно — наявні `devDependencies`:
56
+ ✗ Неправильно — наявні `devDependencies`, не з канонічного Storybook-списку:
54
57
 
55
58
  ```json
56
59
  { "devDependencies": { "@7n/rules": "^1.0.0" } }
57
60
  ```
61
+
62
+ ✓ Правильно — канонічний Storybook-devDep із зафіксованою версією (канон Storybook):
63
+
64
+ ```json
65
+ { "devDependencies": { "storybook": "9.1.10" } }
66
+ ```
67
+
68
+ ✗ Неправильно — Storybook-devDep присутній, але версія не збігається з каноном:
69
+
70
+ ```json
71
+ { "devDependencies": { "storybook": "8.0.0" } }
72
+ ```