@7n/rules 1.49.26 → 1.50.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.
@@ -3,50 +3,62 @@ type: JS Module
3
3
  title: main.mjs
4
4
  resource: npm/rules/doc-files/docgen-scan/main.mjs
5
5
  docgen:
6
- crc: 8729f94f
7
- model: omlx/gemma-4-e4b-it-OptiQ-4bit
6
+ crc: 9db666fe
7
+ model: openai-codex/gpt-5.5
8
+ tier: cloud-avg
8
9
  score: 100
9
- issues: judge:inaccurate:0.99
10
+ issues: judge-refine:kept-original,judge:inaccurate:0.98
11
+ judgeModel: openai-codex/gpt-5.4-mini
10
12
  ---
11
13
 
12
14
  ## Огляд
13
15
 
14
- Модуль визначає, які файли є джерелами та обчислює відповідні шляхи для їхньої документації. Він сканує систему на наявність ізольованих або застарілих документів і надає засоби для ініціалізації сканування та перевірки якості існуючих документів.
16
+ Файл сканує дерево проєкту й класифікує стан сусідньої поведінкової документації в `docs/` без власних операцій запису. Він визначає кодові кандидати через активні lang-плагіни, поважає `.gitignore`, пропускає тести й службові дерева, а для згенерованої документації оцінює docgen-CRC з урахуванням повʼязаних usage-сценаріїв.
17
+
18
+ Публічні API-анкори: `isSourceFile`, `docPathForSource`, `isDocCandidate`, `scanForDocFiles`, `scanOrphanedDocs`, `describeFile`, `resolveRoot`.
19
+
20
+ Стан документації розрізняє відсутню доку як `stale:missing`, свіжу як `stale:false`, змінену відносно CRC як `crc-mismatch`, а ручні або чужі документи як `foreign:true` без позначення застарілості. Docgen-документи з CRC лишаються `foreign:false` і перевіряються за CRC-семантикою. Orphan-сканування окремо знаходить документацію, для якої зник source, але не вважає orphan ручні документи, Directory Index і docs у службових деревах.
15
21
 
16
22
  ## Поведінка
17
23
 
18
- isSourceFile визначає, чи є ім'я файлу кодовим джерелом для генерації документації, виключаючи файли тестів і файли декларацій типів; перелік кодових розширень береться ВИКЛЮЧНО з декларацій активних lang-плагінів (`contributes.docFiles.extensions`: js/mjs/ts/vue lang-js, `.rs`/`.py` lang-rust/lang-python), у ядрі вбудованих розширень немає (фаза 5b) — без активного lang-плагіна скан не бачить жодного джерела.
19
- docPathForSource обчислює очікуваний шлях до markdown-документа для заданого кодового джерельного шляху, розміщуючи його у теці `docs` поруч із джерелом.
20
- isDocCandidate визначає, чи повинен файл підлягати документуванню, перевіряючи його тип, статус тесту, статус ігнорування та чи не є він частиною системних документації.
21
- describeFile описує кодовий файл, надаючи шлях джерела та шлях до відповідного документа, а також статус його застарілості за CRC. Якщо док-файл існує, але без `docgen:`-CRC у frontmatter (рукописна дока), файл позначається `foreign: true` і НЕ вважається застарілим — людський зміст рахується чинною документацією і мовчки не перезаписується (перезапис лише explicit `--overwrite` у batch-CLI).
22
- scanOrphanedDocs знаходить markdown-документи, які мають метадані про джерело, але відповідний код-файл більше не існує.
23
- scanForDocFiles обходить дерево від кореня і повертає список всіх кодових файлів, для яких може бути згенерована документація, з інформацією про їхній статус застарілості, виключаючи ті, що ігноруються через Git.
24
- resolveRoot визначає абсолютний кореневий каталог для сканування, беручи його з аргументів або використовуючи поточний робочий каталог.
25
- runDocFilesScanCli сканує вказане дерево та виводить у форматі JSON список усіх кодових файлів з їхнім статусом застарілості.
26
- runDocFilesCheckCli виконує перевірку на застарілість документації: може перевіряти один файл із stdin, групу змінених файлів через `git diff`, або звітувати про неякісні документи, блокуючи процес при знаходженні застарілого коду.
24
+ `resolveRoot` визначає корінь обходу з CLI або поточної теки. Від цього кореня весь сканер читає конфігурацію активних lang-плагінів, застосовує правила пропуску й повертає відносні шляхи результатів.
27
25
 
28
- ## Публічний API
26
+ `scanForDocFiles` є основним потоком: обходить дерево від кореня, відсіює службові й ignored-дерева, root-level файли в system-wide docs layout, тести, `.d.ts` і некодові файли. Статус кандидата узгоджується через `isDocCandidate`, а кодове розширення — через `isSourceFile`. Розширення не вбудовані в ядро: JS/Vue, Python і Rust-файли стають джерелами лише тоді, коли їх декларують активні lang-плагіни; без такого плагіна файл не документується.
27
+
28
+ Для кожного кандидата `docPathForSource` спрямовує документацію в сусідню теку `docs/` зі stem імені джерела. Далі `describeFile` порівнює стан документа з джерелом і повертає, чи потрібне оновлення: відсутня документація дає `missing`, зміна джерела або повʼязаного usage-сценарію дає `crc-mismatch`, збіг CRC означає актуальний документ.
29
29
 
30
- isSourceFile визначає, чи є файл кодовим джерелом, що підлягає документуванню.
31
- docPathForSource — розраховує шлях до відповідного Markdown-документа, розміщуючи його в теці `docs/` поруч із кодом.
32
- isDocCandidate вирішує, чи повинен файл підлягати документуванню: перевіряє розширення, виключення тестів, ігнорованих файлів та системних кореневих доків.
33
- describeFile — створює опис одного кодового файлу, включаючи його розташування, шлях доки, статус застарілості за CRC та ознаку `foreign` (рукописна дока без docgen-frontmatter — не ціль генерації).
34
- scanOrphanedDocs шукає Markdown-документи, у яких вказано неіснуючий кодовий файл через `resource:` та `docgen.crc` у метаданих.
35
- scanForDocFiles — рекурсивно збирає всі кодові файли з дерева, повертаючи їх разом із статусом застарілості, фільтруючи за допомогою `git check-ignore`.
36
- resolveRoot визначає корінь проекту для сканування, використовуючи аргумент `--root` або поточний робочий каталог.
37
- runDocFilesScanCli — сканує дерево та виводить у форматі JSON список усіх кодових файлів зі статусом застарілості.
38
- runDocFilesCheckCli — виконує перевірку на застарілість доків для хуків та CLI-інструментів.
30
+ Рукописна документація має пріоритет над автоматичною генерацією. Якщо очікуваний doc-файл уже існує, але не має docgen-CRC у frontmatter, `describeFile` позначає його як foreign і не вважає stale. Це покриває як документи без frontmatter, так і документи з людським frontmatter без docgen-CRC; такі файли не мають мовчки перезаписуватись звичайним скануванням.
31
+
32
+ `scanForDocFiles` поважає `.gitignore`: ignored source-файли не потрапляють у результат, а шляхи без ignore-маркера залишаються кандидатами. Якщо git-контекст недоступний або ignored-шляхів немає, сканування продовжується без помилки.
33
+
34
+ `scanOrphanedDocs` працює окремим потоком очищення: шукає лише згенеровані doc-файли з resource і docgen-CRC та повідомляє ті, для яких source вже зник. Directory Index-документи з resource, що закінчується на `/`, ручні документи без CRC або без resource, а також документи всередині `node_modules` не вважаються orphan.
35
+
36
+ ## Публічний API
39
37
 
40
- Режими роботи:
41
- --hook перевіряє один файл, отриманий із вхідних даних, у контексті хука після використання інструменту.
42
- --git порівнює з поточним станом Git, блокуючи процес, якщо знайдено застарілі доки, перевищуючи встановлений поріг.
43
- --degraded виводить інформаційний звіт про доки, які не відповідають встановленому порогу.
44
- <paths...>обробляє перелічені користувачем шляхи як джерела для документування.
38
+ - isSourceFile — Чи є файл кодовим джерелом для документування. Розширення декларують ЛИШЕ
39
+ активні lang-плагіни (`n-rules.contributes.docFiles.extensions` js/mjs/ts/vue
40
+ дає `@7n/rules-lang-js`, .rs/.py lang-rust/lang-python); у ядрі вбудованих
41
+ розширень немає (фаза 5b spec lang-plugins-extraction).
42
+ - docPathForSource Обчислює шлях md-документа для кодового файлу: тека `docs/` поряд із джерелом.
43
+ Якщо `sourcePath` відносний, `docPath` теж відносний; якщо абсолютний — абсолютний.
44
+ - isDocCandidate — Чи кодовий файл `relPath` (posix, від кореня) підлягає документуванню:
45
+ правильне розширення, не тест, не в ignore-дереві, не кореневий system-wide docs.
46
+ - describeFile — Описує один кодовий файл: шлях джерела, шлях доки, стан застарілості за CRC.
45
47
 
46
- Вихідний код (Exit 2) вказує на знаходження застарілих доків (блокування хука); вихідний код (Exit 0) — вказує на відсутність проблем або успішне проходження понад встановлений поріг.
48
+ `foreign: true`docPath існує, але БЕЗ `docgen:`-CRC у frontmatter: рукописна
49
+ (людська) дока. Така дока вважається чинною документацією файлу (`stale: false`) —
50
+ генерація її мовчки не перезаписує (перезапис лише explicit `--overwrite`, який
51
+ бере всі цілі без фільтра). Живий кейс: `npm/docs/index.md` — людський зміст модуля
52
+ у проєкті-споживачі; сканер бачив його як `missing` і затирав чат-філером моделі.
53
+ - scanOrphanedDocs — Знаходить "сирітські" доки: `docs/<stem>.md` із `resource:` + `docgen.crc` у frontmatter,
54
+ у яких відповідний source-файл (resource:) вже не існує. Перевіряє лише файли,
55
+ згенеровані `fix-doc-files` (наявність `docgen.crc` у frontmatter). Directory Index
56
+ (resource із `/` на кінці) та ручні доки без `resource:` або без CRC — ігноруються.
57
+ - scanForDocFiles — Рекурсивно обходить дерево від `root`, повертає кодові файли зі станом застарілості.
58
+ Синхронний `readdirSync` — детермінований порядок без гонок; обсяг дерева це дозволяє.
59
+ Поверх `DOCGEN_IGNORE_GLOBS` відсіює ще й те, що в `.gitignore` (через git check-ignore).
60
+ - resolveRoot — Парсить `--root <dir>` з argv; default — cwd.
47
61
 
48
62
  ## Гарантії поведінки
49
63
 
50
- - Read-only: не виконує операцій запису (ФС/БД).
51
- - Перехоплює помилки і не пропускає винятків назовні (fail-safe).
52
- - За певних помилок повертає порожнє значення (напр. `null`) замість винятку.
64
+ - Власних операцій запису (ФС/БД) у файлі немає; виклики імпортованих модулів можуть писати.
@@ -6,6 +6,7 @@ import { execFileSync } from 'node:child_process'
6
6
  import { isDocgenIgnored } from '../docgen-ignore/main.mjs'
7
7
  import { parseDocFrontmatter, readDocCrc, staleness } from '../docgen-crc/main.mjs'
8
8
  import { pluginDocFilesExtensions } from './lang-extensions.mjs'
9
+ import { buildTestEvidenceIndex } from '../docgen-test-context/main.mjs'
9
10
 
10
11
  /** `*.test.*`, `*.spec.*`, `*.stories.*` — тести й Storybook CSF-файли, документувати не треба. */
11
12
  const TEST_FILE_RE = /\.(?:test|spec|stories)\.[^.]+$/u
@@ -72,15 +73,16 @@ export function isDocCandidate(root, relPath) {
72
73
  * у проєкті-споживачі; сканер бачив його як `missing` і затирав чат-філером моделі.
73
74
  * @param {string} root абсолютний корінь
74
75
  * @param {string} sourcePath posix-шлях джерела від кореня
76
+ * @param {ReturnType<typeof buildTestEvidenceIndex>|null} [testIndex] source↔tests index
75
77
  * @returns {{sourcePath:string, docPath:string, stale:boolean, reason:'missing'|'crc-mismatch'|null, foreign:boolean}} опис файлу
76
78
  */
77
- export function describeFile(root, sourcePath) {
79
+ export function describeFile(root, sourcePath, testIndex = null) {
78
80
  const docPath = docPathForSource(sourcePath)
79
81
  const docAbsPath = join(root, docPath)
80
82
  if (existsSync(docAbsPath) && readDocCrc(docAbsPath) === null) {
81
83
  return { sourcePath, docPath, stale: false, reason: null, foreign: true }
82
84
  }
83
- const { stale, reason } = staleness(join(root, sourcePath), docAbsPath)
85
+ const { stale, reason } = staleness(join(root, sourcePath), docAbsPath, testIndex)
84
86
  return { sourcePath, docPath, stale, reason, foreign: false }
85
87
  }
86
88
 
@@ -190,6 +192,7 @@ function gitIgnoredPaths(root, relPaths) {
190
192
  */
191
193
  export function scanForDocFiles(root) {
192
194
  const results = []
195
+ const testIndex = buildTestEvidenceIndex(root)
193
196
 
194
197
  /** @param {string} dir поточний каталог обходу */
195
198
  function walk(dir) {
@@ -209,7 +212,7 @@ export function scanForDocFiles(root) {
209
212
  if (isSystemWideDocsRoot(root) && dirname(relPath) === '.') continue
210
213
  const sourcePath = relPath.split(sep).join('/')
211
214
  if (isDocgenIgnored(sourcePath)) continue
212
- results.push(describeFile(root, sourcePath))
215
+ results.push(describeFile(root, sourcePath, testIndex))
213
216
  }
214
217
  }
215
218
  }
@@ -0,0 +1,9 @@
1
+ ---
2
+ type: Directory Index
3
+ title: npm/rules/doc-files/docgen-test-context
4
+ resource: npm/rules/doc-files/docgen-test-context/
5
+ ---
6
+
7
+ | Файл | Тип |
8
+ | ------------------- | --------- |
9
+ | [main.mjs](main.md) | JS Module |
@@ -0,0 +1,57 @@
1
+ ---
2
+ type: JS Module
3
+ title: main.mjs
4
+ resource: npm/rules/doc-files/docgen-test-context/main.mjs
5
+ docgen:
6
+ crc: 78bc0ee9
7
+ model: openai-codex/gpt-5.5
8
+ tier: cloud-avg
9
+ score: 100
10
+ issues: judge:error
11
+ judgeModel: openai-codex/gpt-5.4-mini
12
+ ---
13
+
14
+ ## Огляд
15
+
16
+ Файл визначає docgen test-файли, будує індекс підтверджень із тестів, знаходить тестові підтвердження для source-файлів, рендерить сценарії для документації та визначає source-файли, повʼязані з тестом, через `isDocgenTestFile`, `buildTestEvidenceIndex`, `testEvidenceForSource`, `renderTestScenarios`, `sourceFilesForTest`.
17
+
18
+ Він існує, щоб документація могла посилатися на поведінку, підтверджену тестами, без зупинки процесу через нерозвʼязані або помилкові звʼязки. Локальні fail-safe гілки не дають окремим помилкам аналізу зірвати генерацію; інші помилки можуть поширюватися назовні.
19
+
20
+ ## Поведінка
21
+
22
+ isDocgenTestFile визначає, чи файл може бути джерелом підтверджених usage-сценаріїв для документації. Це перший фільтр потоку: до подальшого аналізу потрапляють лише окремі test/spec-файли, тоді як вбудовані Rust unit-тести залишаються частиною самого source-файлу.
23
+
24
+ buildTestEvidenceIndex обходить репозиторій, знаходить релевантні test/spec-файли та будує стабільний індекс звʼязків між source-файлами й тестами. Звʼязок вважається підтвердженим лише тоді, коли тест посилається на реальний файл через relative string literal і цей звʼязок схожий саме на тестування відповідного source, а не на допоміжний import.
25
+
26
+ testEvidenceForSource читає індекс для конкретного source-файлу й перетворює знайдені тестові підтвердження на дані для документації та детермінований payload для перевірки актуальності. Тестовий код не передається в LLM prompt: у документацію потрапляють лише дослівні назви підтверджених сценаріїв, підготовлені для окремого JS-рендеру.
27
+
28
+ renderTestScenarios приймає вже підготовлені тестові підтвердження та детерміновано формує компактний Markdown-фрагмент. Він не вигадує поведінку й не перефразовує тестові назви, а лише обмежує обсяг виводу, щоб документація не дублювала весь test-suite.
29
+
30
+ sourceFilesForTest використовує той самий індекс у зворотному напрямку: для зміненого test/spec-файлу повертає source-файли, документацію яких потрібно вважати потенційно застарілою.
31
+
32
+ Локальні fail-safe гілки під час аналізу не дають непідтвердженим або нерозвʼязаним звʼязкам потрапити у результат і не зупиняють генерацію документації. Власних записів у файлову систему чи базу даних цей модуль не виконує.
33
+
34
+ ## Публічний API
35
+
36
+ - isDocgenTestFile — Чи шлях має форму окремого test/spec-файлу, який може описувати usage-сценарії.
37
+ Rust unit-тести всередині source-файлу вже входять до самого джерела.
38
+ - buildTestEvidenceIndex — Будує один source↔tests index на репозиторій. Зв'язок вважається доведеним
39
+ лише через relative string literal, що резолвиться у реальний файл.
40
+ - testEvidenceForSource — Формує дані для JS-рендеру сценаріїв і детермінований payload для CRC.
41
+ Test-код не потрапляє до LLM prompt: опис тестового usage лишається дослівним.
42
+ - renderTestScenarios — Детерміновано рендерить компактні підтверджені тестами сценарії у Markdown.
43
+ Назви походять безпосередньо з `describe`/`test`/`it`, тому LLM не може їх
44
+ перефразувати або додати неіснуючу поведінку; показуємо до пʼяти прикладів,
45
+ а решту чесно рахуємо, щоб не дублювати весь test-suite у документації.
46
+ - sourceFilesForTest — Source-файли, на які посилається конкретний змінений test/spec-файл.
47
+
48
+ ## Сценарії використання
49
+
50
+ - `npm/rules/doc-files/docgen-test-context/tests/main.test.mjs` (isDocgenTestFile; buildTestEvidenceIndex) — розпізнає JS/TS test/spec і Python test naming; звичайний source-файл не є тестом; звʼязує source лише з тестом, що реально посилається на нього; інший сценарій; підтримує import без розширення і vi.mock relative reference; ще 4
51
+ - `npm/rules/doc-files/tests/main.test.mjs` (lint — детект (read-only detector)) — ci (files=undefined): ловить відсутню доку у дереві; ci: свіжа дока → 0 violations; quick: змінене джерело без доки → violation; порожній набір → 0; quick: реверс-мапінг — змінена дока веде до перевірки джерела; quick: ігнорує test-файл без звʼязку із source; ще 5
52
+
53
+ ## Гарантії поведінки
54
+
55
+ - Власних операцій запису (ФС/БД) у файлі немає; виклики імпортованих модулів можуть писати.
56
+ - Містить локальні fail-safe гілки; інші помилки можуть поширюватися назовні.
57
+ - Деякі локальні fail-safe гілки повертають порожнє значення (напр. `null`) замість винятку.
@@ -0,0 +1,212 @@
1
+ /**
2
+ * @see ./docs/main.md
3
+ */
4
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'
5
+ import { basename, dirname, extname, join, relative, resolve, sep } from 'node:path'
6
+
7
+ import { isDocgenIgnored } from '../docgen-ignore/main.mjs'
8
+
9
+ const JS_TEST_RE = /\.(?:test|spec)\.(?:[cm]?[jt]sx?)$/u
10
+ const RELATIVE_LITERAL_RE = /(['"])(\.{1,2}\/[^'"\n]+)\1/gu
11
+ const SCENARIO_RE = /\b(describe|test|it)\s*\(\s*['"`]([^'"`\n]{1,200})['"`]/gu
12
+ const QUERY_OR_HASH_RE = /[?#]/u
13
+ const SOURCE_EXTENSIONS = Object.freeze(['.mjs', '.cjs', '.js', '.jsx', '.ts', '.tsx', '.vue', '.py', '.rs'])
14
+ const TEST_SUFFIX_RE = /\.(?:test|spec)$/u
15
+
16
+ /**
17
+ * Чи шлях має форму окремого test/spec-файлу, який може описувати usage-сценарії.
18
+ * Rust unit-тести всередині source-файлу вже входять до самого джерела.
19
+ * @param {string} fileName basename файлу
20
+ * @returns {boolean} true для JS/TS test/spec та Python test-файлів
21
+ */
22
+ export function isDocgenTestFile(fileName) {
23
+ const pythonTest = fileName.endsWith('.py') && (fileName.startsWith('test_') || fileName.endsWith('_test.py'))
24
+ return JS_TEST_RE.test(fileName) || pythonTest
25
+ }
26
+
27
+ /**
28
+ * Рекурсивно знаходить test/spec-файли, поважаючи те саме ignore-дерево, що й doc-files.
29
+ * @param {string} root корінь репозиторію
30
+ * @returns {string[]} абсолютні шляхи у стабільному порядку
31
+ */
32
+ function collectTestFiles(root) {
33
+ const out = []
34
+
35
+ /** @param {string} dir поточний каталог */
36
+ function walk(dir) {
37
+ let entries
38
+ try {
39
+ entries = readdirSync(dir, { withFileTypes: true })
40
+ } catch {
41
+ return
42
+ }
43
+ for (const entry of entries) {
44
+ const abs = join(dir, entry.name)
45
+ const rel = relative(root, abs).split(sep).join('/')
46
+ if (entry.isDirectory()) {
47
+ if (!isDocgenIgnored(rel, 'dir')) walk(abs)
48
+ } else if (entry.isFile() && isDocgenTestFile(entry.name) && !isDocgenIgnored(rel)) {
49
+ out.push(abs)
50
+ }
51
+ }
52
+ }
53
+
54
+ walk(root)
55
+ return out.toSorted()
56
+ }
57
+
58
+ /**
59
+ * Повертає наявний файл для relative module specifier-а з test-файлу.
60
+ * Підтримує explicit extension, import без розширення і directory index.
61
+ * @param {string} testAbs абсолютний шлях тесту
62
+ * @param {string} specifier relative specifier із рядкового літерала
63
+ * @returns {string|null} абсолютний шлях referenced-файлу
64
+ */
65
+ function resolveRelativeReference(testAbs, specifier) {
66
+ const clean = specifier.split(QUERY_OR_HASH_RE, 1)[0]
67
+ const base = resolve(dirname(testAbs), clean)
68
+ const candidates = [base]
69
+ if (!extname(base)) {
70
+ for (const ext of SOURCE_EXTENSIONS) candidates.push(base + ext)
71
+ for (const ext of SOURCE_EXTENSIONS) candidates.push(join(base, `index${ext}`))
72
+ }
73
+ for (const candidate of candidates) {
74
+ try {
75
+ if (existsSync(candidate) && statSync(candidate).isFile()) return resolve(candidate)
76
+ } catch {
77
+ // Файл міг зникнути між existsSync/statSync — такий reference не беремо.
78
+ }
79
+ }
80
+ return null
81
+ }
82
+
83
+ /**
84
+ * Витягує файли, на які test/spec посилається relative string literal-ом.
85
+ * Це охоплює static/dynamic import, require, vi.mock та аналогічні API без
86
+ * прив'язки до конкретного test runner-а.
87
+ * @param {string} testAbs абсолютний шлях тесту
88
+ * @param {string} content вміст тесту
89
+ * @returns {string[]} абсолютні referenced-файли
90
+ */
91
+ function referencedFiles(testAbs, content) {
92
+ const out = new Set()
93
+ for (const match of content.matchAll(RELATIVE_LITERAL_RE)) {
94
+ const referenced = resolveRelativeReference(testAbs, match[2])
95
+ if (referenced) out.add(referenced)
96
+ }
97
+ return [...out]
98
+ }
99
+
100
+ /**
101
+ * Відсіює helper imports: relative reference є необхідним, але не достатнім
102
+ * доказом, що тест описує поведінку саме цього source. Додатково вимагається
103
+ * naming (`foo.test` → `foo`) або module layout (`module/tests/*` → main/index).
104
+ * @param {string} testAbs абсолютний шлях тесту
105
+ * @param {string} sourceAbs абсолютний шлях referenced source
106
+ * @returns {boolean} true, якщо тест із високою ймовірністю є тестом source
107
+ */
108
+ function isLikelyTestSubject(testAbs, sourceAbs) {
109
+ const testStem = basename(testAbs, extname(testAbs)).replace(TEST_SUFFIX_RE, '')
110
+ const sourceStem = basename(sourceAbs, extname(sourceAbs))
111
+ if (testStem === sourceStem) return true
112
+ if (sourceStem !== 'main' && sourceStem !== 'index') return false
113
+ const sourceDir = dirname(sourceAbs)
114
+ const testRel = relative(sourceDir, testAbs).split(sep).join('/')
115
+ return testStem === basename(sourceDir) || testRel.startsWith('tests/')
116
+ }
117
+
118
+ /**
119
+ * Будує один source↔tests index на репозиторій. Зв'язок вважається доведеним
120
+ * лише через relative string literal, що резолвиться у реальний файл.
121
+ * @param {string} root корінь репозиторію
122
+ * @returns {{ root: string, bySource: Map<string, Array<{absPath:string,relPath:string,content:string}>>, byTest: Map<string,string[]> }} source↔tests index
123
+ */
124
+ export function buildTestEvidenceIndex(root) {
125
+ const normalizedRoot = resolve(root)
126
+ const bySource = new Map()
127
+ const byTest = new Map()
128
+ for (const testAbs of collectTestFiles(normalizedRoot)) {
129
+ let content
130
+ try {
131
+ content = readFileSync(testAbs, 'utf8')
132
+ } catch {
133
+ continue
134
+ }
135
+ const relPath = relative(normalizedRoot, testAbs).split(sep).join('/')
136
+ const sources = referencedFiles(testAbs, content).filter(sourceAbs => isLikelyTestSubject(testAbs, sourceAbs))
137
+ byTest.set(testAbs, sources)
138
+ for (const sourceAbs of sources) {
139
+ const tests = bySource.get(sourceAbs) ?? []
140
+ tests.push({ absPath: testAbs, relPath, content })
141
+ bySource.set(sourceAbs, tests)
142
+ }
143
+ }
144
+ return { root: normalizedRoot, bySource, byTest }
145
+ }
146
+
147
+ /**
148
+ * Витягує назви describe/test/it як короткі підтверджені usage-сценарії.
149
+ * `describe` дає контекст групи, а `test`/`it` — приклади поведінки; це дає
150
+ * змогу не перетворювати docs складного модуля на повний список unit-тестів.
151
+ * @param {string} content вміст тесту
152
+ * @returns {{ groups: string[], scenarios: string[] }} унікальні назви у порядку появи
153
+ */
154
+ function scenarioNames(content) {
155
+ const groups = []
156
+ const scenarios = []
157
+ for (const match of content.matchAll(SCENARIO_RE)) {
158
+ const title = match[2].trim()
159
+ if (!title) continue
160
+ if (match[1] === 'describe') groups.push(title)
161
+ else scenarios.push(title)
162
+ }
163
+ return { groups: [...new Set(groups)], scenarios: [...new Set(scenarios)] }
164
+ }
165
+
166
+ /**
167
+ * Формує дані для JS-рендеру сценаріїв і детермінований payload для CRC.
168
+ * Test-код не потрапляє до LLM prompt: опис тестового usage лишається дослівним.
169
+ * @param {string} sourceAbs абсолютний шлях source-файлу
170
+ * @param {ReturnType<typeof buildTestEvidenceIndex>} index source↔tests index
171
+ * @returns {{ files: Array<{path:string,groups:string[],scenarios:string[]}>, crcPayload:string }} сценарії і повний CRC payload
172
+ */
173
+ export function testEvidenceForSource(sourceAbs, index) {
174
+ const tests = index.bySource.get(resolve(sourceAbs)) ?? []
175
+ if (tests.length === 0) return { files: [], crcPayload: '' }
176
+
177
+ const files = tests.map(test => ({ path: test.relPath, ...scenarioNames(test.content) }))
178
+ const crcPayload = tests.map(test => `\0${test.relPath}\0${test.content}`).join('')
179
+ return { files, crcPayload }
180
+ }
181
+
182
+ /**
183
+ * Детерміновано рендерить компактні підтверджені тестами сценарії у Markdown.
184
+ * Назви походять безпосередньо з `describe`/`test`/`it`, тому LLM не може їх
185
+ * перефразувати або додати неіснуючу поведінку; показуємо до пʼяти прикладів,
186
+ * а решту чесно рахуємо, щоб не дублювати весь test-suite у документації.
187
+ * @param {Array<{path:string, groups?:string[], scenarios:string[]}>} files повʼязані test-файли зі сценаріями
188
+ * @returns {string} вміст секції «Сценарії використання» без заголовка
189
+ */
190
+ export function renderTestScenarios(files) {
191
+ return files
192
+ .filter(test => test.scenarios.length > 0)
193
+ .map(test => {
194
+ const groups = (test.groups ?? []).slice(0, 2).join('; ')
195
+ const examples = test.scenarios.slice(0, 5).join('; ')
196
+ const rest = test.scenarios.length - 5
197
+ const scope = groups ? ` (${groups})` : ''
198
+ const more = rest > 0 ? `; ще ${rest}` : ''
199
+ return `- \`${test.path}\`${scope} — ${examples}${more}`
200
+ })
201
+ .join('\n')
202
+ }
203
+
204
+ /**
205
+ * Source-файли, на які посилається конкретний змінений test/spec-файл.
206
+ * @param {string} testAbs абсолютний шлях тесту
207
+ * @param {ReturnType<typeof buildTestEvidenceIndex>} index source↔tests index
208
+ * @returns {string[]} абсолютні source-шляхи
209
+ */
210
+ export function sourceFilesForTest(testAbs, index) {
211
+ return index.byTest.get(resolve(testAbs)) ?? []
212
+ }
@@ -1,5 +1,5 @@
1
1
  ---
2
- description: Файлова документація — обовʼязковий крок задачі (як lint); unified lint surface, concern doc-files/check — детект застарілості за CRC джерела + fix-by-default генерація local-only конвеєром (omlx)
2
+ description: Файлова документація — обовʼязковий крок задачі (як lint); unified lint surface, concern doc-files/check — детект застарілості за CRC source + повʼязаних тестів і fix-by-default генерація local-only конвеєром (omlx)
3
3
  globs: "**/*.{js,mjs,ts,vue,py,rs},**/docs/**"
4
4
  alwaysApply: true
5
5
  version: '2.0'
@@ -7,7 +7,37 @@ version: '2.0'
7
7
 
8
8
  Кожен кодовий файл (`.js .mjs .ts .vue .py .rs`, крім тестів і `.d.ts`) має **актуальну**
9
9
  файлову доку поряд: `<dir>/docs/<stem>.md`. Це **обовʼязковий крок кожної задачі**, нарівні
10
- з lint. Актуальність детермінується за **CRC** джерела, записаним у frontmatter доки.
10
+ з lint. Актуальність детермінується за **CRC evidence** source + повʼязані test/spec-файли,
11
+ записаним у frontmatter доки. Без повʼязаних тестів CRC лишається CRC самого source.
12
+
13
+ ## Тести як підтверджені usage-сценарії
14
+
15
+ Test/spec-файли не отримують власної файлової доки, але доповнюють документацію source-файлу,
16
+ якщо relative reference у тесті (`import`, `require`, dynamic import, `vi.mock` тощо)
17
+ резолвиться саме в цей source і звʼязок підтверджує naming/layout (`foo.test` → `foo` або
18
+ `module/tests/*` → module `main`/`index`). Це відсіює shared test helpers, які тест лише
19
+ використовує. JS-оркестратор бере назви `describe`/`test`/`it` і детерміновано рендерить один
20
+ компактний рядок на test-файл: до двох груп, пʼять дослівних прикладів і точний лічильник решти.
21
+ Test-код і сценарії не передаються
22
+ моделі, тож вона не може їх перефразувати або вигадати нові.
23
+
24
+ Зміна повʼязаного тесту змінює evidence CRC і робить source-доку stale. У per-file режимі
25
+ змінений тест reverse-map-иться до source-файлів, на які він посилається.
26
+
27
+ ## Авторські коментарі без LLM
28
+
29
+ Якщо мовний екстрактор знайшов змістовний file header і змістовний опис кожного public API,
30
+ doc-files збирає «Огляд» та «Публічний API» **дослівно**. Це працює для JSDoc (`/** */`),
31
+ rustdoc (`//!`/`///`) і Python docstring-ів. У документ потрапляють також детерміновані
32
+ «Сценарії використання» та «Гарантії поведінки».
33
+
34
+ JS обирає один із трьох режимів:
35
+
36
+ - `comment-only` — змістовний header вже пояснює потік; LLM і semantic judge не запускаються;
37
+ - `comment+behavior` — короткий header/pointer без достатнього API-контракту або явний складний flow у коді: LLM пише лише
38
+ коротку «Поведінку», не може змінити авторські секції, а judge перевіряє тільки цей додаток;
39
+ - `fallback` — header або хоча б один public API не має змістовного коментаря, тому лишається
40
+ звичний LLM-шлях.
11
41
 
12
42
  ## Одна команда — unified lint surface
13
43
 
@@ -25,13 +55,14 @@ unified lint surface (spec `docs/specs/2026-06-29-unified-lint-surface.md`) і
25
55
 
26
56
  ## Stale = `missing` ∪ `crc-mismatch`
27
57
 
28
- Дока застаріла, якщо її **немає** (`missing`) або `crc(джерело) ≠ crc` у frontmatter
58
+ Дока застаріла, якщо її **немає** (`missing`) або `crc(source + related tests) ≠ crc` у frontmatter
29
59
  (`crc-mismatch`). Це і є `violations`, які повертає `lint(ctx)` концерну.
30
60
 
31
- Алгоритм детекту (кандидати, ignore-дерево, CRC, реверс-мапінг доки→джерело) — у
61
+ Алгоритм детекту (кандидати, ignore-дерево, evidence CRC, реверс-мапінг доки/тесту→джерело) — у
32
62
  `docgen-scan/main.mjs` / `docgen-crc/main.mjs` / `docgen-ignore/main.mjs`, детектор — у
33
- `check/main.mjs` (`lint(ctx)`), fix — лише `check/fix-worker.mjs` (LLM-регенерація); тут —
34
- лише людинозрозумілий контракт, без дублювання логіки.
63
+ `docgen-test-context/main.mjs` / `check/main.mjs` (`lint(ctx)`), fix — лише
64
+ `check/fix-worker.mjs` (LLM-регенерація); тут — лише людинозрозумілий контракт, без
65
+ дублювання логіки.
35
66
 
36
67
  T0-штампу CRC для `crc-mismatch` **немає навмисно**: свіжий CRC поверх старого тексту
37
68
  назавжди маскує дрейф (CRC-гейт вважає доку актуальною і вона більше не регенерується).