@7n/rules 1.21.0 → 1.23.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
+ ## [1.23.0] - 2026-07-18
4
+
5
+ ### Changed
6
+
7
+ - Фаза 4a spec lang-plugins-extraction: doc-files отримав мовний extension-point — розширення (`.rs` → 'Rust Module', `.py` → 'Python Module') декларуються в маніфесті плагіна (`contributes.docFiles.extensions`, синхронно для hot-path hook), екстрактори фактів/юнітів — handler-модулем (`contributes.handlers['doc-files']`, вантажиться лише на шляху генерації). Rust-екстрактори (extractFactsRust + units-rs) виїхали в `@7n/rules-lang-rust`; ядро документує js/mjs/ts/vue вбудовано, .rs/.py — за активним lang-плагіном
8
+
9
+ ## [1.22.0] - 2026-07-18
10
+
11
+ ### Added
12
+
13
+ - taze: по завершенню переносить зміни з автоствореного worktree назад у вихідне дерево (untracked) і прибирає worktree
14
+
15
+ ### Changed
16
+
17
+ - taze: worktree-only гейт сам створює .worktrees/branch-taze і продовжує там замість throw-and-stop
18
+
3
19
  ## [1.21.0] - 2026-07-18
4
20
 
5
21
  ### Changed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@7n/rules",
3
- "version": "1.21.0",
3
+ "version": "1.23.0",
4
4
  "description": "CLI еталонних правил і skills (префікс n-): синк у репозиторій, дельта-lint, конформність",
5
5
  "keywords": [
6
6
  "cli",
@@ -3,7 +3,7 @@ type: JS Module
3
3
  title: main.mjs
4
4
  resource: npm/rules/doc-files/check/main.mjs
5
5
  docgen:
6
- crc: 715702d3
6
+ crc: 2af6c6b9
7
7
  model: omlx/gemma-4-e4b-it-OptiQ-4bit
8
8
  score: 100
9
9
  issues: judge:inaccurate:0.99
@@ -25,7 +25,7 @@ function sourceForDoc(cwd, docRel) {
25
25
  return null
26
26
  }
27
27
  for (const e of entries) {
28
- if (!e.isFile() || !isSourceFile(e.name)) continue
28
+ if (!e.isFile() || !isSourceFile(e.name, cwd)) continue
29
29
  if (basename(e.name, extname(e.name)) !== stem) continue
30
30
  const rel = srcDir === '.' ? e.name : `${srcDir}/${e.name}`
31
31
  if (isDocCandidate(cwd, rel)) return rel
@@ -3,7 +3,7 @@ type: JS Module
3
3
  title: main.mjs
4
4
  resource: npm/rules/doc-files/docgen-crc/main.mjs
5
5
  docgen:
6
- crc: bc1835c9
6
+ crc: c10183ef
7
7
  model: omlx/gemma-4-e4b-it-OptiQ-4bit
8
8
  score: 100
9
9
  issues: judge:inaccurate:0.99
@@ -3,6 +3,7 @@ import { existsSync, readFileSync } from 'node:fs'
3
3
  import { basename, extname } from 'node:path'
4
4
  import { crc32 as zlibCrc32 } from 'node:zlib'
5
5
  import { env } from 'node:process'
6
+ import { pluginDocFilesExtensions } from '../docgen-scan/lang-extensions.mjs'
6
7
 
7
8
  /** Поріг degraded: дока зі `score` нижче вважається неякісною. */
8
9
  export const QUALITY_THRESHOLD = Number(env.N_CURSOR_DOC_FILES_THRESHOLD ?? 70) || 70
@@ -66,24 +67,24 @@ export function parseDocFrontmatter(md) {
66
67
  /** Максимум кодів issues у frontmatter — це маркер, а не повний лог. */
67
68
  const MAX_ISSUE_CODES = 8
68
69
 
69
- /** OKF type для розширення файлу. */
70
+ /** OKF type для вбудованих розширень; мовні (`.rs`, `.py`) декларують lang-плагіни. */
70
71
  const EXT_TYPES = {
71
72
  '.js': 'JS Module',
72
73
  '.mjs': 'JS Module',
73
74
  '.cjs': 'JS Module',
74
75
  '.ts': 'TS Module',
75
- '.vue': 'Vue Component',
76
- '.py': 'Python Module',
77
- '.rs': 'Rust Module'
76
+ '.vue': 'Vue Component'
78
77
  }
79
78
 
80
79
  /**
81
- * OKF `type` для файлу-джерела за розширенням.
80
+ * OKF `type` для файлу-джерела за розширенням (вбудовані + декларації
81
+ * активних lang-плагінів).
82
82
  * @param {string} sourcePath відносний шлях джерела
83
83
  * @returns {string} тип концепту
84
84
  */
85
85
  function typeForSource(sourcePath) {
86
- return EXT_TYPES[extname(sourcePath).toLowerCase()] ?? 'Source File'
86
+ const ext = extname(sourcePath).toLowerCase()
87
+ return EXT_TYPES[ext] ?? pluginDocFilesExtensions(process.cwd())[ext] ?? 'Source File'
87
88
  }
88
89
 
89
90
  /**
@@ -3,7 +3,7 @@ type: JS Module
3
3
  title: main.mjs
4
4
  resource: npm/rules/doc-files/docgen-extract/main.mjs
5
5
  docgen:
6
- crc: 39c43eb9
6
+ crc: a9332a80
7
7
  model: omlx/gemma-4-e4b-it-OptiQ-4bit
8
8
  score: 100
9
9
  issues: judge:inaccurate:0.99
@@ -241,201 +241,6 @@ function extractMarkers(src) {
241
241
  skips: [...skips]
242
242
  }
243
243
  }
244
-
245
- // ── Rust-екстрактор ──────────────────────────────────────────────────────────
246
-
247
- // pub fn / pub struct / pub enum / pub trait (та fn із exposure-атрибутом)
248
- // матчаться у два кроки по trim-нутому рядку — прості регекспи без бектрекінгу:
249
- // спершу опційний pub(...)-префікс, потім сама декларація
250
- const RS_PUB_PREFIX_RE = /^pub(?:\([^)]*\))?\s+/
251
- const RS_ITEM_DECL_RE = /^(?:async\s+)?(?:unsafe\s+)?(fn|struct|enum|trait|type)\s+(\w+)/
252
-
253
- // fn-декларація у trim-нутому рядку одразу після exposure-атрибута
254
- const RS_FN_AFTER_ATTR_RE = /^(?:pub\s+)?(?:async\s+)?(?:unsafe\s+)?fn\s+/
255
-
256
- // Будь-яка fn-декларація без pub (для приватних localSymbols)
257
- const RS_PRIVATE_FN_RE = /^[ \t]*(?:async\s+)?(?:unsafe\s+)?fn\s+(\w+)/
258
-
259
- // Exposure-атрибути (#[tauri::command] тощо)
260
- const RS_EXPOSURE_ATTR_RE = /#\[(?:tauri::command|wasm_bindgen|uniffi::export|pyo3::pyfunction|napi)/gm
261
-
262
- // use crate::module::{A, B} або use std::..;
263
- const RS_USE_RE = /^[ \t]*use\s+([\w:]+(?:::\{[^}]+\})?(?:::\*)?(?:::\w+)?)\s*;/gm
264
-
265
- // Файловий запис: fs::write / File::create / remove_file / create_dir / write_all
266
- const RS_WRITE_RE = /fs::write|File::create|remove_file|create_dir|BufWriter::new|OpenOptions[^;]*\.write\s*\(\s*true/
267
-
268
- // Raw-SQL запис через sqlx-подібні макро/виклики (query!/query/execute) — DML-
269
- // keyword одразу після відкриваючої лапки рядкового літералу (той самий
270
- // мінімальний тег+вміст сигнал, що й для JS tagged-template, SQL_TAGGED_MUTATION_RE)
271
- const RS_SQL_WRITE_RE = /\b(?:query!?|execute)\s*\(\s*"\s*(?:UPDATE|INSERT|MERGE\s+INTO|DELETE\s+FROM|UPSERT)\b/i
272
-
273
- // Обробка помилок (але не просто `?`): прості маркери; випадок
274
- // «match … з Err(-гілкою» — віконним обходом рядків у rsHasMatchWithErrArm
275
- const RS_CATCH_SIMPLE_RES = [/\.unwrap_or(?:_else|_default)?/, /if\s+let\s+Err\s*\(/, /\.map_err\s*\(/, /\.ok\s*\(\)/]
276
- const RS_MATCH_KW_RE = /\bmatch\s/
277
- const RS_ERR_ARM_RE = /\bErr\s*\(/
278
-
279
- // Функції, що повертають Result або Option
280
- const RS_RESULT_RE = /->\s*(?:Result|Option)\s*</
281
-
282
- // Мережа
283
- const RS_NETWORK_RE = /reqwest|hyper::|TcpStream|UdpSocket|tokio::net/
284
-
285
- // Кешування
286
- const RS_CACHE_RE = /\bcache\b|\bCache\b|lazy_static!|OnceCell|OnceLock|DashMap/i
287
-
288
- /**
289
- * Чи містить джерело `match`-вираз із `Err(`-гілкою неподалік (вікно 12 рядків).
290
- * Замінює бектрекінг-вразливу регулярку детермінованим обходом рядків.
291
- * @param {string[]} srcLines рядки файлу
292
- * @returns {boolean} true, якщо за `match` слідує `Err(`
293
- */
294
- function rsHasMatchWithErrArm(srcLines) {
295
- for (let i = 0; i < srcLines.length; i++) {
296
- if (!RS_MATCH_KW_RE.test(srcLines[i])) continue
297
- const end = Math.min(i + 12, srcLines.length)
298
- for (let j = i; j < end; j++) if (RS_ERR_ARM_RE.test(srcLines[j])) return true
299
- }
300
- return false
301
- }
302
-
303
- /**
304
- * Видобуває `///` doc-рядки перед рядком `lineIdx` (назад через `#[...]` та пусті рядки).
305
- * @param {string[]} lines рядки файлу
306
- * @param {number} lineIdx індекс рядка декларації
307
- * @returns {string} опис або ''
308
- */
309
- function rsDocBefore(lines, lineIdx) {
310
- const doc = []
311
- for (let i = lineIdx - 1; i >= 0; i--) {
312
- const t = lines[i].trim()
313
- if (t.startsWith('///')) doc.unshift(t.slice(3).trim())
314
- else if (t.startsWith('#[') || t.startsWith('#![') || t === '') {
315
- /* skip */
316
- } else break
317
- }
318
- return doc.join(' ').trim()
319
- }
320
-
321
- /**
322
- * Витягує module-level doc (`//!`) із голови `.rs` файлу.
323
- * @param {string} src вміст файлу
324
- * @returns {string} злитий header-текст
325
- */
326
- function rsExtractHeader(src) {
327
- const headerLines = []
328
- for (const line of src.split('\n')) {
329
- const t = line.trim()
330
- if (t.startsWith('//!')) headerLines.push(t.slice(3).trim())
331
- else if (t === '' || t.startsWith('//')) continue
332
- else break
333
- }
334
- return headerLines.join(' ').trim()
335
- }
336
-
337
- /**
338
- * Обчислює номери рядків із `fn`, які стають фактично `pub` через exposure-атрибути.
339
- * @param {string} src вміст файлу
340
- * @param {string[]} srcLines рядки файлу
341
- * @returns {Set<number>} індекси exposure-exposed fn-рядків
342
- */
343
- function rsExposedLineSet(src, srcLines) {
344
- const exposedLineSet = new Set()
345
- for (const m of src.matchAll(RS_EXPOSURE_ATTR_RE)) {
346
- // Знаходимо, який рядок містить цей атрибут
347
- let pos = 0
348
- for (let li = 0; li < srcLines.length; li++) {
349
- if (pos + srcLines[li].length >= m.index) {
350
- // Шукаємо наступний не-атрибутний рядок з fn
351
- for (let nli = li + 1; nli < Math.min(li + 5, srcLines.length); nli++) {
352
- const t = srcLines[nli].trim()
353
- if (t.startsWith('#[') || t === '') continue
354
- if (RS_FN_AFTER_ATTR_RE.test(t)) exposedLineSet.add(nli)
355
- break
356
- }
357
- break
358
- }
359
- pos += srcLines[li].length + 1
360
- }
361
- }
362
- return exposedLineSet
363
- }
364
-
365
- /**
366
- * Збирає публічні items (`pub` + exposure-exposed) `.rs` файлу.
367
- * @param {string[]} srcLines рядки файлу
368
- * @param {Set<number>} exposedLineSet індекси exposure-exposed fn-рядків
369
- * @returns {Array<{name:string, kind:string, desc:string}>} перелік exports
370
- */
371
- function rsCollectExports(srcLines, exposedLineSet) {
372
- const exports = []
373
- for (let li = 0; li < srcLines.length; li++) {
374
- const trimmed = srcLines[li].trimStart()
375
- const pubM = trimmed.match(RS_PUB_PREFIX_RE)
376
- const m = (pubM ? trimmed.slice(pubM[0].length) : trimmed).match(RS_ITEM_DECL_RE)
377
- if (m) {
378
- const isPub = Boolean(pubM) || exposedLineSet.has(li)
379
- if (isPub) {
380
- const desc = rsDocBefore(srcLines, li)
381
- exports.push({ name: m[2], kind: m[1], desc })
382
- }
383
- }
384
- }
385
- return exports
386
- }
387
-
388
- /**
389
- * Класифікує `use`-рядки `.rs` файлу на std / external / internal.
390
- * @param {string} src вміст файлу
391
- * @returns {{stdlib:string[], external:string[], internal:string[]}} згруповані imports
392
- */
393
- function rsExtractImports(src) {
394
- const stdlib = new Set()
395
- const external = new Set()
396
- for (const m of src.matchAll(RS_USE_RE)) {
397
- const path = m[1]
398
- const root = path.split('::', 1)[0]
399
- if (root === 'std' || root === 'core' || root === 'alloc') stdlib.add(path)
400
- else external.add(path)
401
- }
402
- return { stdlib: [...stdlib], external: [...external], internal: [] }
403
- }
404
-
405
- /**
406
- * Витягує факт-лист для `.rs` файлу.
407
- * @param {string} src вміст файлу
408
- * @param {string} relPath відносний шлях
409
- * @returns {object} факт-лист без `unsupported`
410
- */
411
- function extractFactsRust(src, relPath) {
412
- const header = rsExtractHeader(src)
413
- const srcLines = src.split('\n')
414
- const exposedLineSet = rsExposedLineSet(src, srcLines)
415
- const exports = rsCollectExports(srcLines, exposedLineSet)
416
-
417
- // localSymbols — приватні fn (не pub і не exposed) — не документуємо як публічний API
418
- const localSymbols = []
419
- for (const line of srcLines) {
420
- const m = line.match(RS_PRIVATE_FN_RE)
421
- if (m && exports.every(e => e.name !== m[1])) localSymbols.push(m[1])
422
- }
423
-
424
- const imports = rsExtractImports(src)
425
-
426
- // markers
427
- const markers = {
428
- readOnly: !RS_WRITE_RE.test(src) && !RS_SQL_WRITE_RE.test(src),
429
- catchesErrors: RS_CATCH_SIMPLE_RES.some(re => re.test(src)) || rsHasMatchWithErrArm(srcLines),
430
- returnsFalsyOnFail: RS_RESULT_RE.test(src),
431
- network: RS_NETWORK_RE.test(src),
432
- caches: RS_CACHE_RE.test(src),
433
- skips: []
434
- }
435
-
436
- return { relPath, lang: 'rs', header, exports, imports, internalSymbols: [], localSymbols, markers }
437
- }
438
-
439
244
  /**
440
245
  * Головний екстрактор: код файлу → факт-лист.
441
246
  * @param {string} src вміст файлу
@@ -444,7 +249,6 @@ function extractFactsRust(src, relPath) {
444
249
  */
445
250
  export function extractFacts(src, relPath) {
446
251
  const lang = relPath.split('.').pop()
447
- if (lang === 'rs') return extractFactsRust(src, relPath)
448
252
  if (!['js', 'mjs', 'ts'].includes(lang)) {
449
253
  return { relPath, lang, unsupported: true, header: '', exports: [], imports: {}, markers: {} }
450
254
  }
@@ -3,7 +3,7 @@ type: JS Module
3
3
  title: main.mjs
4
4
  resource: npm/rules/doc-files/docgen-gen/main.mjs
5
5
  docgen:
6
- crc: 344e711f
6
+ crc: 358294dc
7
7
  model: omlx/gemma-4-e4b-it-OptiQ-4bit
8
8
  ---
9
9
 
@@ -7,6 +7,7 @@ import { runOneShot } from '@7n/llm-lib/one-shot'
7
7
  import { startChain } from '@7n/llm-lib/chain'
8
8
  import { isRunAsCli } from '../../../scripts/cli-entry.mjs'
9
9
  import { docPathForSource } from '../docgen-scan/main.mjs'
10
+ import { loadDocFilesExtractors } from '../docgen-scan/lang-extensions.mjs'
10
11
  import { extractFacts } from '../docgen-extract/main.mjs'
11
12
  import { extractAnchors, anchorTokens } from '../docgen-extract-anchors/main.mjs'
12
13
  import { QUALITY_THRESHOLD } from '../docgen-crc/main.mjs'
@@ -506,7 +507,11 @@ export async function generateDoc(
506
507
  `docgen pre-send guard: джерело ~${estTokens} токенів > бюджет ${budget} (0.5× контексту) — Prompt too long, skip`
507
508
  )
508
509
  }
509
- const facts = extractFacts(src, file)
510
+ // Мовний екстрактор з lang-плагіна (напр. `.rs` у @7n/rules-lang-rust) має
511
+ // пріоритет; вбудований extractFacts покриває JS-екосистему, решта — whole-file.
512
+ const langExtractors = await loadDocFilesExtractors(process.cwd())
513
+ const ext = `.${file.split('.').pop()}`.toLowerCase()
514
+ const facts = langExtractors.get(ext)?.extractFacts?.(src, file) ?? extractFacts(src, file)
510
515
  const t0 = Date.now()
511
516
  llmMeter = { calls: 0, ms: 0 }
512
517
  const chain = chainFactory({ kind: 'doc-generate', unit: facts.relPath, cwd: process.cwd() })
@@ -0,0 +1,10 @@
1
+ ---
2
+ type: Directory Index
3
+ title: npm/rules/doc-files/docgen-scan
4
+ resource: npm/rules/doc-files/docgen-scan/
5
+ ---
6
+
7
+ | Файл | Тип |
8
+ | ----------------------------------------- | --------- |
9
+ | [lang-extensions.mjs](lang-extensions.md) | JS Module |
10
+ | [main.mjs](main.md) | JS Module |
@@ -0,0 +1,34 @@
1
+ ---
2
+ type: JS Module
3
+ title: lang-extensions.mjs
4
+ resource: npm/rules/doc-files/docgen-scan/lang-extensions.mjs
5
+ docgen:
6
+ crc: 3c5bc28b
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
+ Збирає з активних плагінів у репозиторії мапу розширень doc-files і мовні екстрактори для них, щоб інші частини системи могли підготувати обробку файла за доступними плагінними можливостями. `pluginDocFilesExtensions` формує перелік підтримуваних doc-files розширень, `loadDocFilesExtractors` підтягує екстрактори мов із плагінів, а `clearDocFilesLangCache` скидає кеш у межах прогону. Спирається на `.n-rules.json` і `.n-cursor.json` як джерело конфігурації активних плагінів та їхніх правил. Працює fail-safe: биті handler-модулі мовчки пропускає, не кидає винятків назовні, кешує стан у межах прогону.
17
+
18
+ ## Поведінка
19
+
20
+ - `pluginDocFilesExtensions` — повертає мапу розширень doc-files, які декларують активні плагіни в репозиторії, з урахуванням кешу для поточного прогону.
21
+ - `loadDocFilesExtractors` — завантажує мовні екстрактори з handler-модулів плагінів для doc-files і повертає їх за розширеннями; биті модулі мовчки пропускає, тож для таких файлів далі можливий whole-file шлях.
22
+ - `clearDocFilesLangCache` — скидає внутрішній кеш мовних розширень і екстракторів, щоб наступний прогін прочитав актуальний стан заново.
23
+
24
+ ## Публічний API
25
+
26
+ - pluginDocFilesExtensions — збирає з активних плагінів карту розширень для doc-files і тримає її в процесному кеші; порожній результат означає, що жоден плагін не оголосив підтримку.
27
+ - loadDocFilesExtractors — підвантажує мовні extractors із plugin handler-модулів для extension-point `doc-files`; якщо модуль зламаний, його тихо пропускає і далі обробляє файл цілком.
28
+ - clearDocFilesLangCache — очищає кеші, щоб тести починали з чистого стану.
29
+
30
+ ## Гарантії поведінки
31
+
32
+ - Read-only: не виконує операцій запису (ФС/БД).
33
+ - Перехоплює помилки і не пропускає винятків назовні (fail-safe).
34
+ - Кешує результати в межах одного прогону.
@@ -3,7 +3,7 @@ type: JS Module
3
3
  title: main.mjs
4
4
  resource: npm/rules/doc-files/docgen-scan/main.mjs
5
5
  docgen:
6
- crc: 6b2177db
6
+ crc: 66000db2
7
7
  model: omlx/gemma-4-e4b-it-OptiQ-4bit
8
8
  score: 100
9
9
  issues: judge:inaccurate:0.99
@@ -0,0 +1,89 @@
1
+ /** @see ./docs/lang-extensions.md */
2
+ import { existsSync, readFileSync } from 'node:fs'
3
+ import { join } from 'node:path'
4
+ import { pathToFileURL } from 'node:url'
5
+
6
+ import { getDocFilesExtensions, getHandlers } from '../../../scripts/lib/resolve-plugins.mjs'
7
+
8
+ /**
9
+ * Мовні розширення doc-files від плагінів (фаза 4 spec lang-plugins-extraction).
10
+ *
11
+ * Розширення (`.rs` → 'Rust Module') декларуються в МАНІФЕСТІ плагіна
12
+ * (`n-rules.contributes.docFiles.extensions`) — hot-path (hook на кожен файл)
13
+ * читає їх синхронно без динамічного import. Екстрактори фактів/юнітів —
14
+ * у handler-модулі (`contributes.handlers['doc-files']`), вантажаться лише
15
+ * на асинхронному шляху генерації.
16
+ */
17
+
18
+ /** Кеш ініціалізації на процес: cwd → мапа розширення → тип-мітка. */
19
+ const EXT_CACHE = new Map()
20
+ /** Кеш завантажених екстракторів: cwd → мапа розширення → модуль-екстрактор. */
21
+ const EXTRACTOR_CACHE = new Map()
22
+
23
+ /**
24
+ * Синхронно читає `plugins` з `.n-rules.json` (легка версія: лише це поле,
25
+ * без merge/схем — той самий контракт, що `readNRulesConfigLite`, але sync
26
+ * для hot-path).
27
+ * @param {string} cwd корінь репозиторію
28
+ * @returns {{ plugins?: string[] }} конфіг-стаб для resolvePlugins
29
+ */
30
+ function readPluginsConfigSync(cwd) {
31
+ for (const name of ['.n-rules.json', '.n-cursor.json']) {
32
+ const p = join(cwd, name)
33
+ if (!existsSync(p)) continue
34
+ try {
35
+ const parsed = JSON.parse(readFileSync(p, 'utf8'))
36
+ return { plugins: parsed.plugins }
37
+ } catch {
38
+ return {}
39
+ }
40
+ }
41
+ return {}
42
+ }
43
+
44
+ /**
45
+ * Мапа doc-files-розширень від плагінів для репо (`.rs` → 'Rust Module', …),
46
+ * з кешем на процес. Порожня мапа — жодний активний плагін їх не декларує.
47
+ * @param {string} cwd корінь репозиторію
48
+ * @returns {Record<string, string>} розширення → тип-мітка
49
+ */
50
+ export function pluginDocFilesExtensions(cwd) {
51
+ const cached = EXT_CACHE.get(cwd)
52
+ if (cached) return cached
53
+ const out = getDocFilesExtensions(cwd, readPluginsConfigSync(cwd))
54
+ EXT_CACHE.set(cwd, out)
55
+ return out
56
+ }
57
+
58
+ /**
59
+ * Асинхронно вантажить мовні екстрактори з handler-модулів плагінів
60
+ * (extension-point `doc-files`): default-експорт
61
+ * `{ id, extensions: string[], extractFacts?, extractUnits? }`.
62
+ * Битий модуль — мовчазний пропуск (генерація тоді йде whole-file шляхом).
63
+ * @param {string} cwd корінь репозиторію
64
+ * @returns {Promise<Map<string, { id: string, extractFacts?: (src: string, relPath: string) => object, extractUnits?: (src: string, relPath: string) => Array<object>|null }>>} розширення → екстрактор
65
+ */
66
+ export async function loadDocFilesExtractors(cwd) {
67
+ const cached = EXTRACTOR_CACHE.get(cwd)
68
+ if (cached) return cached
69
+ const map = new Map()
70
+ for (const handler of getHandlers(cwd, readPluginsConfigSync(cwd), 'doc-files')) {
71
+ try {
72
+ // eslint-disable-next-line no-unsanitized/method
73
+ const mod = await import(pathToFileURL(handler.modulePath).href)
74
+ const extractor = mod.default
75
+ if (!extractor || typeof extractor !== 'object' || !Array.isArray(extractor.extensions)) continue
76
+ for (const ext of extractor.extensions) map.set(ext, extractor)
77
+ } catch {
78
+ /* битий handler — пропускаємо, доки згенеруються whole-file шляхом */
79
+ }
80
+ }
81
+ EXTRACTOR_CACHE.set(cwd, map)
82
+ return map
83
+ }
84
+
85
+ /** Скидає кеші (для тестів). */
86
+ export function clearDocFilesLangCache() {
87
+ EXT_CACHE.clear()
88
+ EXTRACTOR_CACHE.clear()
89
+ }
@@ -5,9 +5,14 @@ import { execFileSync } from 'node:child_process'
5
5
 
6
6
  import { isDocgenIgnored } from '../docgen-ignore/main.mjs'
7
7
  import { parseDocFrontmatter, readDocCrc, staleness } from '../docgen-crc/main.mjs'
8
+ import { pluginDocFilesExtensions } from './lang-extensions.mjs'
8
9
 
9
- /** Кодові розширення, для яких генеруємо документацію. */
10
- const SOURCE_EXTENSIONS = new Set(['.js', '.mjs', '.ts', '.vue', '.py', '.rs'])
10
+ /**
11
+ * Вбудовані кодові розширення, для яких генеруємо документацію. Мовні
12
+ * розширення поза JS-екосистемою (`.rs`, `.py`) декларують lang-плагіни
13
+ * (`n-rules.contributes.docFiles.extensions`) — див. lang-extensions.mjs.
14
+ */
15
+ const SOURCE_EXTENSIONS = new Set(['.js', '.mjs', '.ts', '.vue'])
11
16
 
12
17
  /** `*.test.*`, `*.spec.*`, `*.stories.*` — тести й Storybook CSF-файли, документувати не треба. */
13
18
  const TEST_FILE_RE = /\.(?:test|spec|stories)\.[^.]+$/u
@@ -24,14 +29,18 @@ function isSystemWideDocsRoot(root) {
24
29
  }
25
30
 
26
31
  /**
27
- * Чи є файл кодовим джерелом для документування.
32
+ * Чи є файл кодовим джерелом для документування. З `root` — враховує і
33
+ * розширення активних lang-плагінів; без нього — лише вбудовані JS-екосистемні.
28
34
  * @param {string} fileName базове ім'я файлу
35
+ * @param {string} [root] корінь репозиторію (для плагінних розширень)
29
36
  * @returns {boolean} true — документуємо; false — пропускаємо
30
37
  */
31
- export function isSourceFile(fileName) {
38
+ export function isSourceFile(fileName, root) {
32
39
  if (fileName.endsWith('.d.ts')) return false
33
40
  if (TEST_FILE_RE.test(fileName)) return false
34
- return SOURCE_EXTENSIONS.has(extname(fileName))
41
+ const ext = extname(fileName)
42
+ if (SOURCE_EXTENSIONS.has(ext)) return true
43
+ return root !== undefined && ext in pluginDocFilesExtensions(root)
35
44
  }
36
45
 
37
46
  /**
@@ -55,7 +64,7 @@ export function docPathForSource(sourcePath) {
55
64
  */
56
65
  export function isDocCandidate(root, relPath) {
57
66
  const fileName = posix.basename(relPath)
58
- if (!isSourceFile(fileName)) return false
67
+ if (!isSourceFile(fileName, root)) return false
59
68
  if (isSystemWideDocsRoot(root) && posix.dirname(relPath) === '.') return false
60
69
  return !isDocgenIgnored(relPath)
61
70
  }
@@ -203,7 +212,7 @@ export function scanForDocFiles(root) {
203
212
  if (entry.isDirectory()) {
204
213
  if (isDocgenIgnored(relPath, 'dir')) continue
205
214
  walk(fullPath)
206
- } else if (entry.isFile() && isSourceFile(entry.name)) {
215
+ } else if (entry.isFile() && isSourceFile(entry.name, root)) {
207
216
  if (isSystemWideDocsRoot(root) && dirname(relPath) === '.') continue
208
217
  const sourcePath = relPath.split(sep).join('/')
209
218
  if (isDocgenIgnored(sourcePath)) continue
@@ -3,9 +3,8 @@ type: JS Module
3
3
  title: main.mjs
4
4
  resource: npm/rules/doc-files/units/main.mjs
5
5
  docgen:
6
- crc: b304d152
6
+ crc: 8acdefc1
7
7
  model: omlx/gemma-4-e4b-it-OptiQ-4bit
8
- tier: local-min
9
8
  score: 100
10
9
  issues: judge:inaccurate:0.98
11
10
  judgeModel: openai-codex/gpt-5.4-mini
@@ -1,13 +1,13 @@
1
1
  /** @see ./docs/units.md */
2
2
 
3
3
  import { extractUnitsJs } from '../units-js/main.mjs'
4
- import { extractUnitsRs } from '../units-rs/main.mjs'
5
4
 
6
5
  const JS_EXT = new Set(['js', 'mjs', 'ts', 'jsx', 'tsx', 'cts', 'mts'])
7
6
 
8
7
  /**
9
8
  * Мовно-агностичний фасад юніт-шару. Диспатчить за розширенням:
10
- * js/mjs/ts → oxc AST; rs regex+brace-counting; vue/py null (whole-file шлях).
9
+ * js/mjs/ts → oxc AST; інші мови (rs lang-плагін, extension-point
10
+ * `doc-files`.extractUnits; vue/py) → null (whole-file шлях).
11
11
  * @param {string} src вміст файлу
12
12
  * @param {string} relPath шлях файлу
13
13
  * @returns {Array<object>|null} юніти або null, якщо мова ще не підтримана / файл не парситься
@@ -15,6 +15,5 @@ const JS_EXT = new Set(['js', 'mjs', 'ts', 'jsx', 'tsx', 'cts', 'mts'])
15
15
  export function extractUnits(src, relPath) {
16
16
  const ext = (relPath.split('.').pop() || '').toLowerCase()
17
17
  if (JS_EXT.has(ext)) return extractUnitsJs(src, relPath)
18
- if (ext === 'rs') return extractUnitsRs(src, relPath)
19
18
  return null
20
19
  }
@@ -3,7 +3,7 @@ type: JS Module
3
3
  title: resolve-plugins.mjs
4
4
  resource: npm/scripts/lib/resolve-plugins.mjs
5
5
  docgen:
6
- crc: 17248373
6
+ crc: 0d197e39
7
7
  ---
8
8
 
9
9
  Резолв плагінів @7n/rules: визначає, які пакети-плагіни активні у проєкті, де їхні `rules/`-каталоги, які capabilities вони надають і які handlers надають.