@7n/rules 1.22.0 → 1.23.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.23.1] - 2026-07-18
4
+
5
+ ### Changed
6
+
7
+ - Оновлено peer-версію @earendil-works/pi-coding-agent/pi-ai до ~0.80.10 — підтримка gpt-5.6-sol через openai-codex backend
8
+
9
+ ## [1.23.0] - 2026-07-18
10
+
11
+ ### Changed
12
+
13
+ - Фаза 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-плагіном
14
+
3
15
  ## [1.22.0] - 2026-07-18
4
16
 
5
17
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@7n/rules",
3
- "version": "1.22.0",
3
+ "version": "1.23.1",
4
4
  "description": "CLI еталонних правил і skills (префікс n-): синк у репозиторій, дельта-lint, конформність",
5
5
  "keywords": [
6
6
  "cli",
@@ -86,8 +86,8 @@
86
86
  },
87
87
  "optionalDependencies": {
88
88
  "@agentclientprotocol/claude-agent-acp": "^0.59.0",
89
- "@earendil-works/pi-ai": "0.80.2",
90
- "@earendil-works/pi-coding-agent": "0.80.2"
89
+ "@earendil-works/pi-ai": "0.80.10",
90
+ "@earendil-works/pi-coding-agent": "0.80.10"
91
91
  },
92
92
  "engines": {
93
93
  "bun": ">=1.3",
@@ -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 надають.
@@ -56,7 +56,7 @@ function hasLangSignal(projectRoot, signal, maxDepth) {
56
56
  let level = [projectRoot]
57
57
  for (let depth = 0; depth <= maxDepth && level.length > 0; depth++) {
58
58
  if (level.some(dir => existsSync(join(dir, signal)))) return true
59
- if (depth < maxDepth) level = level.flatMap(listScannableSubdirs)
59
+ if (depth < maxDepth) level = level.flatMap(dir => listScannableSubdirs(dir))
60
60
  }
61
61
  return false
62
62
  }
@@ -192,7 +192,7 @@ export function ensurePluginInstalled(projectRoot, packageName) {
192
192
  * @property {string} name npm-ім'я пакета (`@7n/rules` для ядра)
193
193
  * @property {string} packageRoot абсолютний корінь пакета
194
194
  * @property {string} rulesDir абсолютний шлях до `rules/` пакета
195
- * @property {{ capabilities: string[], contributes: { rules?: boolean, handlers?: Record<string, string> } }} manifest нормалізований блок `n-rules` з package.json плагіна
195
+ * @property {{ capabilities: string[], contributes: { rules?: boolean, handlers?: Record<string, string>, docFilesExtensions?: Record<string, string> } }} manifest нормалізований блок `n-rules` з package.json плагіна
196
196
  */
197
197
 
198
198
  /**
@@ -202,7 +202,7 @@ export function ensurePluginInstalled(projectRoot, packageName) {
202
202
  */
203
203
  function readPluginManifest(packageRoot) {
204
204
  /** @type {ResolvedPlugin['manifest']} */
205
- const fallback = { capabilities: [], contributes: { rules: true, handlers: {} } }
205
+ const fallback = { capabilities: [], contributes: { rules: true, handlers: {}, docFilesExtensions: {} } }
206
206
  try {
207
207
  const pkg = JSON.parse(readFileSync(join(packageRoot, 'package.json'), 'utf8'))
208
208
  const raw = pkg?.['n-rules']
@@ -213,7 +213,17 @@ function readPluginManifest(packageRoot) {
213
213
  contributes.handlers && typeof contributes.handlers === 'object' && !Array.isArray(contributes.handlers)
214
214
  ? Object.fromEntries(Object.entries(contributes.handlers).filter(([, v]) => typeof v === 'string'))
215
215
  : {}
216
- return { capabilities, contributes: { rules: contributes.rules !== false, handlers } }
216
+ // Декларативні doc-files-розширення (`docFiles.extensions`: '.rs' 'Rust Module')
217
+ // саме в маніфесті, а не в handler-модулі, щоб hot-path (hook per-file) читав їх
218
+ // синхронно без динамічного import.
219
+ const rawDocFiles = contributes.docFiles && typeof contributes.docFiles === 'object' ? contributes.docFiles : {}
220
+ const docFilesExtensions =
221
+ rawDocFiles.extensions && typeof rawDocFiles.extensions === 'object' && !Array.isArray(rawDocFiles.extensions)
222
+ ? Object.fromEntries(
223
+ Object.entries(rawDocFiles.extensions).filter(([k, v]) => k.startsWith('.') && typeof v === 'string')
224
+ )
225
+ : {}
226
+ return { capabilities, contributes: { rules: contributes.rules !== false, handlers, docFilesExtensions } }
217
227
  } catch {
218
228
  return fallback
219
229
  }
@@ -295,6 +305,22 @@ export function getActiveCapabilities(projectRoot, config, options = {}) {
295
305
  return caps
296
306
  }
297
307
 
308
+ /**
309
+ * Агреговані doc-files-розширення активних плагінів: '.rs' → 'Rust Module' тощо.
310
+ * Синхронно і без установки (hot-path hook) — лише вже встановлені плагіни.
311
+ * @param {string} projectRoot корінь репозиторію
312
+ * @param {{ plugins?: unknown } | null | undefined} config розпарсений `.n-rules.json`
313
+ * @returns {Record<string, string>} мапа розширення → тип-мітка доки
314
+ */
315
+ export function getDocFilesExtensions(projectRoot, config) {
316
+ /** @type {Record<string, string>} */
317
+ const out = {}
318
+ for (const p of resolvePlugins(projectRoot, config, { allowInstall: false, quiet: true })) {
319
+ Object.assign(out, p.manifest.contributes.docFilesExtensions)
320
+ }
321
+ return out
322
+ }
323
+
298
324
  /**
299
325
  * Handlers для extension-point правила ядра (v1 — лише API; перший споживач — v2).
300
326
  * @param {string} projectRoot корінь репозиторію
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: doc-files
3
3
  description: >-
4
- Обовʼязковий крок задачі (як lint): для кожного зміненого/нового кодового файлу (js/mjs/ts/vue/py) JS-оркестрована генерація лаконічної поведінкової української md-документації у теку docs/ поряд із кодом, зі звіркою застарілості за CRC у frontmatter
4
+ Обовʼязковий крок задачі (як lint): для кожного зміненого/нового кодового файлу (js/mjs/ts/vue вбудовано; rs/py — через lang-плагіни) JS-оркестрована генерація лаконічної поведінкової української md-документації у теку docs/ поряд із кодом, зі звіркою застарілості за CRC у frontmatter
5
5
  version: '1.0'
6
6
  ---
7
7
 
@@ -1,3 +0,0 @@
1
- {
2
- "$schema": "https://unpkg.com/@7n/rules/schemas/concern.json"
3
- }
@@ -1,36 +0,0 @@
1
- ---
2
- type: JS Module
3
- title: main.mjs
4
- resource: npm/rules/doc-files/units-rs/main.mjs
5
- docgen:
6
- crc: 45adb1a4
7
- model: omlx/gemma-4-e4b-it-OptiQ-4bit
8
- score: 100
9
- issues: judge:inaccurate:0.98
10
- judgeModel: openai-codex/gpt-5.4-mini
11
- ---
12
-
13
- ## Огляд
14
-
15
- Огляд:
16
- Цей файл відповідає за аналіз коду з метою вилучення інформації про публічно доступні елементи, зокрема, про структуру та методи. Він ідентифікує відображені елементи на підставі їхніх атрибутів або публічності, збираючи повні описи та тіла їхніх декларацій. Зокрема, функція `extractUnitsRs` забезпечує можливість автоматично генерувати технічну документацію, детально описуючи призначення кожного публічного елемента.
17
-
18
- ## Поведінка
19
-
20
- Поведінка:
21
-
22
- 1. Функція `extractUnitsRs` аналізує вміст файлу з кодом.
23
- 2. Функція визначає всі визначені верхнерівневі та методи в блоках `impl`.
24
- 3. Визначення відображеності (exported) елементів відбувається на основі атрибутів, таких як `tauri::command`, або якщо функція звичайного виклику є вказана як публічна.
25
- 4. Для кожного знайденого елемента видобувається повний текстовий опис, що йде безпосередньо перед його декларацією.
26
- 5. Для функції, структури, перерахування, трейту або типу видобувається повний вміст тіла декларації.
27
- 6. Визначається, до якого блоку `impl` належить елемент, якщо він не є верхнерівневим.
28
- 7. Для вилучених елементів ідентифікуються внутрішні виклики до інших юнітів у межах того ж файлу.
29
-
30
- ## Публічний API
31
-
32
- extractUnitsRs — витягує основні та реалізовані методи з файлів Rust.
33
-
34
- ## Гарантії поведінки
35
-
36
- - Read-only: не виконує операцій запису (ФС/БД).
@@ -1,311 +0,0 @@
1
- /** @see ./docs/units-rs.md */
2
-
3
- /**
4
- * Пропускає рядковий літерал `"..."` (з escape-послідовностями).
5
- * @param {string} src вміст файлу
6
- * @param {number} i позиція відкриваючого `"`
7
- * @returns {number} позиція ПІСЛЯ закриваючого `"`
8
- */
9
- function skipString(src, i) {
10
- i++ // відкриваючий "
11
- while (i < src.length) {
12
- if (src[i] === '\\') {
13
- i += 2
14
- continue
15
- }
16
- if (src[i] === '"') return i + 1
17
- i++
18
- }
19
- return i
20
- }
21
-
22
- /**
23
- * Пропускає рядковий (`//`) чи блочний коментар на позиції `i`.
24
- * @param {string} src вміст файлу
25
- * @param {number} i позиція `/` початку коментаря
26
- * @returns {number} позиція ПІСЛЯ коментаря, або `-1` якщо на `i` не коментар
27
- */
28
- function skipComment(src, i) {
29
- if (src[i] !== '/') return -1
30
- if (src[i + 1] === '/') {
31
- const nl = src.indexOf('\n', i)
32
- return nl === -1 ? src.length : nl + 1
33
- }
34
- if (src[i + 1] === '*') {
35
- const end = src.indexOf('*/', i + 2)
36
- return end === -1 ? src.length : end + 2
37
- }
38
- return -1
39
- }
40
-
41
- /**
42
- * Знаходить індекс закриваючої `}` для відкриваючої `{` на позиції `start`.
43
- * Правильно пропускає рядки/блочні коментарі та рядкові літерали.
44
- * @param {string} src вміст файлу
45
- * @param {number} start позиція відкриваючої `{`
46
- * @returns {number} індекс `}` або -1, якщо не знайдено
47
- */
48
- function findClosingBrace(src, start) {
49
- let depth = 0
50
- let i = start
51
- while (i < src.length) {
52
- const ch = src[i]
53
- const afterComment = skipComment(src, i)
54
- if (afterComment !== -1) {
55
- i = afterComment
56
- continue
57
- }
58
- if (ch === '"') {
59
- i = skipString(src, i)
60
- continue
61
- }
62
- if (ch === '{') {
63
- depth++
64
- i++
65
- continue
66
- }
67
- if (ch === '}') {
68
- depth--
69
- if (depth === 0) return i
70
- i++
71
- continue
72
- }
73
- i++
74
- }
75
- return -1
76
- }
77
-
78
- /**
79
- * Видобуває `///` doc-рядки безпосередньо перед рядком `lineIdx`.
80
- * Сканує назад через `///`, `#[...]` та пусті рядки.
81
- * @param {string[]} lines рядки файлу
82
- * @param {number} lineIdx рядок декларації
83
- * @returns {string} склеєний опис або ''
84
- */
85
- function docBefore(lines, lineIdx) {
86
- const doc = []
87
- for (let i = lineIdx - 1; i >= 0; i--) {
88
- const t = lines[i].trim()
89
- if (t.startsWith('///')) {
90
- doc.unshift(t.slice(3).trim())
91
- } else if (t.startsWith('#[') || t.startsWith('#![') || t === '') {
92
- // пропустити атрибути та пусті рядки
93
- } else {
94
- break
95
- }
96
- }
97
- return doc.join(' ').trim()
98
- }
99
-
100
- // Pub-items матчаться у два кроки по trim-нутому рядку (прості регекспи без
101
- // бектрекінгу): спершу опційний pub(...)-префікс, потім сама декларація.
102
- // Також ловить fn без pub (для localSymbols і impl-методів)
103
- const PUB_PREFIX_RE = /^pub(?:\([^)]*\))?\s+/
104
- const ITEM_DECL_RE = /^(?:async\s+)?(?:unsafe\s+)?(fn|struct|enum|trait|type)\s+(\w+)/
105
-
106
- // impl Type { або impl<T> Trait for Type { — теж двокроково: голова `impl<...>`,
107
- // далі тип після `for` (trait-impl) або перше слово (inherent impl)
108
- const IMPL_HEAD_RE = /^impl(?:<[^>]*>)?\s+/
109
- const IMPL_FOR_TYPE_RE = /\bfor\s+(\w+)/
110
- const TYPE_NAME_RE = /^(\w+)/
111
-
112
- // Підозрілі exposure-атрибути, що роблять непуб-fn фактично публічними
113
- const EXPOSURE_ATTR_RE = /#\[(?:tauri::command|wasm_bindgen|uniffi::export|pyo3::pyfunction|napi)/
114
-
115
- // Базовий виклик fn-імені (для call-graph всередині юніта)
116
- const CALL_RE = /\b([a-z_]\w*)\s*\(/g
117
-
118
- /**
119
- * Рахує дельту глибини `{}` в рядку, пропускаючи `//`-коментарі й рядкові літерали.
120
- * @param {string} line рядок коду
121
- * @returns {number} приріст глибини (додатний — відкрито більше, ніж закрито)
122
- */
123
- function braceDeltaInLine(line) {
124
- let delta = 0
125
- let j = 0
126
- while (j < line.length) {
127
- const ch = line[j]
128
- if (ch === '/' && line[j + 1] === '/') break
129
- if (ch === '"') {
130
- j++
131
- while (j < line.length && line[j] !== '"') {
132
- if (line[j] === '\\') j++
133
- j++
134
- }
135
- j++
136
- continue
137
- }
138
- if (ch === '{') delta++
139
- else if (ch === '}') delta--
140
- j++
141
- }
142
- return delta
143
- }
144
-
145
- /**
146
- * Оновлює impl-стек за поточним рядком: прибирає закриті impl і, якщо рядок — impl-
147
- * декларація на глибині ≤1, додає новий запис.
148
- * @param {Array<{typeName:string, openDepth:number}>} implStack стек відкритих impl (мутується)
149
- * @param {string} line сирий рядок
150
- * @param {string} trimmed рядок без лідируючих пробілів
151
- * @param {number} depth глибина ПІСЛЯ обробки рядка
152
- * @param {number} depthAtStart глибина ДО обробки рядка
153
- * @returns {void}
154
- */
155
- function updateImplStack(implStack, line, trimmed, depth, depthAtStart) {
156
- while (implStack.length > 0 && implStack.at(-1).openDepth > depth) {
157
- implStack.pop()
158
- }
159
- if (depthAtStart <= 1) {
160
- const headM = trimmed.match(IMPL_HEAD_RE)
161
- if (headM && line.includes('{')) {
162
- const rest = trimmed.slice(headM[0].length)
163
- const typeM = rest.match(IMPL_FOR_TYPE_RE) ?? rest.match(TYPE_NAME_RE)
164
- if (typeM) implStack.push({ typeName: typeM[1], openDepth: depth })
165
- }
166
- }
167
- }
168
-
169
- /**
170
- * Витягує тіло item-а (fn/struct/enum/trait) через `findClosingBrace`.
171
- * @param {string} src вміст файлу
172
- * @param {string[]} lines рядки файлу
173
- * @param {number} li індекс рядка декларації
174
- * @param {number} lineOffset зсув початку рядка в `src`
175
- * @param {string} kind вид item-а
176
- * @returns {{body:string, itemEnd:number}} тіло та зсув кінця item-а
177
- */
178
- function extractItemBody(src, lines, li, lineOffset, kind) {
179
- let body = ''
180
- let itemEnd = lineOffset + lines[li].length
181
- if (kind !== 'type') {
182
- const openBraceIdx = src.indexOf('{', lineOffset)
183
- // Шукаємо `{` не далі ніж через 3 рядки від початку декларації
184
- const threeLines = lines.slice(li, li + 3).join('\n').length
185
- if (openBraceIdx !== -1 && openBraceIdx - lineOffset <= threeLines) {
186
- const closeIdx = findClosingBrace(src, openBraceIdx)
187
- if (closeIdx !== -1) {
188
- itemEnd = closeIdx + 1
189
- body = src.slice(lineOffset, itemEnd)
190
- }
191
- }
192
- }
193
- return { body, itemEnd }
194
- }
195
-
196
- /**
197
- * Заповнює `calls` кожного юніта викликами інших юнітів цього ж файлу.
198
- * @param {Array<{name:string, body:string, calls:string[]}>} units юніти файлу (мутуються)
199
- * @returns {void}
200
- */
201
- function fillCallGraph(units) {
202
- const unitNames = new Set(units.map(u => u.name))
203
- for (const u of units) {
204
- if (!u.body) continue
205
- const calls = new Set()
206
- let cm
207
- const re = new RegExp(CALL_RE.source, 'g')
208
- while ((cm = re.exec(u.body)) !== null) {
209
- if (unitNames.has(cm[1]) && cm[1] !== u.name) calls.add(cm[1])
210
- }
211
- u.calls = [...calls]
212
- }
213
- }
214
-
215
- /**
216
- * Обробляє рядок на глибині ≤1: якщо це декларація item-а — додає юніт у `units`;
217
- * інакше скидає exposure-флаг на не-атрибутних рядках. Повертає новий стан флага.
218
- * @param {object} p параметри
219
- * @param {string} p.src вміст файлу
220
- * @param {string[]} p.lines рядки файлу
221
- * @param {number} p.li індекс рядка
222
- * @param {number} p.lineOffset зсув початку рядка в `src`
223
- * @param {number} p.depthAtStart глибина ДО обробки рядка
224
- * @param {string|null} p.currentImpl тип поточного impl або `null`
225
- * @param {boolean} p.nextFnExposed чи наступний fn exposure-exposed
226
- * @param {Array<object>} p.units акумулятор юнітів (мутується)
227
- * @returns {boolean} новий стан `nextFnExposed`
228
- */
229
- function processItemLine({ src, lines, li, lineOffset, depthAtStart, currentImpl, nextFnExposed, units }) {
230
- const line = lines[li]
231
- const trimmed = line.trimStart()
232
- const pubM = trimmed.match(PUB_PREFIX_RE)
233
- const m = (pubM ? trimmed.slice(pubM[0].length) : trimmed).match(ITEM_DECL_RE)
234
- if (!m) {
235
- // Рядок не є item — скидаємо exposure-флаг якщо не атрибут
236
- const t = line.trim()
237
- if (!t.startsWith('#[') && !t.startsWith('#![') && !t.startsWith('///') && t !== '') return false
238
- return nextFnExposed
239
- }
240
- const kind = m[1]
241
- const isPub = Boolean(pubM) || (kind === 'fn' && nextFnExposed)
242
- const doc = docBefore(lines, li)
243
- // Витягуємо тіло через findClosingBrace для fn/struct/enum/trait
244
- const { body, itemEnd } = extractItemBody(src, lines, li, lineOffset, kind)
245
- units.push({
246
- name: m[2],
247
- kind,
248
- exported: isPub,
249
- implName: depthAtStart === 1 ? currentImpl : null,
250
- span: { start: lineOffset, end: itemEnd },
251
- body,
252
- calls: [],
253
- doc
254
- })
255
- return kind === 'fn' ? false : nextFnExposed
256
- }
257
-
258
- /**
259
- * Юніт-екстрактор для `.rs` файлів.
260
- * Визначає top-level і impl-методи через підрахунок дужок по рядках.
261
- * Відомі обмеження: рядкові літерали з `{`/`}` всередині `{}` можуть дати
262
- * хибну глибину (рідкісно в реальному Rust-коді з rustfmt).
263
- * @param {string} src вміст файлу
264
- * @param {string} [_relPath] резервний (не використовується)
265
- * @returns {Array<{name:string, kind:string, exported:boolean, implName:string|null, span:{start:number,end:number}, body:string, calls:string[], doc:string}>|null} юніти файлу (fn та impl-методи) або `null`, якщо юнітів не знайдено
266
- */
267
- export function extractUnitsRs(src, _relPath) {
268
- const lines = src.split('\n')
269
- const units = []
270
- let depth = 0
271
- let lineOffset = 0
272
- // Стек відкритих impl: { typeName, openDepth }
273
- const implStack = []
274
- // Флаг: наступний fn отримує exposure (через #[tauri::command] тощо)
275
- let nextFnExposed = false
276
-
277
- for (let li = 0; li < lines.length; li++) {
278
- const line = lines[li]
279
- const depthAtStart = depth
280
- depth += braceDeltaInLine(line)
281
-
282
- updateImplStack(implStack, line, line.trimStart(), depth, depthAtStart)
283
- const currentImpl = implStack.at(-1)?.typeName ?? null
284
-
285
- // Перевіряємо exposure-атрибути
286
- if (EXPOSURE_ATTR_RE.test(line)) {
287
- nextFnExposed = true
288
- }
289
-
290
- // Елементи на глибині 0 (top-level) і 1 (всередині impl)
291
- if (depthAtStart <= 1) {
292
- nextFnExposed = processItemLine({
293
- src,
294
- lines,
295
- li,
296
- lineOffset,
297
- depthAtStart,
298
- currentImpl,
299
- nextFnExposed,
300
- units
301
- })
302
- }
303
-
304
- lineOffset += line.length + 1 // +1 для '\n'
305
- }
306
-
307
- // Базовий call-graph: виклики інших юнітів цього файлу
308
- fillCallGraph(units)
309
-
310
- return units.length > 0 ? units : null
311
- }