@7n/rules 1.22.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 +6 -0
- package/package.json +1 -1
- package/rules/doc-files/check/docs/main.md +1 -1
- package/rules/doc-files/check/main.mjs +1 -1
- package/rules/doc-files/docgen-crc/docs/main.md +1 -1
- package/rules/doc-files/docgen-crc/main.mjs +7 -6
- package/rules/doc-files/docgen-extract/docs/main.md +1 -1
- package/rules/doc-files/docgen-extract/main.mjs +0 -196
- package/rules/doc-files/docgen-gen/docs/main.md +1 -1
- package/rules/doc-files/docgen-gen/main.mjs +6 -1
- package/rules/doc-files/docgen-scan/docs/index.md +10 -0
- package/rules/doc-files/docgen-scan/docs/lang-extensions.md +34 -0
- package/rules/doc-files/docgen-scan/docs/main.md +1 -1
- package/rules/doc-files/docgen-scan/lang-extensions.mjs +89 -0
- package/rules/doc-files/docgen-scan/main.mjs +16 -7
- package/rules/doc-files/units/docs/main.md +1 -2
- package/rules/doc-files/units/main.mjs +2 -3
- package/scripts/lib/docs/resolve-plugins.md +1 -1
- package/scripts/lib/resolve-plugins.mjs +30 -4
- package/skills/doc-files/SKILL.md +1 -1
- package/rules/doc-files/units-rs/concern.json +0 -3
- package/rules/doc-files/units-rs/docs/main.md +0 -36
- package/rules/doc-files/units-rs/main.mjs +0 -311
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
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
|
+
|
|
3
9
|
## [1.22.0] - 2026-07-18
|
|
4
10
|
|
|
5
11
|
### Added
|
package/package.json
CHANGED
|
@@ -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,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
|
-
|
|
86
|
+
const ext = extname(sourcePath).toLowerCase()
|
|
87
|
+
return EXT_TYPES[ext] ?? pluginDocFilesExtensions(process.cwd())[ext] ?? 'Source File'
|
|
87
88
|
}
|
|
88
89
|
|
|
89
90
|
/**
|
|
@@ -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
|
}
|
|
@@ -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
|
-
|
|
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
|
+
- Кешує результати в межах одного прогону.
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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:
|
|
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;
|
|
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:
|
|
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
|
-
|
|
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,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
|
-
}
|